Compare commits

...

9 commits

13 changed files with 241 additions and 71 deletions

5
cert/localhost-key.pem Normal file
View file

@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgWGTG/X7/vLtzInr9
k482dpSC7bZvV3us2FkWqJFe1XWhRANCAASmrapDgQf6kVlMPsSOHeoCB+Z0+P0L
K3x95uUa6igELdVtXJPaSJJksytjXWGCpsn9RhkCO2XKdSvqW/9QVcYn
-----END PRIVATE KEY-----

22
cert/localhost.pem Normal file
View file

@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDkTCCAfmgAwIBAgIRAItJkjr6cwdtBbymbjslLOowDQYJKoZIhvcNAQELBQAw
gZMxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE0MDIGA1UECwwrSk9U
U1ItVklWT1xKdWxpZW5ASk9UU1ItVklWTyAoSnVsaWVuIE9jdWxpKTE7MDkGA1UE
AwwybWtjZXJ0IEpPVFNSLVZJVk9cSnVsaWVuQEpPVFNSLVZJVk8gKEp1bGllbiBP
Y3VsaSkwHhcNMjQwNzA2MTUwNjE0WhcNMjYxMDA2MTUwNjE0WjBfMScwJQYDVQQK
Ex5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxNDAyBgNVBAsMK0pPVFNS
LVZJVk9cSnVsaWVuQEpPVFNSLVZJVk8gKEp1bGllbiBPY3VsaSkwWTATBgcqhkjO
PQIBBggqhkjOPQMBBwNCAASmrapDgQf6kVlMPsSOHeoCB+Z0+P0LK3x95uUa6igE
LdVtXJPaSJJksytjXWGCpsn9RhkCO2XKdSvqW/9QVcYno14wXDAOBgNVHQ8BAf8E
BAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHwYDVR0jBBgwFoAUmZZ5b76pxQAu
0prvUf+X+pZICf0wFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUA
A4IBgQC7Fro6IEMsPu2I4qlhtzWUCVogSUE1PeMCVNxy6HzpVHw1oKiVQJf32A4m
VLfGQLkXb2pmM7/Ph/owAsH/RMRFXO44vLeJ+j0aMJHxl9m6W6QJJU++FtpMJ0lX
iot0Ojy6xzSjmaLw9eqBkptXRRvLkbglVNQI/LuEg2JRj1zmYaPsTXpHkm+pOEoM
l+3ik9UPh04hCBhlCxIP6PJMZ8qfU51nfQA7P+9PB1FJuzuhi7goEdkUGCWCHGJl
CTefaInBiFNthXXHMmE6RdAqUUGfzkY9VcfjoWfk30UMF+yBibXJOYa1v59IR+rw
4fijUlujOastp4TYD3W0MuL9HT/XeDChRgNjwyTRrl/Lc4sAvb8Dm7LeC/CbqPlX
Y1uy31GDBfydERjRIwe8qp69+rysKHmDfyjH932o6ENVEWMdwvNly6MC2SsZHHQd
jj/O04tWmdN7gtEkmxZfGTGSmx2GUPfJssNEH8pLYxU/MUENAOk2cl/2SIeziaf8
gNtrzYA=
-----END CERTIFICATE-----

8
dev.ts
View file

@ -5,4 +5,10 @@ import config from './fresh.config.ts'
import '$std/dotenv/load.ts' import '$std/dotenv/load.ts'
await dev(import.meta.url, './main.ts', config) await dev(import.meta.url, './main.ts', {
...config,
server: {
cert: await Deno.readTextFile('./cert/localhost.pem'),
key: await Deno.readTextFile('./cert/localhost-key.pem'),
},
})

View file

