Public Access
feat(security): add a report-only CSP and security headers
nginx.conf is now an nginx template, so connect-src takes AUTH_HOST and API_HOST from the container env. The image sets both to empty by default, so nginx still starts without them. Report-only until production shows no violation. Also nosniff, Referrer-Policy and Permissions-Policy.
This commit is contained in:
1 parent
f8e6c2c970
commit
6edb53162e
4 files changed
+56
-3
No files matched your search
+3
-1
@@ -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;"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user