Content Security Policy

Apr 5, 2025    m. Apr 7, 2025    #web  

Content Security Policy (CSP) is used as a defense against cross-site scripting (XSS).

This post explains how to protect a website with the following setup:

Implement as HTTP Response Header

There are two ways to implement CSP:

  1. HTTP Content-Security-Policy Response Header (Recommended)
  2. As a http-equiv attribute of a <meta> element

Implementing as HTTP response header is preferred because it provides flexibility to use nonce . In the use case here, nonce is recommended because Google Tag Manager (GTM) inline script is considered as a CSP violation.

Use Nginx to set CSP Response Header

Given the website is static, nonce cannot be dynamically generated by the site itself. Instead, we could use Nginx for that.

Below is a sample nginx.conf file implementation.

server {
  listen 80;

  set $dynamic_csp_domain "https://prod.wltheng.com https://dev.wltheng.com"

  set_secure_random_alphanum $cspNonce 32;

  set $csp_default      "default-src 'self';";
  set $csp_script       "script-src 'self' 'nonce-${cspNonce}' https://prod.wltheng.com https://dev.wltheng.com https://www.google-analytics.com https://www.googletagmanager.com https://analytics.google.com;";
  set $csp_connect      "connect-src 'self' https://prod.wltheng.com https://dev.wltheng.com https://www.google-analytics.com https://www.googletagmanager.com https://analytics.google.com;";
  set $csp_worker       "worker-src blob:;";
  set $csp_object       "object-src 'none';";
  set $csp_frame_src    "frame-src 'self' https://www.googletagmanager.com https://www.youtube.com;";
  set $csp_img          "img-src data: blob: 'self' https://prod.wltheng.com https://dev.wltheng.com https://*.google-analytics.com https://*.googletagmanager.com;";
  set $csp_style        "style-src 'self' 'unsafe-inline' fonts.googleapis.com;";
  set $csp_font         "font-src 'self' fonts.gstatic.com;";
  set $csp_base         "base-uri 'self';";

  location / {
    root /usr/share/nginx/html;
    index index.html index.htm;
    try_files $uri $uri/ /index.html =404;

    add_header Content-Security-Policy "${csp_default} ${csp_script} ${csp_connect} ${csp_worker} ${csp_object} ${csp_frame_src} ${csp_img} ${csp_style} ${csp_font} ${csp_base}" always;

    sub_filter '__CSP_NONCE__' $cspNonce;
    sub_filter_once off;
  }
}

style-src ‘unsafe-inline’

Note that having 'unsafe-inline' at style-src directive is not recommended. Though, it is better than having it as script-src .

Hide Non-Production Domain

In the sample above, some directives contain both production and non-production domains.

set $csp_script "script-src 'self' 'nonce-${cspNonce}' https://prod.wltheng.com https://dev.wltheng.com <redacted>";

To hide non-production domain from the production CSP header, we could dynamically set the domain based on environment variable using set_by_lua_block from the ngx_http_lua module.

set_by_lua_block $dynamic_csp_domain {
  local env = os.getenv("DEPLOY_ENV")
  if env ~= "prd" then
    return "https://dev.wltheng.com"
  else
    return "https://prod.wltheng.com"
  end
}

set $csp_script "script-src 'self' 'nonce-${cspNonce}' ${dynamic_csp_domain} https://www.google-analytics.com https://www.googletagmanager.com https://analytics.google.com;";

Let’s say if the container is deployed to Kubernetes, the environment variable can be set for the pod.

env:
  - name: DEPLOY_ENV
    value: "prd"

Add Nginx Modules

If using docker-nginx as the base container, it does not come with some modules mentioned above.

Referring to the guide in the documentation here , modules can be enabled by building a custom image.

$ docker build \
--platform=linux/amd64 \
--build-arg ENABLED_MODULES="ndk lua headers-more set-misc subs-filter" \
--build-arg NGINX_FROM_IMAGE=nginx:1.26.1-alpine \
-t wltheng.com/custom-docker-nginx:1.26.1-alpine \
-f Dockerfile.alpine . 2>&1 | tee build.log

