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 { 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', '/')
|
||||
|
|
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
|
||||
}
|
||||
|
||||
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