mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(csp): allow Google Fonts, blob workers, and jsDelivr in CSP directives
This commit is contained in:
@@ -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
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user