From 7d9cf907fcf709435bdab2eabcb7164d1b3136c0 Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 26 Sep 2026 07:15:14 +0200 Subject: [PATCH] feat(security): enforce the Content Security Policy The report-only run showed no violation in production. --- README.md | 3 +-- nginx.conf | 3 +-- src/csp.test.js | 7 ++++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a4d8bdc..cc76fd1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ To Be Played is split into three repositories: - react-hook-form + zod for forms, i18next for translations (`public/i18n`) - Vitest + React Testing Library + jsdom - Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy - (report-only for now) and security headers + and security headers - CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image) ## Project structure @@ -91,7 +91,6 @@ npm run build ## Roadmap - Mood filter for "What to play next". -- Enforce the Content Security Policy (it runs in report-only mode now). ## License diff --git a/nginx.conf b/nginx.conf index a70a83d..814b11b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,8 +3,7 @@ server { root /usr/share/nginx/html; # An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile). - # Report-Only until production shows no violation in the browser console; then rename the header. - add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; diff --git a/src/csp.test.js b/src/csp.test.js index c2b6802..78edb4e 100644 --- a/src/csp.test.js +++ b/src/csp.test.js @@ -4,7 +4,7 @@ import {describe, expect, it} from 'vitest'; const root = path.resolve(import.meta.dirname, '..'); const nginx = readFileSync(path.join(root, 'nginx.conf'), 'utf8'); -const policy = nginx.match(/Content-Security-Policy(?:-Report-Only)? "([^"]+)"/)[1]; +const policy = nginx.match(/Content-Security-Policy "([^"]+)"/)[1]; const directive = (name) => policy.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name} `)); const LINK_ONLY_HOSTS = ['store.steampowered.com']; @@ -34,6 +34,11 @@ describe('Content Security Policy in nginx.conf', () => { expect(directive('connect-src')).toBe("connect-src 'self' https://${AUTH_HOST} https://${API_HOST}"); }); + it('is enforced, not only reported', () => { + expect(nginx).toMatch(/add_header Content-Security-Policy "/); + expect(nginx).not.toContain('Content-Security-Policy-Report-Only'); + }); + it('allows no inline or foreign script', () => { expect(directive('script-src')).toBe("script-src 'self'"); expect(directive('frame-ancestors')).toBe("frame-ancestors 'none'");