@ -1,6 +1,9 @@
import { FreshContext } from '$fresh/server.ts' import { FreshContext } from '$fresh/server.ts'
import { useCsp } from ':src/csp/middleware.ts'
import { useSecurityHeaders } from ':src/security_headers/middleware.ts'
import { useServiceworker } from ':src/serviceworker/middleware.ts'
import { useSession } from ':src/session/middleware.ts'
import { SessionStore } from ':src/session/mod.ts' import { SessionStore } from ':src/session/mod.ts'
import { getCookies, setCookie } from '@std/http/cookie'
export async function handler(request: Request, ctx: FreshContext) { export async function handler(request: Request, ctx: FreshContext) {
// Update fresh context state with session // Update fresh context state with session
@ -9,69 +12,11 @@ export async function handler(request: Request, ctx: FreshContext) {
// Get response // Get response
const response = await ctx.next() const response = await ctx.next()
//Add security headers // Use custom middleware hooks
// See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_strict_transport_security useSecurityHeaders(request, response, ctx)
response.headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload') await useCsp(request, response, ctx)
response.headers.set('Content-Security-Policy', "frame-ancestors 'none'; upgrade-insecure-requests") useSession(request, response, ctx)
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Referrer_policy useServiceworker(request, response, ctx)
response.headers.set('Referrer-Policy', 'no-referrer, strict-origin-when-cross-origin')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/MIME_types
response.headers.set('X-Content-Type-Options', 'nosniff')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Clickjacking
response.headers.set('X-Frame-Options', 'DENY')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CORP
response.headers.set('Cross-Origin-Resource-Policy', 'same-origin')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
//? SRI plugin for non local resources only ?
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSP
//? fresh useCSP https://fresh.deno.dev/docs/examples/using-csp
// Allow service worker to serve root scope
if (ctx.url.pathname.endsWith('island-startserviceworker.js')) {
response.headers.set('Service-Worker-Allowed', '/')
}
// Start session
if (SessionStore.getFromRequest(request) === undefined) {
// Clear outdated cookies
for (const cookie in getCookies(request.headers)) {
setCookie(response.headers, {
name: cookie,
value: '',
path: '/',
expires: 0,
})
}
// Create new session
const session = SessionStore.createSession()
ctx.state = { ...ctx.state, session }
// Set session cookie
setCookie(response.headers, {
name: '__Secure-SESSION',
value: session.uuid,
httpOnly: true,
sameSite: 'Strict',
secure: true,
path: '/',
expires: SessionStore.maxAge,
})
// Set csrf
const csrf = crypto.randomUUID()
session.set('_csrf', csrf)
setCookie(response.headers, {
name: '__Host-CSRF',
value: csrf,
httpOnly: false,
sameSite: 'Strict',
secure: true,
path: '/',
expires: SessionStore.maxAge,
})
}
return response return response
} }

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

@ -0,0 +1,32 @@
import { FreshContext } from '$fresh/server.ts'
import { applyCspRulesWithNonce, CspRules } from ':src/csp/mod.ts'
export function useCsp(
_request: Request,
response: Response,
ctx: FreshContext,
) {
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSP
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

@ -1,13 +1,15 @@
import { Db, Group } from '@cohabit/ressources_manager/mod.ts' import { Db, Group } from '@cohabit/ressources_manager/mod.ts'
// Import Datas // Import Datas
import groups from './mock/groups.json' with { type: 'json' } import { exists } from '$std/fs/exists.ts'
import users from './mock/users.json' with { type: 'json' } import { ensureDir } from '$std/fs/mod.ts'
import { User } from '@cohabit/ressources_manager/src/models/mod.ts' import { User } from '@cohabit/ressources_manager/src/models/mod.ts'
import { MailAddress } from '@cohabit/ressources_manager/types.ts' import { MailAddress } from '@cohabit/ressources_manager/types.ts'
import { exists } from '$std/fs/exists.ts' import groups from ":src/db/mock/groups.json" with { type: 'json' }
import users from ":src/db/mock/users.json" with { type: 'json' }
const dbPath = './_fresh/db.sqlite' await ensureDir('./cache')
const dbPath = './cache/db.sqlite'
const dbExist = await exists(dbPath) const dbExist = await exists(dbPath)
export const db = await Db.init(dbPath) export const db = await Db.init(dbPath)

View file

@ -0,0 +1,26 @@
import { FreshContext } from '$fresh/server.ts'
export function useSecurityHeaders(
_request: Request,
response: Response,
_ctx: FreshContext,
) {
// See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_strict_transport_security
response.headers.set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload',
)
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Referrer_policy
response.headers.set(
'Referrer-Policy',
'no-referrer, strict-origin-when-cross-origin',
)
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/MIME_types
response.headers.set('X-Content-Type-Options', 'nosniff')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Clickjacking
response.headers.set('X-Frame-Options', 'DENY')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CORP
response.headers.set('Cross-Origin-Resource-Policy', 'same-origin')
//See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
//? SRI plugin for non local resources only ?
}

View file

@ -0,0 +1,12 @@
import { FreshContext } from '$fresh/server.ts'
export function useServiceworker(
_request: Request,
response: Response,
ctx: FreshContext,
) {
// Allow service worker to serve root scope
if (ctx.url.pathname.endsWith('island-startserviceworker.js')) {
response.headers.set('Service-Worker-Allowed', '/')
}
}

53
src/session/middleware.ts Normal file
View file

@ -0,0 +1,53 @@
import { FreshContext } from '$fresh/server.ts'
import { SessionStore } from ':src/session/mod.ts'
import { getCookies, setCookie } from 'jsr:@std/http@^0.224.4/cookie'
export function useSession(
request: Request,
response: Response,
ctx: FreshContext,
) {
// Check if session already started
if (SessionStore.getFromRequest(request) !== undefined) {
return
}
// Clear outdated cookies
for (const cookie in getCookies(request.headers)) {
setCookie(response.headers, {
name: cookie,
value: '',
path: '/',
expires: 0,
})
}
// Create new session
const session = SessionStore.createSession()
ctx.state = { ...ctx.state, session }
// Set session cookie
setCookie(response.headers, {
name: '__Secure-SESSION',
value: session.uuid,
httpOnly: true,
sameSite: 'Strict',
secure: true,
path: '/',
expires: SessionStore.maxAge,
})
// Set csrf
const csrf = crypto.randomUUID()
session.set('_csrf', csrf)
setCookie(response.headers, {
name: '__Host-CSRF',
value: csrf,
httpOnly: false,
sameSite: 'Strict',
secure: true,
path: '/',
expires: SessionStore.maxAge,
})
}

View file

@ -47,7 +47,7 @@ export class SessionStore {
} }
static getFromRequest(request: Request): Session | undefined { static getFromRequest(request: Request): Session | undefined {
const sessionId = getCookies(request.headers)['_SESSION'] ?? '' const sessionId = getCookies(request.headers)['__Secure-SESSION'] ?? ''
return this.getSession(sessionId) return this.getSession(sessionId)
} }

View file

@ -184,7 +184,8 @@ input[type='checkbox'] {
.cta:focus-visible, .cta:focus-visible,
.button:hover, .button:hover,
.button:focus-visible { .button:focus-visible {
background-color: var(--lime-1); background-color: var(--_background-color);
font-weight: 500;
color: var(--_accent-color); color: var(--_accent-color);
box-shadow: 0 0 0 0 var(--_accent-color); box-shadow: 0 0 0 0 var(--_accent-color);
} }

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
}