feat(backend): 🔒 add CSP rules

This commit is contained in:
Julien Oculi 2024-07-09 11:00:35 +02:00
parent d433c11035
commit 6a54a174a3
4 changed files with 99 additions and 0 deletions

View file

@ -1,4 +1,5 @@
import { FreshContext } from '$fresh/server.ts' import { FreshContext } from '$fresh/server.ts'
import { useCsp } from ':src/csp/middleware.ts'
import { SessionStore } from ':src/session/mod.ts' import { SessionStore } from ':src/session/mod.ts'
import { getCookies, setCookie } from '@std/http/cookie' 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 //See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSP
//? fresh useCSP https://fresh.deno.dev/docs/examples/using-csp //? fresh useCSP https://fresh.deno.dev/docs/examples/using-csp
await useCsp(request, response, ctx)
// Allow service worker to serve root scope // Allow service worker to serve root scope
if (ctx.url.pathname.endsWith('island-startserviceworker.js')) { if (ctx.url.pathname.endsWith('island-startserviceworker.js')) {
response.headers.set('Service-Worker-Allowed', '/') response.headers.set('Service-Worker-Allowed', '/')

30
src/csp/middleware.ts Normal file
View file

@ -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)
}

52
src/csp/mod.ts Normal file
View file

@ -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<void> {
// 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)
}

View file

@ -191,3 +191,17 @@ export function unwrapSignalOrValue<T>(valueOrSignal: T | SignalLike<T>): T {
return valueOrSignal 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="(?<nonce>\w+)"/)
return match?.groups?.nonce
}