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 "([^"]+)"/)[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('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'"); }); });