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:
- Deployed as a static website served with Nginx
- Containerized with Docker
- Using Google Tag Manager (for Google Analytics)
Implement as HTTP Response Header
There are two ways to implement CSP:
- HTTP
Content-Security-Policy
Response Header (Recommended) - 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.
- Use
set_secure_random_alphanum
from ngx_set_misc_module to generate nonce, which is cryptographically-strong random alphabetical string. See reference here . - Use
add_header
to add the CSP header. All CSP directives are written as individual variables for easier maintenance. - Use
sub_filter
to replace static__CSP_NONCE__
placeholder in the code.
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);
}
}