feat(backend): 🔒 add CSP rules
This commit is contained in:
parent
d433c11035
commit
6a54a174a3
|
@ -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
30
src/csp/middleware.ts
Normal 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
52
src/csp/mod.ts
Normal 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)
|
||||||
|
}
|
14
src/utils.ts
14
src/utils.ts
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue