diff --git a/Dockerfile b/Dockerfile index 61da87a..a110d40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ RUN npm run build # Serve the static files using a lightweight web server FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +# nginx.conf is a template. An env var that is not set stays in it as ${...}, and nginx then fails to start. +ENV AUTH_HOST="" API_HOST="" +COPY nginx.conf /etc/nginx/templates/default.conf.template EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md index 1a080ee..9dfe24b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ To Be Played is split into three repositories: - Tailwind CSS 4, Radix UI primitives (shadcn/ui style components), Lucide icons - react-hook-form + zod for forms, i18next for translations (`public/i18n`) - Vitest + React Testing Library + jsdom -- Production image: nginx (Alpine), SPA fallback and long-term caching for hashed assets +- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy + (report-only for now) and security headers - CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image) ## Project structure @@ -90,7 +91,7 @@ npm run build ## Roadmap - Mood filter for "What to play next". -- A Content Security Policy in `nginx.conf`. +- Enforce the Content Security Policy (it runs in report-only mode now). ## License diff --git a/nginx.conf b/nginx.conf index 022f911..a70a83d 100644 --- a/nginx.conf +++ b/nginx.conf @@ -2,6 +2,15 @@ server { listen 80; 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 X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + # Keeps the headers above in the locations that add their own Cache-Control (nginx 1.29.3 and later). + add_header_inherit merge; + # Vite puts a content hash in these file names, so they never change. location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; diff --git a/src/csp.test.js b/src/csp.test.js new file mode 100644 index 0000000..c2b6802 --- /dev/null +++ b/src/csp.test.js @@ -0,0 +1,41 @@ +import {readdirSync, readFileSync} from 'node:fs'; +import path from 'node:path'; +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 directive = (name) => policy.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name} `)); + +const LINK_ONLY_HOSTS = ['store.steampowered.com']; + +const allows = (sources, host) => sources.split(' ').some((source) => + source === `https://${host}` || (source.startsWith('https://*.') && host.endsWith(source.slice('https://*'.length)))); + +describe('Content Security Policy in nginx.conf', () => { + it('allows every image host the code builds URLs for', () => { + const code = readdirSync(path.join(root, 'src'), {recursive: true}) + .filter((file) => /\.jsx?$/.test(file) && !/\.test\.jsx?$/.test(file) && !file.startsWith('test')) + .map((file) => readFileSync(path.join(root, 'src', file), 'utf8')) + .join('\n'); + const hosts = new Set([...code.matchAll(/`https:\/\/([a-z0-9.-]+)\//g)].map((match) => match[1])); + // Only links: a navigation needs no img-src. + LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host)); + // Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures. + ['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com'] + .forEach((host) => hosts.add(host)); + + for (const host of hosts) { + expect(allows(directive('img-src'), host), host).toBe(true); + } + }); + + it('lets the app reach Auth and Core, whose hosts come from the container env', () => { + expect(directive('connect-src')).toBe("connect-src 'self' https://${AUTH_HOST} https://${API_HOST}"); + }); + + it('allows no inline or foreign script', () => { + expect(directive('script-src')).toBe("script-src 'self'"); + expect(directive('frame-ancestors')).toBe("frame-ancestors 'none'"); + }); +});