It is advisible to build this image in a server with sufficient allocated memory, otherwise it could randomly fail when building some of the modules (even when using M1 Pro with 24g memory allocated to the build). In case the memory appears to be a bottleneck, try specifying --memory=32g.

The Nginx images seems to always come with several modules despite not specified. Below is the list of modules found in the output image, where we can see geoip and few other modules are present.

ndk_http_module-debug.so
ndk_http_module.so
ngx_http_geoip_module-debug.so
ngx_http_geoip_module.so
ngx_http_headers_more_filter_module-debug.so
ngx_http_headers_more_filter_module.so
ngx_http_image_filter_module-debug.so
ngx_http_image_filter_module.so
ngx_http_js_module-debug.so
ngx_http_js_module.so
ngx_http_lua_module-debug.so
ngx_http_lua_module.so
ngx_http_set_misc_module-debug.so
ngx_http_set_misc_module.so
ngx_http_subs_filter_module-debug.so
ngx_http_subs_filter_module.so
ngx_http_xslt_filter_module-debug.so
ngx_http_xslt_filter_module.so
ngx_stream_geoip_module-debug.so
ngx_stream_geoip_module.so
ngx_stream_js_module-debug.so
ngx_stream_js_module.so
ngx_stream_lua_module-debug.so
ngx_stream_lua_module.so

These modules need to be loaded at the top-level Nginx configuration file (/etc/nginx/nginx.conf/) to be effective.

The configuration can be customized using a sed command in the Dockerfile.

# `load_module` commands need to be inserted to the top-level Nginx configuration file (/etc/nginx/nginx.conf)
# because modules need to be loaded before any other configuration (e.g. /etc/nginx/conf.d/default.conf) processing occurs.
# Note: The modules would be loaded in the same order listed below.
RUN sed -i \
    -e '1i load_module modules/ndk_http_module.so;' \
    -e '1i load_module modules/ngx_http_lua_module.so;' \
    -e '1i load_module modules/ngx_http_set_misc_module.so;' \
    -e '1i load_module modules/ngx_http_headers_more_filter_module.so;' \
    -e '1i load_module modules/ngx_http_subs_filter_module.so;' \
    -e 'env DEPLOY_ENV;' \
    /etc/nginx/nginx.conf

CSP Reporting

To refine the policy from time to time, comprehensive testing and data collection are essential for understanding the web application behaviours. Since CSP violations can occur across any pages within the application, establishing a robust feedback mechanism is crucial for thorough testing and monitoring.

The recommended method for reporting CSP violations is to use the Reporting API , declaring endpoints in Reporting-Endpoints and specifying one of them as the CSP reporting target using the Content-Security-Policy header’s report-to directive.

set $csp_report "report-to csp-endpoint;";
add_header Content-Security-Policy "${csp_default} ${csp_script} ${csp_connect} ${csp_worker} ${csp_object} ${csp_frame_src} ${csp_img} ${csp_style} ${csp_font} ${csp_base} ${csp_report}" always;
add_header Reporting-Endpoints 'csp-endpoint="https://wltheng.com/csp-report"' always;

One way to implement the Reporting API is to create an API that accepts a dynamic object and persists it at Elasticsearch, then view it via Kibana. Below is a sample snippet of Spring Boot implementation.

@PostMapping("/csp-report")
public void cspReports(@RequestBody List<Map<String,Object>> contents) {
    String elasticsearchEndpoint = "https://" + elasticsearchUrl + "/csp-reports/_doc";
    var header = new HttpHeaders();
    header.setBasicAuth(elasticAuthentication);
    header.setContentType(MediaType.APPLICATION_JSON);
    for (Map<String, Object> report : input) {
        report.putIfAbsent("timestamp", TemporalUtils.asString(TemporalUtils.now()));
        var request = new HttpEntity<>(report, header);
        restTemplate.postForObject(elasticsearchEndpoint, request, String.class);
    }
}