fix(csp): allow Google Fonts, blob workers, and jsDelivr in CSP directives

This commit is contained in:
neon
2026-05-14 19:03:11 -06:00
parent 3138cf3a4d
commit da5d6cb31f
4 changed files with 281 additions and 42 deletions
+1
View File
@@ -6,6 +6,7 @@
<link rel="icon" href="/pwa-icon-source.svg" type="image/svg+xml" /> <link rel="icon" href="/pwa-icon-source.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="BookOrbit" /> <meta name="apple-mobile-web-app-title" content="BookOrbit" />
@@ -0,0 +1,224 @@
import { describe, it, expect } from 'vitest';
import { parseBooleanEnv, parseTrustProxy, buildCspDirectives } from './bootstrap.utils';
describe('parseBooleanEnv', () => {
it('returns fallback when value is undefined', () => {
expect(parseBooleanEnv(undefined)).toBe(false);
expect(parseBooleanEnv(undefined, true)).toBe(true);
});
it('returns fallback when value is empty string', () => {
expect(parseBooleanEnv('')).toBe(false);
expect(parseBooleanEnv('', true)).toBe(true);
});
it('returns fallback when value is whitespace only', () => {
expect(parseBooleanEnv(' ')).toBe(false);
expect(parseBooleanEnv(' ', true)).toBe(true);
});
it.each(['false', 'FALSE', 'False', '0', 'no', 'NO', 'off', 'OFF'])('returns false for falsy string %s', (val) => {
expect(parseBooleanEnv(val)).toBe(false);
expect(parseBooleanEnv(val, true)).toBe(false);
});
it.each(['true', 'TRUE', 'True', '1', 'yes', 'YES', 'on', 'ON'])('returns true for truthy string %s', (val) => {
expect(parseBooleanEnv(val)).toBe(true);
expect(parseBooleanEnv(val, false)).toBe(true);
});
it('returns fallback for unrecognised value', () => {
expect(parseBooleanEnv('maybe')).toBe(false);
expect(parseBooleanEnv('maybe', true)).toBe(true);
expect(parseBooleanEnv('enabled')).toBe(false);
});
it('trims surrounding whitespace before evaluating', () => {
expect(parseBooleanEnv(' true ')).toBe(true);
expect(parseBooleanEnv(' false ')).toBe(false);
expect(parseBooleanEnv(' 1 ')).toBe(true);
expect(parseBooleanEnv(' 0 ')).toBe(false);
});
});
describe('parseTrustProxy', () => {
it('returns the default loopback string when value is undefined', () => {
expect(parseTrustProxy(undefined)).toBe('loopback,linklocal,uniquelocal');
});
it('returns the default loopback string when value is empty string', () => {
expect(parseTrustProxy('')).toBe('loopback,linklocal,uniquelocal');
});
it('returns the default loopback string when value is whitespace only', () => {
expect(parseTrustProxy(' ')).toBe('loopback,linklocal,uniquelocal');
});
it.each(['false', '0', 'no', 'off'])('returns false for falsy string %s', (val) => {
expect(parseTrustProxy(val)).toBe(false);
});
it.each(['true', '1', 'yes', 'on'])('returns true for truthy string %s', (val) => {
expect(parseTrustProxy(val)).toBe(true);
});
it('parses non-negative integer hop counts', () => {
expect(parseTrustProxy('0')).toBe(false); // '0' is a falsy string, handled before hop-count check
expect(parseTrustProxy('1')).toBe(true); // '1' is a truthy string
expect(parseTrustProxy('2')).toBe(2);
expect(parseTrustProxy('10')).toBe(10);
});
it('returns the raw string for arbitrary proxy values', () => {
expect(parseTrustProxy('127.0.0.1')).toBe('127.0.0.1');
expect(parseTrustProxy('10.0.0.0/8')).toBe('10.0.0.0/8');
expect(parseTrustProxy('loopback')).toBe('loopback');
});
it('returns the raw string for decimal numbers (not valid integers)', () => {
expect(parseTrustProxy('1.5')).toBe('1.5');
expect(parseTrustProxy('2.9')).toBe('2.9');
});
it('returns the raw string for negative integers', () => {
expect(parseTrustProxy('-1')).toBe('-1');
expect(parseTrustProxy('-5')).toBe('-5');
});
it('trims surrounding whitespace before evaluating', () => {
expect(parseTrustProxy(' true ')).toBe(true);
expect(parseTrustProxy(' false ')).toBe(false);
expect(parseTrustProxy(' 127.0.0.1 ')).toBe('127.0.0.1');
});
});
describe('buildCspDirectives', () => {
it('returns directives with required sources when Cloudflare Insights is disabled', () => {
const directives = buildCspDirectives({ allowCloudflareInsights: false });
expect(directives.defaultSrc).toEqual(["'self'"]);
expect(directives.scriptSrc).toContain("'self'");
expect(directives.scriptSrc).toContain("'wasm-unsafe-eval'");
expect(directives.objectSrc).toEqual(["'none'"]);
expect(directives.upgradeInsecureRequests).toBeNull();
});
it('uses defaults when called with no arguments', () => {
const directives = buildCspDirectives();
expect(directives.scriptSrc).toContain("'self'");
expect(directives.scriptSrc).toContain("'wasm-unsafe-eval'");
expect(directives.connectSrc).not.toContain('https://cloudflareinsights.com');
});
describe('styleSrc', () => {
it("includes 'self', 'unsafe-inline', blob:, and Google Fonts stylesheet domain", () => {
const { styleSrc } = buildCspDirectives();
expect(styleSrc).toContain("'self'");
expect(styleSrc).toContain("'unsafe-inline'");
expect(styleSrc).toContain('blob:');
expect(styleSrc).toContain('https://fonts.googleapis.com');
});
});
describe('fontSrc', () => {
it("includes 'self', data:, blob:, and Google Fonts gstatic domain", () => {
const { fontSrc } = buildCspDirectives();
expect(fontSrc).toContain("'self'");
expect(fontSrc).toContain('data:');
expect(fontSrc).toContain('blob:');
expect(fontSrc).toContain('https://fonts.gstatic.com');
});
});
describe('workerSrc', () => {
it("includes 'self' and blob: to allow PDF web workers", () => {
const { workerSrc } = buildCspDirectives();
expect(workerSrc).toContain("'self'");
expect(workerSrc).toContain('blob:');
});
});
describe('connectSrc', () => {
it('always includes jsDelivr CDN for embedpdf stamps manifest', () => {
const { connectSrc } = buildCspDirectives({ allowCloudflareInsights: false });
expect(connectSrc).toContain("'self'");
expect(connectSrc).toContain('ws:');
expect(connectSrc).toContain('wss:');
expect(connectSrc).toContain('https://cdn.jsdelivr.net');
});
it('does not include Cloudflare Insights connect endpoint when disabled', () => {
const { connectSrc } = buildCspDirectives({ allowCloudflareInsights: false });
expect(connectSrc).not.toContain('https://cloudflareinsights.com');
});
});
describe('scriptSrc with Cloudflare Insights', () => {
it('adds Cloudflare Insights script domain when enabled', () => {
const { scriptSrc } = buildCspDirectives({ allowCloudflareInsights: true });
expect(scriptSrc).toContain("'self'");
expect(scriptSrc).toContain("'wasm-unsafe-eval'");
expect(scriptSrc).toContain('https://static.cloudflareinsights.com');
});
it('adds Cloudflare Insights connect domain when enabled', () => {
const { connectSrc } = buildCspDirectives({ allowCloudflareInsights: true });
expect(connectSrc).toContain('https://cdn.jsdelivr.net');
expect(connectSrc).toContain('https://cloudflareinsights.com');
});
it('does not add Cloudflare domains when disabled', () => {
const { scriptSrc, connectSrc } = buildCspDirectives({ allowCloudflareInsights: false });
expect(scriptSrc).not.toContain('https://static.cloudflareinsights.com');
expect(connectSrc).not.toContain('https://cloudflareinsights.com');
});
});
describe('scriptSrc WASM', () => {
it("always includes 'wasm-unsafe-eval' to allow WebAssembly compilation", () => {
expect(buildCspDirectives({ allowCloudflareInsights: false }).scriptSrc).toContain("'wasm-unsafe-eval'");
expect(buildCspDirectives({ allowCloudflareInsights: true }).scriptSrc).toContain("'wasm-unsafe-eval'");
});
});
describe('static directives', () => {
it('imgSrc allows self, data, and blob', () => {
const { imgSrc } = buildCspDirectives();
expect(imgSrc).toEqual(["'self'", 'data:', 'blob:']);
});
it('mediaSrc allows self, data, and blob', () => {
const { mediaSrc } = buildCspDirectives();
expect(mediaSrc).toEqual(["'self'", 'data:', 'blob:']);
});
it('frameSrc allows self and blob', () => {
const { frameSrc } = buildCspDirectives();
expect(frameSrc).toEqual(["'self'", 'blob:']);
});
it('frameAncestors restricts to self only', () => {
const { frameAncestors } = buildCspDirectives();
expect(frameAncestors).toEqual(["'self'"]);
});
it('objectSrc is none', () => {
const { objectSrc } = buildCspDirectives();
expect(objectSrc).toEqual(["'none'"]);
});
it('upgradeInsecureRequests is null', () => {
const { upgradeInsecureRequests } = buildCspDirectives();
expect(upgradeInsecureRequests).toBeNull();
});
});
});
@@ -0,0 +1,54 @@
const DEFAULT_TRUST_PROXY = 'loopback,linklocal,uniquelocal';
export function parseTrustProxy(value: string | undefined): string | boolean | number {
const raw = value?.trim();
if (!raw) return DEFAULT_TRUST_PROXY;
const normalized = raw.toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
const hopCount = Number(raw);
if (Number.isInteger(hopCount) && hopCount >= 0) return hopCount;
return raw;
}
export function parseBooleanEnv(value: string | undefined, fallback = false): boolean {
const raw = value?.trim();
if (!raw) return fallback;
const normalized = raw.toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
return fallback;
}
export interface CspOptions {
allowCloudflareInsights?: boolean;
}
const CLOUDFLARE_INSIGHTS_SCRIPT_SRC = 'https://static.cloudflareinsights.com';
const CLOUDFLARE_INSIGHTS_CONNECT_SRC = 'https://cloudflareinsights.com';
export function buildCspDirectives(options: CspOptions = {}) {
const { allowCloudflareInsights = false } = options;
const scriptSrc = ["'self'", "'wasm-unsafe-eval'", ...(allowCloudflareInsights ? [CLOUDFLARE_INSIGHTS_SCRIPT_SRC] : [])];
const connectSrc = ["'self'", 'ws:', 'wss:', 'https://cdn.jsdelivr.net', ...(allowCloudflareInsights ? [CLOUDFLARE_INSIGHTS_CONNECT_SRC] : [])];
return {
defaultSrc: ["'self'"],
scriptSrc,
styleSrc: ["'self'", "'unsafe-inline'", 'blob:', 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc,
mediaSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:', 'blob:', 'https://fonts.gstatic.com'],
objectSrc: ["'none'"],
frameSrc: ["'self'", 'blob:'],
frameAncestors: ["'self'"],
workerSrc: ["'self'", 'blob:'],
upgradeInsecureRequests: null,
};
}
+2 -42
View File
@@ -12,40 +12,12 @@ import fastifyMultipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import fastifyHelmet from '@fastify/helmet'; import fastifyHelmet from '@fastify/helmet';
import fastifyCompress from '@fastify/compress'; import fastifyCompress from '@fastify/compress';
import { parseBooleanEnv, parseTrustProxy, buildCspDirectives } from './common/utils/bootstrap.utils';
const MAX_COVER_BYTES = 20 * 1024 * 1024; const MAX_COVER_BYTES = 20 * 1024 * 1024;
const DEFAULT_TRUST_PROXY = 'loopback,linklocal,uniquelocal';
const CLOUDFLARE_INSIGHTS_SCRIPT_SRC = 'https://static.cloudflareinsights.com';
const CLOUDFLARE_INSIGHTS_CONNECT_SRC = 'https://cloudflareinsights.com';
function parseTrustProxy(value: string | undefined) {
const raw = value?.trim();
if (!raw) return DEFAULT_TRUST_PROXY;
const normalized = raw.toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
const hopCount = Number(raw);
if (Number.isInteger(hopCount) && hopCount >= 0) return hopCount;
return raw;
}
function parseBooleanEnv(value: string | undefined, fallback = false): boolean {
const raw = value?.trim();
if (!raw) return fallback;
const normalized = raw.toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
return fallback;
}
async function bootstrap() { async function bootstrap() {
const allowCloudflareInsights = parseBooleanEnv(process.env.CSP_ALLOW_CLOUDFLARE_INSIGHTS, false); const allowCloudflareInsights = parseBooleanEnv(process.env.CSP_ALLOW_CLOUDFLARE_INSIGHTS, false);
const cspScriptSrc = ["'self'", ...(allowCloudflareInsights ? [CLOUDFLARE_INSIGHTS_SCRIPT_SRC] : [])];
const cspConnectSrc = ["'self'", 'ws:', 'wss:', ...(allowCloudflareInsights ? [CLOUDFLARE_INSIGHTS_CONNECT_SRC] : [])];
const adapter = new FastifyAdapter({ logger: false, trustProxy: parseTrustProxy(process.env.TRUST_PROXY) }); const adapter = new FastifyAdapter({ logger: false, trustProxy: parseTrustProxy(process.env.TRUST_PROXY) });
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { bufferLogs: true }); const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { bufferLogs: true });
@@ -97,19 +69,7 @@ async function bootstrap() {
await app.register(fastifyHelmet as never, { await app.register(fastifyHelmet as never, {
crossOriginOpenerPolicy: false, crossOriginOpenerPolicy: false,
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: buildCspDirectives({ allowCloudflareInsights }),
defaultSrc: ["'self'"],
scriptSrc: cspScriptSrc,
styleSrc: ["'self'", "'unsafe-inline'", 'blob:'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: cspConnectSrc,
mediaSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:', 'blob:'],
objectSrc: ["'none'"],
frameSrc: ["'self'", 'blob:'],
frameAncestors: ["'self'"],
upgradeInsecureRequests: null,
},
}, },
}); });