From 6a54a174a3121644c7f5f49d27f5399c0a764163 Mon Sep 17 00:00:00 2001 From: Julien Oculi Date: Tue, 9 Jul 2024 11:00:35 +0200 Subject: [PATCH] feat(backend): :lock: add CSP rules --- routes/_middleware.ts | 3 +++ src/csp/middleware.ts | 30 +++++++++++++++++++++++++ src/csp/mod.ts | 52 +++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 14 ++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 src/csp/middleware.ts create mode 100644 src/csp/mod.ts diff --git a/routes/_middleware.ts b/routes/_middleware.ts index 96c5cc7..21dd97b 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -1,4 +1,5 @@ import { FreshContext } from '$fresh/server.ts' +import { useCsp } from ':src/csp/middleware.ts' import { SessionStore } from ':src/session/mod.ts' import { getCookies, setCookie } from '@std/http/cookie' @@ -26,6 +27,8 @@ export async function handler(request: Request, ctx: FreshContext) { //See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSP //? fresh useCSP https://fresh.deno.dev/docs/examples/using-csp + await useCsp(request, response, ctx) + // Allow service worker to serve root scope if (ctx.url.pathname.endsWith('island-startserviceworker.js')) { response.headers.set('Service-Worker-Allowed', '/') diff --git a/src/csp/middleware.ts b/src/csp/middleware.ts new file mode 100644 index 0000000..f5aa806 --- /dev/null +++ b/src/csp/middleware.ts @@ -0,0 +1,30 @@ +import { FreshContext } from '$fresh/server.ts' +import { applyCspRulesWithNonce, CspRules } from ':src/csp/mod.ts' + +export function useCsp( + _request: Request, + response: Response, + ctx: FreshContext, +) { + const trustedDomains = ["'self'", 'https://git.cohabit.fr'] + + const cspRules: CspRules = { + defaultSrc: ["'none'"], + frameAncestors: ["'none'"], + upgradeInsecureRequests: true, + styleSrc: [...trustedDomains, "'unsafe-inline'"], //set nonce to inline script + manifestSrc: [`${ctx.url.origin}/manifest.json`], + imgSrc: [ + ...trustedDomains, + 'data:', + 'https://picsum.photos', + 'https://fastly.picsum.photos', + ], + fontSrc: [...trustedDomains, 'https://cdn.jsdelivr.net'], + scriptSrc: ["'self'", "'strict-dynamic'"], + connectSrc: ["'self'"], + formAction: ["'none'"], + } + + return applyCspRulesWithNonce(response, cspRules) +} diff --git a/src/csp/mod.ts b/src/csp/mod.ts new file mode 100644 index 0000000..e9639fd --- /dev/null +++ b/src/csp/mod.ts @@ -0,0 +1,52 @@ +import type { ContentSecurityPolicyDirectives } from '$fresh/runtime.ts' +import { getFreshNonce, toSnakeCase } from ':src/utils.ts' + +export type CspRules = ContentSecurityPolicyDirectives & { + upgradeInsecureRequests: true +} + +export function applyCspRules( + { headers }: { headers: Headers }, + rules: CspRules, +): void { + const rulesString: string[] = [] + + for (const rule in rules) { + const value = rules[rule as unknown as keyof CspRules] + const ruleName = toSnakeCase(rule) + + if (typeof value === 'boolean') { + rulesString.push(`${ruleName}`) + continue + } + + if (Array.isArray(value)) { + rulesString.push(`${ruleName} ${value.join(' ')}`) + continue + } + + if (typeof value === 'string') { + rulesString.push(`${ruleName} ${value}`) + continue + } + + throw TypeError(`unsupported csp rule "${rule}" with value (${value})`) + } + + headers.set('Content-Security-Policy', rulesString.join('; ')) +} + +export async function applyCspRulesWithNonce( + response: Response, + rules: CspRules, +): Promise { + // Get nonce from any html response + const nonce = await getFreshNonce(response) + + // Add nonce to script src if defined + if (nonce) { + rules.scriptSrc?.push(`'nonce-${nonce}'`) + } + + return applyCspRules(response, rules) +} diff --git a/src/utils.ts b/src/utils.ts index 71c0c7a..f35dbc1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -191,3 +191,17 @@ export function unwrapSignalOrValue(valueOrSignal: T | SignalLike): T { return valueOrSignal } + +export function toSnakeCase(str: string): string { + return str.replaceAll(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) +} + +export async function getFreshNonce(response: Response) { + if (!response.headers.get('Content-Type')?.startsWith('text/html')) { + return + } + + const source = await response.clone().text() + const match = source.match(/nonce="(?\w+)"/) + return match?.groups?.nonce +}