From 9d3079581b2967dd5272a9f06c92b514fc6e57e5 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Tue, 26 May 2026 11:28:43 +0300 Subject: [PATCH 1/3] Move CSRF handling into security middleware --- apps/blog-next/app/api/login/route.ts | 1 - apps/blog-next/app/api/register/route.ts | 1 - apps/blog-next/app/login/actions.ts | 1 - apps/blog-next/app/login/page.tsx | 1 - apps/blog-next/app/register/actions.ts | 1 - apps/blog-next/app/register/page.tsx | 1 - apps/blog-next/config/security.ts | 2 +- apps/blog-next/proxy.ts | 25 +- apps/blog-next/tests/login-page.test.mjs | 1 - apps/blog-next/tests/register-page.test.mjs | 3 - apps/blog-nuxt/app/pages/login/index.vue | 1 - apps/blog-nuxt/app/pages/register/index.vue | 1 - apps/blog-nuxt/config/security.ts | 2 +- apps/blog-nuxt/server/api/login.post.ts | 1 - apps/blog-nuxt/server/api/register.post.ts | 1 - apps/blog-nuxt/server/middleware/csrf.ts | 3 + apps/blog-sveltekit/config/security.ts | 2 +- apps/blog-sveltekit/src/hooks.server.ts | 2 + .../src/routes/+layout.server.ts | 4 +- .../src/routes/login/+page.server.ts | 10 +- .../src/routes/login/+page.svelte | 2 +- .../src/routes/register/+page.server.ts | 10 +- .../src/routes/register/+page.svelte | 2 +- .../routes/super-admin/login/+page.server.ts | 10 +- .../src/routes/super-admin/login/+page.svelte | 2 +- .../tests/auth-page-actions.test.mjs | 3 - apps/docs/docs/auth/current-auth-client.md | 4 - apps/docs/docs/forms/client-usage.md | 27 +- apps/docs/docs/forms/framework-integration.md | 32 +- apps/docs/docs/forms/server-validation.md | 28 +- apps/docs/docs/security.md | 113 ++++--- bun.lock | 6 + packages/adapter-next/src/client.ts | 1 - .../adapter-sveltekit/tests/runtime.test.ts | 1 + packages/auth/src/next/server.ts | 55 +--- packages/auth/src/nuxt-shim.d.ts | 12 - packages/auth/src/nuxt/server.ts | 32 +- packages/auth/src/runtime/csrfCookie.ts | 144 --------- packages/auth/src/sveltekit/server.ts | 43 --- packages/auth/tests/framework.test.ts | 261 +++++---------- packages/forms/src/client-security.ts | 21 +- packages/forms/src/contracts.ts | 15 +- packages/forms/src/internal/client.ts | 7 +- packages/forms/src/security.ts | 3 - packages/forms/tests/client.test.ts | 23 +- packages/forms/tests/client.type.test.ts | 1 - packages/forms/tests/contracts.test.ts | 305 ++---------------- packages/forms/tests/contracts.type.test.ts | 6 +- packages/forms/tests/security.test.ts | 13 +- packages/security/package.json | 25 ++ packages/security/src/client-config.ts | 98 ++++++ packages/security/src/client.ts | 61 +--- packages/security/src/contracts.ts | 11 +- packages/security/src/csrf.ts | 91 ++++-- packages/security/src/framework-shim.d.ts | 53 +++ packages/security/src/index.ts | 4 +- packages/security/src/next/server.ts | 94 ++++++ packages/security/src/nuxt/server.ts | 91 ++++++ packages/security/src/sveltekit/server.ts | 103 ++++++ packages/security/tests/client.test.ts | 63 ++-- packages/security/tests/client.type.test.ts | 30 -- packages/security/tests/docs-smoke.test.ts | 12 +- .../tests/framework-middleware.test.ts | 241 ++++++++++++++ packages/security/tests/package.test.ts | 53 +++ packages/security/tests/sveltekit.test.ts | 200 ++++++++++++ packages/security/tsup.config.ts | 4 + 66 files changed, 1392 insertions(+), 1088 deletions(-) create mode 100644 apps/blog-nuxt/server/middleware/csrf.ts delete mode 100644 packages/auth/src/runtime/csrfCookie.ts create mode 100644 packages/security/src/client-config.ts create mode 100644 packages/security/src/framework-shim.d.ts create mode 100644 packages/security/src/next/server.ts create mode 100644 packages/security/src/nuxt/server.ts create mode 100644 packages/security/src/sveltekit/server.ts create mode 100644 packages/security/tests/framework-middleware.test.ts create mode 100644 packages/security/tests/sveltekit.test.ts diff --git a/apps/blog-next/app/api/login/route.ts b/apps/blog-next/app/api/login/route.ts index 7c97dd7b..019e907c 100644 --- a/apps/blog-next/app/api/login/route.ts +++ b/apps/blog-next/app/api/login/route.ts @@ -5,7 +5,6 @@ import { loginForm } from '@/lib/schemas/auth' export async function POST(request: Request) { const submission = await validate(request, loginForm, { - csrf: true, throttle: 'login', }) diff --git a/apps/blog-next/app/api/register/route.ts b/apps/blog-next/app/api/register/route.ts index fad06501..01e27db0 100644 --- a/apps/blog-next/app/api/register/route.ts +++ b/apps/blog-next/app/api/register/route.ts @@ -5,7 +5,6 @@ import { registerForm } from '@/lib/schemas/auth' export async function POST(request: Request) { const submission = await validate(request, registerForm, { - csrf: true, throttle: 'register', }) diff --git a/apps/blog-next/app/login/actions.ts b/apps/blog-next/app/login/actions.ts index 942d5e0c..098e42ab 100644 --- a/apps/blog-next/app/login/actions.ts +++ b/apps/blog-next/app/login/actions.ts @@ -9,7 +9,6 @@ import { loginForm } from '@/lib/schemas/auth' export async function loginAction(formData: FormData) { const submission = await validate(formData, loginForm, { - csrf: true, throttle: 'login', }) diff --git a/apps/blog-next/app/login/page.tsx b/apps/blog-next/app/login/page.tsx index 0ba422a0..75719b83 100644 --- a/apps/blog-next/app/login/page.tsx +++ b/apps/blog-next/app/login/page.tsx @@ -17,7 +17,6 @@ const panelStyle = { export default function LoginPage() { const form = useForm(loginForm, { - csrf: true, validateOn: 'blur', initialValues: { email: '', password: '', remember: false }, async submitter({ formData }) { diff --git a/apps/blog-next/app/register/actions.ts b/apps/blog-next/app/register/actions.ts index 7b7e973f..b01800ed 100644 --- a/apps/blog-next/app/register/actions.ts +++ b/apps/blog-next/app/register/actions.ts @@ -9,7 +9,6 @@ import { registerForm } from '@/lib/schemas/auth' export async function registerAction(formData: FormData) { const submission = await validate(formData, registerForm, { - csrf: true, throttle: 'register', }) diff --git a/apps/blog-next/app/register/page.tsx b/apps/blog-next/app/register/page.tsx index 71a2ff87..99190aa6 100644 --- a/apps/blog-next/app/register/page.tsx +++ b/apps/blog-next/app/register/page.tsx @@ -19,7 +19,6 @@ const panelStyle = { export default function RegisterPage() { const form = useForm(registerForm, { - csrf: true, validateOn: 'blur', initialValues: { name: '', email: '', password: '', passwordConfirmation: '' }, async submitter({ formData }) { diff --git a/apps/blog-next/config/security.ts b/apps/blog-next/config/security.ts index 3ea5ad54..a6d5b541 100644 --- a/apps/blog-next/config/security.ts +++ b/apps/blog-next/config/security.ts @@ -6,7 +6,7 @@ export default defineSecurityConfig({ field: '_token', header: 'X-CSRF-TOKEN', cookie: 'XSRF-TOKEN', - except: [], + except: ['/api/v1/*'], }, rateLimit: { driver: 'file', diff --git a/apps/blog-next/proxy.ts b/apps/blog-next/proxy.ts index 8d2454ca..c3be22d8 100644 --- a/apps/blog-next/proxy.ts +++ b/apps/blog-next/proxy.ts @@ -1,6 +1,8 @@ import { authOnly, guestOnly, protectRoutes } from '@holo-js/auth/next/server' +import { csrfProtection } from '@holo-js/security/next/server' -export const proxy = protectRoutes( +const csrf = csrfProtection() +const auth = protectRoutes( guestOnly({ routes: ['/login', '/register', '/forgot-password', '/reset-password'], redirectTo: '/admin', @@ -21,6 +23,25 @@ export const proxy = protectRoutes( }), ) +export async function proxy(request: Parameters[0]) { + const csrfResponse = await csrf(request) + if (csrfResponse?.status === 419) { + return csrfResponse + } + + const authResponse = await auth(request) + return authResponse ?? csrfResponse +} + export const config = { - matcher: ['/login', '/register', '/forgot-password', '/reset-password', '/admin/:path*', '/super-admin', '/super-admin/login'], + matcher: [ + '/login', + '/register', + '/forgot-password', + '/reset-password', + '/admin/:path*', + '/super-admin', + '/super-admin/login', + '/api/:path*', + ], } diff --git a/apps/blog-next/tests/login-page.test.mjs b/apps/blog-next/tests/login-page.test.mjs index 8abbec6e..d57d2020 100644 --- a/apps/blog-next/tests/login-page.test.mjs +++ b/apps/blog-next/tests/login-page.test.mjs @@ -83,7 +83,6 @@ describe('login action', () => { }) expect(mocks.validate).toHaveBeenCalledWith(formData, {}, { - csrf: true, throttle: 'login', }) expect(mocks.login).toHaveBeenCalledWith({ diff --git a/apps/blog-next/tests/register-page.test.mjs b/apps/blog-next/tests/register-page.test.mjs index 7f77631a..0ab08af7 100644 --- a/apps/blog-next/tests/register-page.test.mjs +++ b/apps/blog-next/tests/register-page.test.mjs @@ -127,11 +127,9 @@ describe('register page', () => { }) expect(mocks.useForm).toHaveBeenCalledWith(mocks.registerForm, expect.objectContaining({ - csrf: true, validateOn: 'blur', })) expect(mocks.validate).toHaveBeenCalledWith(expect.any(FormData), mocks.registerForm, { - csrf: true, throttle: 'register', }) expect(mocks.register).not.toHaveBeenCalled() @@ -165,7 +163,6 @@ describe('registerAction', () => { await expect(registerAction(new FormData())).resolves.toBe(failure) expect(mocks.validate).toHaveBeenCalledWith(expect.any(FormData), mocks.registerForm, { - csrf: true, throttle: 'register', }) expect(mocks.register).not.toHaveBeenCalled() diff --git a/apps/blog-nuxt/app/pages/login/index.vue b/apps/blog-nuxt/app/pages/login/index.vue index 84207e11..4a126803 100644 --- a/apps/blog-nuxt/app/pages/login/index.vue +++ b/apps/blog-nuxt/app/pages/login/index.vue @@ -5,7 +5,6 @@ import { loginForm } from '#shared/schemas/auth' const { refreshUser } = await useAuth() const form = useForm(loginForm, { - csrf: true, validateOn: 'blur', initialValues: { email: '', password: '', remember: false }, async submitter({ formData }) { diff --git a/apps/blog-nuxt/app/pages/register/index.vue b/apps/blog-nuxt/app/pages/register/index.vue index 487711db..ad8a90a8 100644 --- a/apps/blog-nuxt/app/pages/register/index.vue +++ b/apps/blog-nuxt/app/pages/register/index.vue @@ -6,7 +6,6 @@ import { registerForm } from '#shared/schemas/auth' const { refreshUser } = await useAuth() const form = useForm(registerForm, { - csrf: true, validateOn: 'blur', initialValues: { name: '', email: '', password: '', passwordConfirmation: '' }, async submitter({ formData }) { diff --git a/apps/blog-nuxt/config/security.ts b/apps/blog-nuxt/config/security.ts index 3ea5ad54..a6d5b541 100644 --- a/apps/blog-nuxt/config/security.ts +++ b/apps/blog-nuxt/config/security.ts @@ -6,7 +6,7 @@ export default defineSecurityConfig({ field: '_token', header: 'X-CSRF-TOKEN', cookie: 'XSRF-TOKEN', - except: [], + except: ['/api/v1/*'], }, rateLimit: { driver: 'file', diff --git a/apps/blog-nuxt/server/api/login.post.ts b/apps/blog-nuxt/server/api/login.post.ts index 926c6a5a..c2aa0952 100644 --- a/apps/blog-nuxt/server/api/login.post.ts +++ b/apps/blog-nuxt/server/api/login.post.ts @@ -5,7 +5,6 @@ import { loginForm } from '#shared/schemas/auth' export default defineEventHandler(async (event) => { const submission = await validate(event, loginForm, { - csrf: true, throttle: 'login', }) diff --git a/apps/blog-nuxt/server/api/register.post.ts b/apps/blog-nuxt/server/api/register.post.ts index 4e1f153c..67244321 100644 --- a/apps/blog-nuxt/server/api/register.post.ts +++ b/apps/blog-nuxt/server/api/register.post.ts @@ -5,7 +5,6 @@ import { registerForm } from '#shared/schemas/auth' export default defineEventHandler(async (event) => { const submission = await validate(event, registerForm, { - csrf: true, throttle: 'register', }) diff --git a/apps/blog-nuxt/server/middleware/csrf.ts b/apps/blog-nuxt/server/middleware/csrf.ts new file mode 100644 index 00000000..f1485d48 --- /dev/null +++ b/apps/blog-nuxt/server/middleware/csrf.ts @@ -0,0 +1,3 @@ +import { csrfProtection } from '@holo-js/security/nuxt/server' + +export default csrfProtection() diff --git a/apps/blog-sveltekit/config/security.ts b/apps/blog-sveltekit/config/security.ts index 3ea5ad54..a6d5b541 100644 --- a/apps/blog-sveltekit/config/security.ts +++ b/apps/blog-sveltekit/config/security.ts @@ -6,7 +6,7 @@ export default defineSecurityConfig({ field: '_token', header: 'X-CSRF-TOKEN', cookie: 'XSRF-TOKEN', - except: [], + except: ['/api/v1/*'], }, rateLimit: { driver: 'file', diff --git a/apps/blog-sveltekit/src/hooks.server.ts b/apps/blog-sveltekit/src/hooks.server.ts index e7c57e3d..d84e1171 100644 --- a/apps/blog-sveltekit/src/hooks.server.ts +++ b/apps/blog-sveltekit/src/hooks.server.ts @@ -1,7 +1,9 @@ import { sequence } from '@sveltejs/kit/hooks' import { authOnly, guestOnly } from '@holo-js/auth/sveltekit/server' +import { csrfProtection } from '@holo-js/security/sveltekit/server' export const handle = sequence( + csrfProtection(), guestOnly({ routes: ['/login', '/register', '/forgot-password', '/reset-password'], redirectTo: '/admin', diff --git a/apps/blog-sveltekit/src/routes/+layout.server.ts b/apps/blog-sveltekit/src/routes/+layout.server.ts index e528b4ad..594aed15 100644 --- a/apps/blog-sveltekit/src/routes/+layout.server.ts +++ b/apps/blog-sveltekit/src/routes/+layout.server.ts @@ -1,12 +1,10 @@ import { auth } from '@holo-js/auth/sveltekit/server' -import { csrf } from '@holo-js/security' import type { LayoutServerLoad } from './$types' -export const load = (async ({ request }) => { +export const load = (async () => { const currentAuth = await auth() return { auth: currentAuth, - csrf: await csrf.field(request), } }) satisfies LayoutServerLoad diff --git a/apps/blog-sveltekit/src/routes/login/+page.server.ts b/apps/blog-sveltekit/src/routes/login/+page.server.ts index bced4a92..d4cbf03d 100644 --- a/apps/blog-sveltekit/src/routes/login/+page.server.ts +++ b/apps/blog-sveltekit/src/routes/login/+page.server.ts @@ -1,14 +1,20 @@ import { fail, redirect } from '@sveltejs/kit' import { login } from '@holo-js/auth' import { validate } from '@holo-js/forms' +import { csrf } from '@holo-js/security' import { loginForm } from '$lib/schemas/auth' -import type { Actions } from './$types' +import type { Actions, PageServerLoad } from './$types' + +export const load = (async ({ request }) => ({ + csrf: { + input: await csrf.input(request), + }, +})) satisfies PageServerLoad export const actions = { default: async ({ request }) => { const submission = await validate(request, loginForm, { - csrf: true, throttle: 'login', }) diff --git a/apps/blog-sveltekit/src/routes/login/+page.svelte b/apps/blog-sveltekit/src/routes/login/+page.svelte index 9295ff54..cbe3dae0 100644 --- a/apps/blog-sveltekit/src/routes/login/+page.svelte +++ b/apps/blog-sveltekit/src/routes/login/+page.svelte @@ -26,7 +26,7 @@
- + {#if formError}

{formError}

diff --git a/apps/blog-sveltekit/src/routes/register/+page.server.ts b/apps/blog-sveltekit/src/routes/register/+page.server.ts index cce5ee30..e7cf27c9 100644 --- a/apps/blog-sveltekit/src/routes/register/+page.server.ts +++ b/apps/blog-sveltekit/src/routes/register/+page.server.ts @@ -1,14 +1,20 @@ import { fail, redirect } from '@sveltejs/kit' import { loginUsing, register } from '@holo-js/auth' import { validate } from '@holo-js/forms' +import { csrf } from '@holo-js/security' import { registerForm } from '$lib/schemas/auth' -import type { Actions } from './$types' +import type { Actions, PageServerLoad } from './$types' + +export const load = (async ({ request }) => ({ + csrf: { + input: await csrf.input(request), + }, +})) satisfies PageServerLoad export const actions = { default: async ({ request }) => { const submission = await validate(request, registerForm, { - csrf: true, throttle: 'register', }) diff --git a/apps/blog-sveltekit/src/routes/register/+page.svelte b/apps/blog-sveltekit/src/routes/register/+page.svelte index 0ff81ea2..73202e30 100644 --- a/apps/blog-sveltekit/src/routes/register/+page.svelte +++ b/apps/blog-sveltekit/src/routes/register/+page.svelte @@ -19,7 +19,7 @@ - + {#if formError}

{formError}

diff --git a/apps/blog-sveltekit/src/routes/super-admin/login/+page.server.ts b/apps/blog-sveltekit/src/routes/super-admin/login/+page.server.ts index de0bc330..8159a2a5 100644 --- a/apps/blog-sveltekit/src/routes/super-admin/login/+page.server.ts +++ b/apps/blog-sveltekit/src/routes/super-admin/login/+page.server.ts @@ -1,14 +1,20 @@ import { fail, redirect } from '@sveltejs/kit' import auth from '@holo-js/auth' import { validate } from '@holo-js/forms' +import { csrf } from '@holo-js/security' import { loginForm } from '$lib/schemas/auth' -import type { Actions } from './$types' +import type { Actions, PageServerLoad } from './$types' + +export const load = (async ({ request }) => ({ + csrf: { + input: await csrf.input(request), + }, +})) satisfies PageServerLoad export const actions = { default: async ({ request }) => { const submission = await validate(request, loginForm, { - csrf: true, throttle: 'login', }) diff --git a/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte b/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte index 972620af..a91bb288 100644 --- a/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte +++ b/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte @@ -19,7 +19,7 @@ - + {#if formError}

{formError}

diff --git a/apps/blog-sveltekit/tests/auth-page-actions.test.mjs b/apps/blog-sveltekit/tests/auth-page-actions.test.mjs index cd991c9c..d7970f48 100644 --- a/apps/blog-sveltekit/tests/auth-page-actions.test.mjs +++ b/apps/blog-sveltekit/tests/auth-page-actions.test.mjs @@ -97,7 +97,6 @@ describe('SvelteKit login page action', () => { expect(response.status).toBe(422) expect(response).toEqual(failure) expect(mocks.validate).toHaveBeenCalledWith(expect.any(Request), mocks.loginForm, { - csrf: true, throttle: 'login', }) expect(mocks.login).not.toHaveBeenCalled() @@ -165,7 +164,6 @@ describe('SvelteKit register page action', () => { expect(response.status).toBe(422) expect(response).toEqual(failure) expect(mocks.validate).toHaveBeenCalledWith(expect.any(Request), mocks.registerForm, { - csrf: true, throttle: 'register', }) expect(mocks.register).not.toHaveBeenCalled() @@ -278,7 +276,6 @@ describe('SvelteKit super admin login page action', () => { expect(response.status).toBe(422) expect(response).toEqual(failure) expect(mocks.validate).toHaveBeenCalledWith(expect.any(Request), mocks.loginForm, { - csrf: true, throttle: 'login', }) expect(mocks.guardLogin).not.toHaveBeenCalled() diff --git a/apps/docs/docs/auth/current-auth-client.md b/apps/docs/docs/auth/current-auth-client.md index 5a625088..a07a3326 100644 --- a/apps/docs/docs/auth/current-auth-client.md +++ b/apps/docs/docs/auth/current-auth-client.md @@ -59,7 +59,6 @@ import { loginForm } from '@/lib/schemas/login' export async function loginAction(formData: FormData) { const submission = await validate(formData, loginForm, { - csrf: true, throttle: 'login', }) @@ -86,7 +85,6 @@ import { loginAction } from './actions' export default function LoginPage() { const form = useForm(loginForm, { - csrf: true, async submitter({ formData }) { return await loginAction(formData) }, @@ -131,7 +129,6 @@ import { loginForm } from '$lib/schemas/login' export async function POST({ request }: { request: Request }) { const submission = await validate(request, loginForm, { - csrf: true, throttle: 'login', }) @@ -161,7 +158,6 @@ export async function POST({ request }: { request: Request }) { const auth = useAuth() const form = useForm(loginForm, { - csrf: true, async submitter({ formData }) { const submission = await (await fetch('/api/login', { method: 'POST', body: formData })).json() if (submission.ok === true && typeof submission.data?.redirectTo === 'string') { diff --git a/apps/docs/docs/forms/client-usage.md b/apps/docs/docs/forms/client-usage.md index 83bdab39..b3591e77 100644 --- a/apps/docs/docs/forms/client-usage.md +++ b/apps/docs/docs/forms/client-usage.md @@ -41,7 +41,6 @@ import { registerUser } from '@/lib/schemas/register' export default function RegisterPage() { const form = useForm(registerUser, { validateOn: 'blur', - csrf: true, initialValues: { name: '', email: '', password: '', passwordConfirmation: '' }, async submitter({ formData }) { const response = await fetch('/api/register', { method: 'POST', body: formData }) @@ -84,7 +83,6 @@ import { registerUser } from '~/lib/schemas/register' const form = useForm(registerUser, { validateOn: 'blur', - csrf: true, initialValues: { name: '', email: '', password: '', passwordConfirmation: '' }, async submitter({ formData }) { return await $fetch('/api/register', { method: 'POST', body: formData }) @@ -124,7 +122,7 @@ const form = useForm(registerUser, { - + { const submission = await validate(event, loginForm, { - // Optional: requires @holo-js/security. - csrf: true, throttle: 'login', }) @@ -73,8 +69,6 @@ const loginForm = schema({ export const actions = { default: async ({ request }) => { const submission = await validate(request, loginForm, { - // Optional: requires @holo-js/security. - csrf: true, throttle: 'login', }) @@ -107,9 +101,8 @@ export const login = form(loginForm, async (data, invalid) => { ::: -`csrf` and `throttle` in these examples are optional security features. Use them only when -`@holo-js/security` is installed and configured. Without that package, call `validate(...)` without those -options. +`throttle` is optional and requires `@holo-js/security`. CSRF is not a `validate(...)` option; the +framework middleware verifies unsafe requests before these handlers run. Use the framework-native request input with `validate(...)`: `request` in Next.js and SvelteKit, `event` in Nuxt `server/api/*`. `useRequestHeaders()` is a Nuxt app-context composable for pages, components, and plugins, @@ -133,7 +126,6 @@ import { loginForm } from '@/lib/schemas/login' export async function loginAction(formData: FormData) { const submission = await validate(formData, loginForm, { - csrf: true, throttle: 'login', }) @@ -160,7 +152,6 @@ import { loginAction } from './actions' export default function LoginPage() { const form = useForm(loginForm, { - csrf: true, async submitter({ formData }) { return await loginAction(formData) }, @@ -177,7 +168,6 @@ import { loginForm } from '~/lib/schemas/login' const { refreshUser } = await useAuth() const form = useForm(loginForm, { - csrf: true, async submitter({ formData }) { const submission = await $fetch('/api/login', { method: 'POST', body: formData }) if (submission?.ok === true && typeof submission.data?.redirectTo === 'string') { @@ -201,7 +191,6 @@ import { loginForm } from '$lib/schemas/login' export const actions = { default: async ({ request }) => { const submission = await validate(request, loginForm, { - csrf: true, throttle: 'login', }) @@ -235,7 +224,7 @@ export const actions = { - + login.fields.email.onInput(event.currentTarget.value)} /> {#if login.errors.has('email')}

{login.errors.first('email')}

{/if} login.fields.password.onInput(event.currentTarget.value)} /> @@ -258,12 +247,17 @@ SvelteKit users have three options for server validation. All three accept Holo Pick the one that fits your app. They are not mutually exclusive. -`useForm(...)` may opt into `csrf: true`, but it does not expose `throttle`. The browser only forwards the CSRF -token so the server can verify it. Throttling is always enforced on the server. +When `@holo-js/security` is installed, `useForm(...)` automatically forwards the CSRF token for unsafe +submissions. It does not expose `throttle`; throttling is always enforced on the server. -For native SvelteKit form actions, render the CSRF field from server data as a hidden input and validate -the action with `validate(request, schema, { csrf: true })`. The SvelteKit auth/framework hook creates the -CSRF cookie before guest pages render, so app pages should not set the CSRF cookie manually. +For native SvelteKit form actions, render the CSRF field from server data as a hidden input: + +```svelte + +``` + +The security middleware creates the CSRF cookie before pages render, so app pages should not set the +CSRF cookie manually. ## Standard Schema interop diff --git a/apps/docs/docs/forms/server-validation.md b/apps/docs/docs/forms/server-validation.md index 8acf81ea..96258ace 100644 --- a/apps/docs/docs/forms/server-validation.md +++ b/apps/docs/docs/forms/server-validation.md @@ -27,8 +27,6 @@ const loginForm = schema({ export async function POST(request: Request) { const submission = await validate(request, loginForm, { - // Optional: requires @holo-js/security. - csrf: true, throttle: 'login', }) @@ -56,8 +54,6 @@ const loginForm = schema({ export default defineEventHandler(async (event) => { const submission = await validate(event, loginForm, { - // Optional: requires @holo-js/security. - csrf: true, throttle: 'login', }) @@ -84,8 +80,6 @@ const loginForm = schema({ export const actions = { default: async ({ request }) => { const submission = await validate(request, loginForm, { - // Optional: requires @holo-js/security. - csrf: true, throttle: 'login', }) @@ -103,12 +97,11 @@ export const actions = { ::: -`csrf` and `throttle` in these examples are optional security features. Use them only when -`@holo-js/security` is installed and configured. Without that package, call `validate(...)` without those -options. +`throttle` is optional and requires `@holo-js/security`. CSRF is not a `validate(...)` option; the +framework middleware verifies unsafe requests before these handlers run. -When you add `csrf` or `throttle`, pass a real web `Request` or request-like event into `validate(...)`. In -Nuxt `server/api/*`, you can pass the h3 event directly instead of validating only the parsed body. +When you add `throttle`, pass a real web `Request` or request-like event into `validate(...)`. In Nuxt +`server/api/*`, you can pass the h3 event directly instead of validating only the parsed body. ## SvelteKit remote functions @@ -216,7 +209,7 @@ and applies the returned values and errors to `useForm(...)`: - + { const submission = await validate(request, loginForm, { - csrf: true, throttle: 'login', }) @@ -429,7 +418,7 @@ export const actions = { - + login.fields.email.onInput(event.currentTarget.value)} /> {#if login.errors.has('email')} @@ -518,8 +507,6 @@ export const registerUser = schema({ export async function POST(request: Request) { const submission = await validate(request, registerUser, { - // Optional: requires @holo-js/security. - csrf: true, throttle: 'register', }) @@ -546,8 +533,6 @@ const registerUser = schema({ export default defineEventHandler(async (event) => { const submission = await validate(event, registerUser, { - // Optional: requires @holo-js/security. - csrf: true, throttle: 'register', }) @@ -575,7 +560,6 @@ const registerUser = schema({ export const actions = { default: async ({ request }) => { const submission = await validate(request, registerUser, { - csrf: true, throttle: 'register', }) diff --git a/apps/docs/docs/security.md b/apps/docs/docs/security.md index 84e322ad..5cb77cbc 100644 --- a/apps/docs/docs/security.md +++ b/apps/docs/docs/security.md @@ -15,11 +15,11 @@ it is installed. - CSRF token helpers for server-rendered forms and browser clients - CORS headers for separate frontend/API deployments -- request protection for plain routes with `protect(...)` +- request protection for unsafe HTTP methods through framework middleware +- route-level protection for plain routes with `protect(...)` - named rate limiters with `limit.perMinute(...)` and `limit.perHour(...)` - low-level `rateLimit(...)` and `clearRateLimit(...)` helpers -- optional integration with `@holo-js/forms` through `validate(..., { csrf, throttle })` and - `useForm(..., { csrf: true })` +- optional integration with `@holo-js/forms` for named request throttles `throttle` stays server-only. The browser never meaningfully enforces the rate limit. @@ -116,7 +116,7 @@ Holo-JS falls back to standalone `host`, which may also be a Unix socket path. - `csrf.enabled` controls the default CSRF behavior for route protection. - `csrf.field` is the hidden form field name for normal form posts. - `csrf.header` is the header accepted for XHR and `fetch` requests. -- `csrf.cookie` stores the signed token cookie that `useForm(..., { csrf: true })` reads on the client. +- `csrf.cookie` stores the signed readable token cookie that browser clients and native forms submit back. - `csrf.except` skips CSRF verification for matching paths such as webhooks. - `cors.origins` lists frontend origins allowed to call the API. - `cors.credentials` must be true when the frontend uses cookie-backed auth with `fetch(..., { credentials: 'include' })`. @@ -136,11 +136,13 @@ Holo-JS falls back to standalone `host`, which may also be a Unix socket path. ## Forms -When `@holo-js/forms` is installed, forms can opt into security directly through `validate(...)`. +CSRF is enforced by middleware before route handlers run. Form validation does not opt into CSRF; it only +validates field data after the request passes the middleware. -Validation failures and auth failures stay separate: +Validation failures, CSRF failures, and auth failures stay separate: -- `validate(...)` returns form validation failures such as missing fields, bad formats, CSRF errors, and throttling. +- `csrfProtection()` verifies unsafe requests before route handlers run and returns `419` on token mismatch. +- `validate(...)` returns form validation failures such as missing fields, bad formats, and throttling. - `login(...)`, `register(...)`, `verifyEmail(...)`, `requestPasswordReset(...)`, and `resetPassword(...)` return auth failures in `error`. - Auth failures are plain data with `status` and `fields`, so routes can forward them directly into the normal form response shape. @@ -157,7 +159,6 @@ const loginForm = schema({ export async function POST(request: Request) { const submission = await validate(request, loginForm, { - csrf: true, throttle: 'login', }) @@ -198,7 +199,6 @@ const registerUser = schema({ export async function POST(request: Request) { const submission = await validate(request, registerUser, { - csrf: true, throttle: 'register', }) @@ -227,7 +227,7 @@ export async function POST(request: Request) { ### Failure statuses - validation failures return `422` -- CSRF failures return `419` +- CSRF middleware failures return `419` - rate-limit failures return `429` `submission.fail()` preserves that status: @@ -240,12 +240,12 @@ return Response.json(submission.fail(), { ### `useForm(...)` -`useForm(...)` only gets one security option: +When `@holo-js/security` is installed and its CSRF cookie exists, `useForm(...)` automatically attaches +the CSRF field to unsafe `FormData` submissions. No CSRF option is needed: ```ts const form = useForm(registerUser, { validateOn: 'blur', - csrf: true, initialValues: { name: '', email: '', @@ -261,77 +261,73 @@ const form = useForm(registerUser, { }) ``` -`csrf: true` tells the client helper to read the CSRF cookie and attach the hidden field to outgoing -`FormData` for unsafe methods. The actual protection still happens on the server when `validate(...)` -or `protect(...)` verifies the token. +The actual protection happens in the framework middleware before route code runs. The middleware also +passes the configured CSRF field and cookie names to browser helpers, so `config/security.ts` stays the +only source of truth. Do not put `throttle` on `useForm(...)`. Throttling is enforced on the server through `validate(request, schema, { throttle: 'name' })` or `protect(request, { throttle: 'name' })`. -If the browser should use custom CSRF field or cookie names instead of the defaults (`_token` and -`XSRF-TOKEN`), configure the browser helper explicitly: - -```ts -import { configureSecurityClient } from '@holo-js/security/client' - -configureSecurityClient({ - config: { - csrf: { - field: '_csrf', - cookie: 'csrf-token', - }, - }, -}) -``` - ## CSRF helpers -Use the CSRF helpers directly when you are not going through `validate(...)`. +Use CSRF helpers when rendering native server forms or building custom request handling. The framework +middleware remains the normal verification path. ### Server-rendered hidden field ```ts import { csrf } from '@holo-js/security' -const field = await csrf.field(request) +const input = await csrf.input(request) ``` -`field` has the shape: +`input` has the shape: ```ts { + type: 'hidden', name: '_token', value: '...', } ``` -### Setting the readable cookie +### Global CSRF middleware -`useForm(..., { csrf: true })` needs the CSRF cookie to already exist. In the Next.js, Nuxt, and -SvelteKit auth/framework integrations, the route protection hooks create this cookie before guest pages -render. Use `csrf.cookie(request)` directly only for custom server-rendered HTML outside those helpers: +Framework middleware issues the readable CSRF cookie on safe requests and verifies unsafe requests before +route actions run. Generated apps wire this globally. If you wire it manually, use the framework entrypoint: -```ts -import { csrf } from '@holo-js/security' +::: code-group -export async function GET(request: Request) { - return new Response('...', { - headers: { - 'content-type': 'text/html; charset=utf-8', - 'set-cookie': await csrf.cookie(request), - }, - }) -} +```ts [Next.js — proxy.ts] +export { csrfProtection as proxy } from '@holo-js/security/next/server' +``` + +```ts [Nuxt — server/middleware/csrf.ts] +export { csrfProtection as default } from '@holo-js/security/nuxt/server' +``` + +```ts [SvelteKit — src/hooks.server.ts] +import { sequence } from '@sveltejs/kit/hooks' +import { csrfProtection } from '@holo-js/security/sveltekit/server' + +export const handle = sequence( + csrfProtection(), +) ``` -### Route protection without forms +::: + +`csrfProtection()` respects `security.csrf.except`, so webhook paths can opt out from verification. + +Use `csrf.cookie(request)` directly only for custom server-rendered HTML outside framework middleware. + +### Route protection without framework middleware ```ts import { protect } from '@holo-js/security' export async function POST(request: Request) { await protect(request, { - csrf: true, throttle: 'api', }) @@ -456,8 +452,8 @@ Use `redis` when the app runs on multiple instances or when rate-limit state mus ## Nuxt request handling -Security-aware `validate(...)` calls need a real web `Request` or request-like event. In Nuxt, pass the h3 -event directly when you want CSRF or throttling: +Throttle-aware `validate(...)` calls need a real web `Request` or request-like event. In Nuxt, pass the h3 +event directly when you want request-based limiter keys: ```ts import { defineEventHandler } from 'h3' @@ -470,7 +466,6 @@ const loginForm = schema({ export default defineEventHandler(async (event) => { const submission = await validate(event, loginForm, { - csrf: true, throttle: 'login', }) @@ -484,7 +479,7 @@ export default defineEventHandler(async (event) => { }) ``` -If you pass only a plain body object, validation still works, but CSRF and request-based limiter keys cannot be generated. +If you pass only a plain body object, validation still works, but request-based limiter keys cannot be generated. ## Typing @@ -494,8 +489,8 @@ Examples: - `defineSecurityConfig(...)` infers `memory`, `file`, and `redis` driver config correctly - limiter callbacks infer `request` and `values` -- `validate(requestOrEvent, schema, { csrf, throttle })` keeps the schema-derived success and failure types -- `useForm(schema, { csrf: true })` keeps field, value, and error inference +- `validate(requestOrEvent, schema, { throttle })` keeps the schema-derived success and failure types +- `useForm(schema)` keeps field, value, and error inference while client CSRF attachment stays automatic - public contracts such as `SecurityRateLimitStore`, `SecurityRateLimitHitResult`, and `SecurityRateLimitRedisDriverAdapter` are exported when you need explicit annotations @@ -506,7 +501,7 @@ Security stays optional: - install it with `npx holo install security` - include it during project creation only if the app needs it - apps that do not install it do not pay dependency or runtime cost -- `@holo-js/forms` loads it lazily only when security-aware options are actually used +- `@holo-js/forms` loads it lazily only when server throttling or browser CSRF attachment is actually used -If code uses `validate(..., { csrf, throttle })` or `useForm(..., { csrf: true })` without the package -installed, Holo throws a targeted error instead of silently pretending the route is protected. +If code uses `validate(..., { throttle })` without the package installed, Holo throws a targeted error +instead of silently pretending the route is rate-limited. diff --git a/bun.lock b/bun.lock index bd2a7886..1b43b6d4 100644 --- a/bun.lock +++ b/bun.lock @@ -1016,16 +1016,22 @@ }, "devDependencies": { "@types/node": "catalog:", + "h3": "catalog:", "ioredis": "catalog:", + "next": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:", }, "peerDependencies": { + "h3": "catalog:", "ioredis": "catalog:", + "next": "catalog:", }, "optionalPeers": [ + "h3", "ioredis", + "next", ], }, "packages/session": { diff --git a/packages/adapter-next/src/client.ts b/packages/adapter-next/src/client.ts index f696ca13..f257238e 100644 --- a/packages/adapter-next/src/client.ts +++ b/packages/adapter-next/src/client.ts @@ -59,7 +59,6 @@ function areOptionsEqual( ): boolean { return left.action === right.action && left.method === right.method - && left.csrf === right.csrf && left.validateOn === right.validateOn && Boolean(left.submitter) === Boolean(right.submitter) && areEqual(left.initialValues, right.initialValues) diff --git a/packages/adapter-sveltekit/tests/runtime.test.ts b/packages/adapter-sveltekit/tests/runtime.test.ts index 5390b160..d5e55615 100644 --- a/packages/adapter-sveltekit/tests/runtime.test.ts +++ b/packages/adapter-sveltekit/tests/runtime.test.ts @@ -236,4 +236,5 @@ describe('@holo-js/adapter-sveltekit request context', () => { }) expect(redirect).toHaveBeenCalledWith(307, '/login') }) + }) diff --git a/packages/auth/src/next/server.ts b/packages/auth/src/next/server.ts index de488fb4..5100b5f7 100644 --- a/packages/auth/src/next/server.ts +++ b/packages/auth/src/next/server.ts @@ -1,35 +1,10 @@ import type { HoloAuthUser } from '../contracts' -import { - createSignedCsrfToken, - defaultCsrfCookieName, - isCsrfCookieRequest, - resolveCsrfCookieOptions, -} from '../runtime/csrfCookie' import type * as AuthRuntime from '../index' import { runWithNextAuthRequest, type NextAuthRequestLike } from './request-context' type AuthRuntimeModule = typeof AuthRuntime const sourceAuthRuntimePath = '../index' -type NextResponseCookieOptions = { - readonly path?: string - readonly secure?: boolean - readonly sameSite?: 'lax' | 'strict' | 'none' - readonly httpOnly?: boolean -} - -type NextResponseWithCookies = Response & { - readonly cookies: { - set(name: string, value: string, options?: NextResponseCookieOptions): void - } -} - -type NextServerModule = { - readonly NextResponse: { - next(): NextResponseWithCookies - } -} - export type AuthState = { readonly authenticated: boolean readonly guard: string @@ -67,32 +42,6 @@ type NextRouteProtectionProxy = ( request: NextRouteProtectionRequest, ) => NextRouteProtectionResult | Promise -function hasCsrfCookie(request: NextRouteProtectionRequest): boolean { - return Boolean(request.cookies.get(defaultCsrfCookieName)?.value) -} - -async function createCsrfCookieResponse(request: NextRouteProtectionRequest): Promise { - if (!isCsrfCookieRequest(request.method) || hasCsrfCookie(request)) { - return undefined - } - - const signingKey = process.env.APP_KEY?.trim() - if (!signingKey) { - return undefined - } - - const token = await createSignedCsrfToken(signingKey) - if (!token) { - return undefined - } - - const { NextResponse } = await import('next/server') as NextServerModule - const response = NextResponse.next() - response.cookies.set(defaultCsrfCookieName, token, resolveCsrfCookieOptions(request)) - - return response -} - function toClientAuthUser(user: HoloAuthUser | null): HoloAuthUser | null { return user ? { ...user } : null } @@ -229,13 +178,11 @@ export function protectRoutes(...proxies: readonly NextRouteProtectionProxy[]): } } - return await createCsrfCookieResponse(request) + return undefined } } export const routeProtectionInternals = { - createCsrfCookieResponse, - createCsrfToken: createSignedCsrfToken, isSameUrl, matchesRoute, matchesRoutes, diff --git a/packages/auth/src/nuxt-shim.d.ts b/packages/auth/src/nuxt-shim.d.ts index fce91df4..f0dd0f42 100644 --- a/packages/auth/src/nuxt-shim.d.ts +++ b/packages/auth/src/nuxt-shim.d.ts @@ -6,10 +6,6 @@ interface HoloComputedRef { readonly value: TValue } -interface HoloCookieRef { - value: TValue -} - interface HoloUseFetchResult { readonly data: HoloRef readonly refresh: () => Promise @@ -30,14 +26,6 @@ declare module '#imports' { to: string, options?: { readonly redirectCode?: number }, ): HoloNavigateToResult - export function useCookie( - name: string, - options?: { - readonly path?: string - readonly sameSite?: 'lax' | 'strict' | 'none' - readonly secure?: boolean - }, - ): HoloCookieRef export function useFetch( request: string, options?: { readonly key?: string }, diff --git a/packages/auth/src/nuxt/server.ts b/packages/auth/src/nuxt/server.ts index 472a74f0..35ab4278 100644 --- a/packages/auth/src/nuxt/server.ts +++ b/packages/auth/src/nuxt/server.ts @@ -1,5 +1,5 @@ import { useAuth } from '../nuxt' -import { defineNuxtRouteMiddleware, navigateTo, useCookie } from '#imports' +import { defineNuxtRouteMiddleware, navigateTo } from '#imports' export type RouteMatcher = string | RegExp | ((pathname: string) => boolean) @@ -83,31 +83,6 @@ function createUseAuthOptions(guard: string | undefined): { readonly guard: stri return guard ? { guard } : undefined } -async function ensureCsrfCookie(path: string): Promise { - if ('window' in globalThis) { - return - } - - const signingKey = process.env.APP_KEY?.trim() - if (!signingKey) { - return - } - - const { - createSignedCsrfToken, - defaultCsrfCookieName, - resolveCsrfCookieOptions, - } = await import('../runtime/csrfCookie') - const cookie = useCookie(defaultCsrfCookieName, resolveCsrfCookieOptions( - new URL(path, process.env.APP_URL || 'http://localhost'), - )) - if (cookie.value) { - return - } - - cookie.value = await createSignedCsrfToken(signingKey) -} - export function guestOnly(options: GuestOnlyOptions): GuestOnlyRouteMiddleware { return defineNuxtRouteMiddleware(async (to) => { if (!matchesRoutes(options.routes, to.path)) { @@ -116,12 +91,10 @@ export function guestOnly(options: GuestOnlyOptions): GuestOnlyRouteMiddleware { const currentAuth = await useAuth(createUseAuthOptions(options.guard)) if (!currentAuth.authenticated.value) { - await ensureCsrfCookie(to.path) return undefined } if (isSamePath(to.path, options.redirectTo)) { - await ensureCsrfCookie(to.path) return undefined } @@ -139,12 +112,10 @@ export function authOnly(options: AuthOnlyOptions): AuthOnlyRouteMiddleware { const currentAuth = await useAuth(createUseAuthOptions(options.guard)) if (currentAuth.authenticated.value) { - await ensureCsrfCookie(to.path) return undefined } if (isSamePath(to.path, options.redirectTo)) { - await ensureCsrfCookie(to.path) return undefined } @@ -155,7 +126,6 @@ export function authOnly(options: AuthOnlyOptions): AuthOnlyRouteMiddleware { } export const routeProtectionInternals = { - ensureCsrfCookie, isSamePath, matchesRoute, matchesRoutes, diff --git a/packages/auth/src/runtime/csrfCookie.ts b/packages/auth/src/runtime/csrfCookie.ts deleted file mode 100644 index df738b21..00000000 --- a/packages/auth/src/runtime/csrfCookie.ts +++ /dev/null @@ -1,144 +0,0 @@ -export const defaultCsrfCookieName = 'XSRF-TOKEN' - -export type CsrfCookieOptions = { - readonly path: '/' - readonly sameSite: 'lax' - readonly secure: boolean - readonly httpOnly: false -} - -type CsrfCookieRequest = { - readonly url: string | URL - readonly headers: Headers -} - -type CsrfCookieTarget = string | URL | CsrfCookieRequest - -type WebCryptoSubtle = { - importKey( - format: 'raw', - keyData: Uint8Array, - algorithm: { readonly name: 'HMAC', readonly hash: 'SHA-256' }, - extractable: false, - keyUsages: readonly ['sign'], - ): Promise - sign( - algorithm: 'HMAC', - key: object, - data: Uint8Array, - ): Promise -} - -type WebCrypto = { - readonly subtle?: WebCryptoSubtle - getRandomValues(array: TArray): TArray -} - -type BrowserEncodingGlobal = { - btoa(value: string): string -} - -function getGlobalCrypto(): WebCrypto | undefined { - const runtime = globalThis as typeof globalThis & { readonly crypto?: WebCrypto } - return runtime.crypto -} - -function base64UrlEncode(bytes: Uint8Array): string { - let binary = '' - for (const byte of bytes) { - binary += String.fromCharCode(byte) - } - - return (globalThis as typeof globalThis & BrowserEncodingGlobal).btoa(binary) - .replaceAll('+', '-') - .replaceAll('/', '_') - .replaceAll('=', '') -} - -async function signCsrfNonce(nonce: string, signingKey: string): Promise { - const crypto = getGlobalCrypto() - if (!crypto?.subtle) { - return undefined - } - - const encoder = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(signingKey), - { - name: 'HMAC', - hash: 'SHA-256', - }, - false, - ['sign'], - ) - const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(nonce)) - - return base64UrlEncode(new Uint8Array(signature)) -} - -export async function createSignedCsrfToken(signingKey: string): Promise { - const crypto = getGlobalCrypto() - if (!crypto) { - return undefined - } - - const nonceBytes = new Uint8Array(32) - crypto.getRandomValues(nonceBytes) - const nonce = base64UrlEncode(nonceBytes) - const signature = await signCsrfNonce(nonce, signingKey) - - return signature ? `${nonce}.${signature}` : undefined -} - -export function isCsrfCookieRequest(method: string | undefined): boolean { - const normalized = method?.trim().toUpperCase() ?? 'GET' - return normalized === 'GET' || normalized === 'HEAD' -} - -function normalizeForwardedValue(value: string): string { - return value.trim().replace(/^"|"$/g, '').toLowerCase() -} - -function getForwardedProto(headers: Headers): string | undefined { - const forwardedProto = headers.get('x-forwarded-proto')?.split(',', 1)[0]?.trim() - if (forwardedProto) { - return normalizeForwardedValue(forwardedProto) - } - - const forwarded = headers.get('forwarded')?.split(',', 1)[0] - if (!forwarded) { - return undefined - } - - for (const segment of forwarded.split(';')) { - const [name, value] = segment.split('=', 2) - if (name?.trim().toLowerCase() === 'proto' && value) { - return normalizeForwardedValue(value) - } - } - - return undefined -} - -function isCsrfCookieRequestTarget(target: CsrfCookieTarget): target is CsrfCookieRequest { - return typeof target === 'object' - && !(target instanceof URL) - && target.headers instanceof Headers -} - -export function resolveCsrfCookieOptions(target: CsrfCookieTarget): CsrfCookieOptions { - const requestUrl = isCsrfCookieRequestTarget(target) - ? typeof target.url === 'string' ? new URL(target.url) : target.url - : typeof target === 'string' ? new URL(target) : target - const forwardedProto = isCsrfCookieRequestTarget(target) - ? getForwardedProto(target.headers) - : undefined - - return { - path: '/', - sameSite: 'lax', - secure: forwardedProto === 'https' || requestUrl.protocol === 'https:', - httpOnly: false, - } -} diff --git a/packages/auth/src/sveltekit/server.ts b/packages/auth/src/sveltekit/server.ts index c928add9..e5d08848 100644 --- a/packages/auth/src/sveltekit/server.ts +++ b/packages/auth/src/sveltekit/server.ts @@ -1,12 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks' import holoAuth, { authRuntimeInternals, provider as currentProvider, user as currentUser } from '../index' import type { AuthUserLike, HoloAuthUser } from '../contracts' -import { - createSignedCsrfToken, - defaultCsrfCookieName, - isCsrfCookieRequest, - resolveCsrfCookieOptions, -} from '../runtime/csrfCookie' export type AuthState = { readonly authenticated: boolean @@ -161,38 +155,6 @@ function isSameUrl(left: URL, right: URL): boolean { && left.hash === right.hash } -async function ensureCsrfCookie(event: SvelteKitHandleEvent): Promise { - if (!isSvelteKitStoredRequestEvent(event)) { - return - } - - if (!isCsrfCookieRequest(event.request.method) || event.cookies.get(defaultCsrfCookieName)) { - return - } - - const signingKey = process.env.APP_KEY?.trim() - if (!signingKey) { - return - } - - const token = await createSignedCsrfToken(signingKey) - if (!token) { - return - } - - try { - const { csrfInternals } = await import('@holo-js/security') - csrfInternals.generatedTokenCache.set(event.request, token) - } catch { - // @holo-js/security is optional for auth-only installs. - } - - event.cookies.set(defaultCsrfCookieName, token, resolveCsrfCookieOptions({ - url: event.url, - headers: event.request.headers, - })) -} - export async function auth(options: AuthOptions = {}): Promise { const guard = options.guard ?? authRuntimeInternals.getRuntimeBindings().config.defaults.guard let user: HoloAuthUser | null @@ -233,13 +195,11 @@ export function guestOnly(options: GuestOnlyOptions): SvelteKitHandle { const currentAuth = await auth({ guard: options.guard }) if (!currentAuth.authenticated) { - await ensureCsrfCookie(event) return resolve(event) } const redirectUrl = new URL(options.redirectTo, event.url) if (isSameUrl(event.url, redirectUrl)) { - await ensureCsrfCookie(event) return resolve(event) } @@ -257,13 +217,11 @@ export function authOnly(options: AuthOnlyOptions): SvelteKitHandle { const currentAuth = await auth({ guard: options.guard }) if (currentAuth.authenticated) { - await ensureCsrfCookie(event) return resolve(event) } const redirectUrl = new URL(options.redirectTo, event.url) if (isSameUrl(event.url, redirectUrl)) { - await ensureCsrfCookie(event) return resolve(event) } @@ -273,7 +231,6 @@ export function authOnly(options: AuthOnlyOptions): SvelteKitHandle { } export const routeProtectionInternals = { - ensureCsrfCookie, isSameUrl, matchesRoute, matchesRoutes, diff --git a/packages/auth/tests/framework.test.ts b/packages/auth/tests/framework.test.ts index 58f48b50..af1ccd83 100644 --- a/packages/auth/tests/framework.test.ts +++ b/packages/auth/tests/framework.test.ts @@ -1,5 +1,4 @@ import { execFile } from 'node:child_process' -import { createHmac } from 'node:crypto' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' @@ -72,7 +71,6 @@ function createNuxtImportsMock(overrides: Readonly> = {} return middleware }, navigateTo: vi.fn(), - useCookie: vi.fn(() => ({ value: undefined })), useFetch: vi.fn(), useState(_key: string, init: () => TValue) { return { value: init() } @@ -476,75 +474,22 @@ describe('@holo-js/auth framework helpers', () => { expect(response?.headers.get('location')).toBe('https://app.test/admin') }) - it('issues a signed CSRF cookie when Next route protection lets a guest page continue', async () => { - const previousAppKey = process.env.APP_KEY - process.env.APP_KEY = 'next-csrf-signing-key' - - try { - vi.doMock('next/server', () => ({ - NextResponse: { - next() { - const response = new Response(null, { - headers: { - 'x-middleware-next': '1', - }, - }) - const headers = response.headers - - return Object.assign(response, { - cookies: { - set(name: string, value: string, options: { readonly path?: string, readonly sameSite?: string, readonly secure?: boolean, readonly httpOnly?: boolean }) { - headers.append('set-cookie', [ - `${name}=${encodeURIComponent(value)}`, - options.path ? `Path=${options.path}` : undefined, - options.sameSite ? `SameSite=${options.sameSite[0]?.toUpperCase()}${options.sameSite.slice(1)}` : undefined, - options.secure ? 'Secure' : undefined, - options.httpOnly ? 'HttpOnly' : undefined, - ].filter((attribute): attribute is string => typeof attribute === 'string').join('; ')) - }, - }, - }) - }, - }, - })) - - const { protectRoutes } = await import('../src/next/server') - const request = { - method: 'GET', - cookies: { - get: vi.fn(() => undefined), - }, - headers: new Headers({ - 'x-forwarded-proto': 'https', - }), - nextUrl: new URL('http://app.test/login'), - url: 'http://app.test/login', - } - const response = await protectRoutes(async () => undefined)(request) - const setCookie = response?.headers.get('set-cookie') ?? '' - const encodedToken = setCookie.split(';', 1)[0]?.slice('XSRF-TOKEN='.length) - const token = decodeURIComponent(encodedToken ?? '') - const separator = token.indexOf('.') - const nonce = token.slice(0, separator) - const signature = token.slice(separator + 1) - - expect(response?.headers.get('x-middleware-next')).toBe('1') - expect(setCookie).toContain('XSRF-TOKEN=') - expect(setCookie).toContain('Path=/') - expect(setCookie).toContain('SameSite=Lax') - expect(setCookie).toContain('Secure') - expect(setCookie).not.toContain('HttpOnly') - expect(separator).toBeGreaterThan(0) - expect(signature).toBe(createHmac('sha256', 'next-csrf-signing-key') - .update(nonce) - .digest('base64url')) - } finally { - if (typeof previousAppKey === 'undefined') { - delete process.env.APP_KEY - } else { - process.env.APP_KEY = previousAppKey - } + it('leaves Next CSRF cookies to the security middleware when route protection lets a guest page continue', async () => { + const { protectRoutes } = await import('../src/next/server') + const request = { + method: 'GET', + cookies: { + get: vi.fn(() => undefined), + }, + headers: new Headers({ + 'x-forwarded-proto': 'https', + }), + nextUrl: new URL('http://app.test/login'), + url: 'http://app.test/login', } + const response = await protectRoutes(async () => undefined)(request) + + expect(response).toBeUndefined() }) it('keeps Next route protection branch behavior unchanged', async () => { @@ -767,79 +712,56 @@ describe('@holo-js/auth framework helpers', () => { expect(authResponse.headers.get('location')).toBe('https://other.test/login') }) - it('issues a signed SvelteKit CSRF cookie before resolving guest pages', async () => { - const previousAppKey = process.env.APP_KEY - process.env.APP_KEY = 'sveltekit-csrf-signing-key' - - try { - vi.doMock('../src/index', () => ({ - default: { - guard() { - return { - provider: vi.fn(async () => null), - user: vi.fn(async () => null), - } - }, + it('leaves SvelteKit CSRF cookies to the security middleware', async () => { + vi.doMock('../src/index', () => ({ + default: { + guard() { + return { + provider: vi.fn(async () => null), + user: vi.fn(async () => null), + } }, - authRuntimeInternals: { - getRuntimeBindings() { - return { - config: { - defaults: { - guard: 'web', - }, + }, + authRuntimeInternals: { + getRuntimeBindings() { + return { + config: { + defaults: { + guard: 'web', }, - } - }, + }, + } }, - provider: vi.fn(async () => null), - user: vi.fn(async () => null), - })) - - const setCookie = vi.fn() - const { guestOnly } = await import('../src/sveltekit/server') - const resolve = vi.fn(() => new Response('ok')) - await guestOnly({ - routes: ['/login'], - redirectTo: '/admin', - })({ - event: { - url: new URL('http://app.test/login'), - cookies: { - get: vi.fn(() => undefined), - set: setCookie, - }, - request: { - method: 'GET', - headers: new Headers({ - 'x-forwarded-proto': 'https', - }), - }, + }, + provider: vi.fn(async () => null), + user: vi.fn(async () => null), + })) + + const setCookie = vi.fn() + const { guestOnly } = await import('../src/sveltekit/server') + const resolve = vi.fn(() => new Response('ok')) + await guestOnly({ + routes: ['/login'], + redirectTo: '/admin', + })({ + event: { + url: new URL('http://app.test/login'), + cookies: { + get: vi.fn(() => undefined), + set: setCookie, }, - resolve, - }) - const [name, token, options] = setCookie.mock.calls[0] ?? [] - const separator = typeof token === 'string' ? token.indexOf('.') : -1 - const nonce = typeof token === 'string' ? token.slice(0, separator) : '' - const signature = typeof token === 'string' ? token.slice(separator + 1) : '' - - expect(name).toBe('XSRF-TOKEN') - expect(options).toEqual({ - path: '/', - sameSite: 'lax', - secure: true, - httpOnly: false, - }) - expect(signature).toBe(createHmac('sha256', 'sveltekit-csrf-signing-key') - .update(nonce) - .digest('base64url')) - } finally { - if (typeof previousAppKey === 'undefined') { - delete process.env.APP_KEY - } else { - process.env.APP_KEY = previousAppKey - } - } + request: { + method: 'GET', + headers: new Headers({ + 'x-forwarded-proto': 'https', + }), + }, + }, + resolve, + }) + + expect(resolve).toHaveBeenCalledOnce() + expect(setCookie).not.toHaveBeenCalled() }) it('compares Nuxt self redirects by pathname without query strings', async () => { @@ -939,56 +861,21 @@ describe('@holo-js/auth framework helpers', () => { expect(navigateTo).toHaveBeenCalledWith('/super-admin', { redirectCode: 302 }) }) - it('issues a signed Nuxt CSRF cookie before allowing guest pages', async () => { - const previousAppKey = process.env.APP_KEY - const previousAppUrl = process.env.APP_URL - process.env.APP_KEY = 'nuxt-csrf-signing-key' - process.env.APP_URL = 'https://app.test' - - try { - const cookie = { value: undefined as string | undefined } - const useCookie = vi.fn(() => cookie) - vi.doMock('../src/nuxt', () => ({ - useAuth: vi.fn(async () => ({ - authenticated: { value: false }, - })), - })) - vi.doMock('#imports', () => createNuxtImportsMock({ useCookie })) - - const { guestOnly } = await import('../src/nuxt/server') - const middleware = guestOnly({ - routes: ['/login'], - redirectTo: '/admin', - }) - - await expect(middleware({ path: '/login' }, { path: '/' })).resolves.toBeUndefined() - - const separator = cookie.value?.indexOf('.') ?? -1 - const nonce = cookie.value?.slice(0, separator) ?? '' - const signature = cookie.value?.slice(separator + 1) ?? '' + it('leaves Nuxt CSRF cookies to the security middleware before allowing guest pages', async () => { + vi.doMock('../src/nuxt', () => ({ + useAuth: vi.fn(async () => ({ + authenticated: { value: false }, + })), + })) + vi.doMock('#imports', () => createNuxtImportsMock()) - expect(useCookie).toHaveBeenCalledWith('XSRF-TOKEN', { - path: '/', - sameSite: 'lax', - secure: true, - httpOnly: false, - }) - expect(signature).toBe(createHmac('sha256', 'nuxt-csrf-signing-key') - .update(nonce) - .digest('base64url')) - } finally { - if (typeof previousAppKey === 'undefined') { - delete process.env.APP_KEY - } else { - process.env.APP_KEY = previousAppKey - } + const { guestOnly } = await import('../src/nuxt/server') + const middleware = guestOnly({ + routes: ['/login'], + redirectTo: '/admin', + }) - if (typeof previousAppUrl === 'undefined') { - delete process.env.APP_URL - } else { - process.env.APP_URL = previousAppUrl - } - } + await expect(middleware({ path: '/login' }, { path: '/' })).resolves.toBeUndefined() }) it('passes the configured guard through Nuxt auth-only middleware', async () => { diff --git a/packages/forms/src/client-security.ts b/packages/forms/src/client-security.ts index e0e9e1c7..f0f15236 100644 --- a/packages/forms/src/client-security.ts +++ b/packages/forms/src/client-security.ts @@ -59,23 +59,28 @@ export function resetSecurityClientModuleCache(): void { securityClientModulePromise = undefined } -export async function getClientCsrfField(): Promise<{ readonly name: string, readonly value: string }> { +export async function getClientCsrfField(): Promise<{ readonly name: string, readonly value: string } | undefined> { const runtime = globalThis as BrowserLikeGlobal if (!runtime.document || typeof runtime.document.cookie !== 'string') { - throw new FormContractError( - '[@holo-js/forms] useForm({ csrf: true }) requires a browser-like document.cookie environment.', - ) + return undefined } - const security = await loadSecurityClientModule() + let security: SecurityClientModule + try { + security = await loadSecurityClientModule() + } catch (error) { + if (error instanceof FormContractError) { + return undefined + } + + throw error + } const config = security.getSecurityClientConfig().csrf const value = parseCookieHeader(runtime.document.cookie)[config.cookie] if (!value) { - throw new FormContractError( - `[@holo-js/forms] Missing CSRF cookie "${config.cookie}" required by useForm({ csrf: true }).`, - ) + return undefined } return Object.freeze({ diff --git a/packages/forms/src/contracts.ts b/packages/forms/src/contracts.ts index 69586831..2f95af3d 100644 --- a/packages/forms/src/contracts.ts +++ b/packages/forms/src/contracts.ts @@ -56,7 +56,6 @@ export interface SerializedFormSubmission { } export interface FormSecurityOptions { - readonly csrf?: boolean readonly throttle?: string } @@ -679,7 +678,7 @@ export async function validate( let validatedSubmission: | FormSubmissionResult> | undefined - const usesSecurityOptions = options.csrf === true || typeof options.throttle === 'string' + const usesSecurityOptions = typeof options.throttle === 'string' const normalizedRequestInput = normalizeRequestLikeInput(input) ?? (usesSecurityOptions ? await resolveAmbientFormDataRequest(input) : undefined) const validationInput = normalizedRequestInput ?? input @@ -696,14 +695,6 @@ export async function validate( try { const { loadSecurityModule } = await import('./security') const security = await loadSecurityModule() - const verificationRequest = (() => { - try { - return request.clone() - } catch { - return request - } - })() - if (typeof options.throttle === 'string') { const inspection = await validateInput(request.clone(), schemaDefinition as ValidationSchema) const throttleValues = inspection.valid ? inspection.data : inspection.values @@ -715,10 +706,6 @@ export async function validate( values: throttleValues, }) } - - if (options.csrf === true) { - await security.csrf.verify(verificationRequest) - } } catch (error) { const { formsSecurityInternals } = await import('./security') if (formsSecurityInternals.isRootSecurityError(error)) { diff --git a/packages/forms/src/internal/client.ts b/packages/forms/src/internal/client.ts index 8c450ae0..6e4c2bba 100644 --- a/packages/forms/src/internal/client.ts +++ b/packages/forms/src/internal/client.ts @@ -47,7 +47,6 @@ export type ClientSubmitResult export interface UseFormOptions { readonly action?: string readonly method?: string - readonly csrf?: boolean readonly validateOn?: ValidateOnMode readonly initialValues?: Partial readonly initialState?: SerializedFormSubmission | FormFailurePayload | null @@ -866,9 +865,11 @@ export function createFormClient const method = options.method ?? 'POST' const formData = buildFormData(state.values) - if (options.csrf === true && !isSafeMethod(method)) { + if (!isSafeMethod(method)) { const csrfField = await getClientCsrfField() - formData.set(csrfField.name, csrfField.value) + if (csrfField) { + formData.set(csrfField.name, csrfField.value) + } } let response: ClientSubmitResult diff --git a/packages/forms/src/security.ts b/packages/forms/src/security.ts index 173fad16..2c8f2966 100644 --- a/packages/forms/src/security.ts +++ b/packages/forms/src/security.ts @@ -6,9 +6,6 @@ import { } from './security-shared' type SecurityModule = { - csrf: { - verify(request: Request): Promise - } rateLimit(name: string, options: { readonly request?: Request, readonly key?: string, readonly values?: Readonly> }): Promise } diff --git a/packages/forms/tests/client.test.ts b/packages/forms/tests/client.test.ts index 6368582a..81fa3051 100644 --- a/packages/forms/tests/client.test.ts +++ b/packages/forms/tests/client.test.ts @@ -67,7 +67,7 @@ function createSensitiveSchemaFixture(fields: Record): Sensitiv } describe('@holo-js/forms client', () => { - it('attaches the configured csrf token to outgoing form data when enabled', async () => { + it('attaches the configured csrf token to unsafe outgoing form data when the cookie exists', async () => { const registerUser = schema({ email: field.string().required().email(), }) @@ -79,7 +79,6 @@ describe('@holo-js/forms client', () => { } as Document const client = useForm(registerUser, { - csrf: true, initialValues: { email: 'ava@example.com', }, @@ -100,7 +99,7 @@ describe('@holo-js/forms client', () => { }) }) - it('supports client-side csrf configuration without requiring the server security runtime in the browser', async () => { + it('uses csrf names exposed by the security client module without requiring the server runtime in the browser', async () => { const registerUser = schema({ email: field.string().required().email(), }) @@ -115,7 +114,6 @@ describe('@holo-js/forms client', () => { } as Document const client = useForm(registerUser, { - csrf: true, initialValues: { email: 'ava@example.com', }, @@ -137,7 +135,7 @@ describe('@holo-js/forms client', () => { }) }) - it('does not attach csrf tokens for safe methods and fails clearly when the csrf cookie is missing', async () => { + it('does not attach csrf tokens for safe methods or when the csrf cookie is missing', async () => { const registerUser = schema({ email: field.string().required().email(), }) @@ -149,7 +147,6 @@ describe('@holo-js/forms client', () => { } as Document const safeClient = useForm(registerUser, { - csrf: true, method: 'GET', initialValues: { email: 'ava@example.com', @@ -170,17 +167,21 @@ describe('@holo-js/forms client', () => { cookie: '', } as Document - const failingClient = useForm(registerUser, { - csrf: true, + const clientWithoutCookie = useForm(registerUser, { initialValues: { email: 'ava@example.com', }, - async submitter() { - throw new Error('submitter should not run') + async submitter({ formData }) { + expect(formData.has('_token')).toBe(false) + return { + ok: true, + status: 200, + data: undefined, + } }, }) - await expect(failingClient.submit()).rejects.toThrow('Missing CSRF cookie "XSRF-TOKEN"') + await clientWithoutCookie.submit() }) it('creates a typed field tree with initial values and no initial errors', () => { diff --git a/packages/forms/tests/client.type.test.ts b/packages/forms/tests/client.type.test.ts index 41e42176..cab92116 100644 --- a/packages/forms/tests/client.type.test.ts +++ b/packages/forms/tests/client.type.test.ts @@ -20,7 +20,6 @@ describe('@holo-js/forms client typing', () => { }) const client = useForm(registerUser, { - csrf: false, initialValues: { email: 'ava@example.com', age: undefined, diff --git a/packages/forms/tests/contracts.test.ts b/packages/forms/tests/contracts.test.ts index 7bfc0b4c..91fa6ce1 100644 --- a/packages/forms/tests/contracts.test.ts +++ b/packages/forms/tests/contracts.test.ts @@ -246,6 +246,37 @@ describe('@holo-js/forms contracts', () => { }) }) + it('does not flash uploaded files in serialized failure payloads', async () => { + const profile = schema({ + avatar: field.file().required().image().maxSize(1), + }) + const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' }) + + const failure = await validate({ + avatar, + }, profile) + + expect(failure.valid).toBe(false) + if (failure.valid) { + throw new Error('Expected form submission failure.') + } + + expect(failure.values.avatar).toBe(avatar) + expect(failure.serialize()).toEqual({ + valid: false, + submitted: true, + values: {}, + errors: failure.errors.flatten(), + }) + expect(failure.fail()).toEqual({ + ok: false, + status: 422, + valid: false, + values: {}, + errors: failure.errors.flatten(), + }) + }) + it('excludes password-like dontFlash fields while preserving transport tokens in serialized failure payloads', async () => { const registerUser = schema({ email: field.string().required().email(), @@ -451,48 +482,16 @@ describe('@holo-js/forms contracts', () => { }) }) - it('runs csrf and throttle checks through validate() and returns form-shaped security failures', async () => { + it('runs throttle checks through validate() and returns form-shaped security failures', async () => { const login = schema({ email: field.string().required().email(), }) ;(globalThis as typeof globalThis & { __holoFormsSecurityModule__?: unknown }).__holoFormsSecurityModule__ = createSecurityModule() - const csrfFailure = await validate(new Request('https://app.test/login', { - method: 'POST', - body: new URLSearchParams({ - email: 'ava@example.com', - }), - }), login, { - csrf: true, - }) - - expect(csrfFailure.valid).toBe(false) - if (csrfFailure.valid) { - throw new Error('Expected csrf failure.') - } - - expect(csrfFailure.values).toEqual({ - email: 'ava@example.com', - }) - expect(csrfFailure.errors.get('_root')).toEqual(['CSRF token mismatch.']) - expect(csrfFailure.fail()).toEqual({ - ok: false, - status: 419, - valid: false, - values: { - email: 'ava@example.com', - }, - errors: { - _root: ['CSRF token mismatch.'], - }, - }) - const allowedRequest = new Request('https://app.test/login', { method: 'POST', headers: { - cookie: 'XSRF-TOKEN=login-token', - 'X-CSRF-TOKEN': 'login-token', 'x-forwarded-for': '203.0.113.7', }, body: new URLSearchParams({ @@ -501,7 +500,6 @@ describe('@holo-js/forms contracts', () => { }) const firstAllowed = await validate(allowedRequest, login, { - csrf: true, throttle: 'login', }) expect(firstAllowed.valid).toBe(true) @@ -509,8 +507,6 @@ describe('@holo-js/forms contracts', () => { const differentEmail = await validate(new Request('https://app.test/login', { method: 'POST', headers: { - cookie: 'XSRF-TOKEN=login-token', - 'X-CSRF-TOKEN': 'login-token', 'x-forwarded-for': '203.0.113.7', }, body: new URLSearchParams({ @@ -524,8 +520,6 @@ describe('@holo-js/forms contracts', () => { const throttled = await validate(new Request('https://app.test/login', { method: 'POST', headers: { - cookie: 'XSRF-TOKEN=login-token', - 'X-CSRF-TOKEN': 'login-token', 'x-forwarded-for': '203.0.113.7', }, body: new URLSearchParams({ @@ -551,81 +545,17 @@ describe('@holo-js/forms contracts', () => { }) }) - it('applies throttle before csrf verification when both checks are enabled', async () => { - const register = schema({ - email: field.string().required().email(), - }) - - ;(globalThis as typeof globalThis & { __holoFormsSecurityModule__?: unknown }).__holoFormsSecurityModule__ = createSecurityModule() - - const firstAttempt = await validate(new Request('https://app.test/register', { - method: 'POST', - headers: { - 'x-forwarded-for': '203.0.113.8', - }, - body: new URLSearchParams({ - email: 'ava@example.com', - }), - }), register, { - csrf: true, - throttle: 'register', - }) - - expect(firstAttempt.valid).toBe(false) - if (firstAttempt.valid) { - throw new Error('Expected csrf failure.') - } - expect(firstAttempt.fail().status).toBe(419) - - const throttled = await validate(new Request('https://app.test/register', { - method: 'POST', - headers: { - 'x-forwarded-for': '203.0.113.8', - }, - body: new URLSearchParams({ - email: 'ava@example.com', - }), - }), register, { - csrf: true, - throttle: 'register', - }) - - expect(throttled.valid).toBe(false) - if (throttled.valid) { - throw new Error('Expected throttle failure.') - } - expect(throttled.fail().status).toBe(429) - expect(throttled.errors.get('_root')).toEqual(['Too many attempts. Please try again later.']) - }) - - it('merges validation errors with security root failures and requires Request inputs for security-aware validation', async () => { + it('requires Request inputs for throttle-aware validation', async () => { const login = schema({ email: field.string().required().email(), }) ;(globalThis as typeof globalThis & { __holoFormsSecurityModule__?: unknown }).__holoFormsSecurityModule__ = createSecurityModule() - const failure = await validate(new Request('https://app.test/login', { - method: 'POST', - body: new URLSearchParams({ - email: 'bad', - }), - }), login, { - csrf: true, - }) - - expect(failure.valid).toBe(false) - if (failure.valid) { - throw new Error('Expected combined failure.') - } - - expect(failure.errors.first('email')).toBeDefined() - expect(failure.errors.get('_root')).toEqual(['CSRF token mismatch.']) - await expect(validate({ email: 'ava@example.com', }, login, { - csrf: true, + throttle: 'login', })).rejects.toThrow('Security-aware validate() options require a Request or request-like event input.') }) @@ -643,8 +573,6 @@ describe('@holo-js/forms contracts', () => { req: { method: 'POST', headers: { - cookie: 'XSRF-TOKEN=login-token', - 'X-CSRF-TOKEN': 'login-token', 'x-forwarded-for': '203.0.113.7', host: 'app.test', }, @@ -656,7 +584,6 @@ describe('@holo-js/forms contracts', () => { } const firstAllowed = await validate(event, login, { - csrf: true, throttle: 'login', }) expect(firstAllowed.valid).toBe(true) @@ -685,7 +612,6 @@ describe('@holo-js/forms contracts', () => { runtime.__holoFormsNextHeadersImport__ = async () => ({ headers: () => new Headers({ cookie: 'XSRF-TOKEN=login-token', - 'X-CSRF-TOKEN': 'login-token', 'x-forwarded-for': '203.0.113.11', host: 'app.test', referer: 'https://app.test/login', @@ -698,7 +624,6 @@ describe('@holo-js/forms contracts', () => { formData.set('email', 'ava@example.com') const firstAllowed = await validate(formData, login, { - csrf: true, throttle: 'login', }) @@ -725,68 +650,6 @@ describe('@holo-js/forms contracts', () => { expect(throttled.errors.get('_root')).toEqual(['Too many attempts. Please try again later.']) }) - it('falls back to forwarded headers when ambient Next action referer is malformed', async () => { - const login = schema({ - email: field.string().required().email(), - }) - const runtime = globalThis as FormsTestGlobal - - runtime.__holoFormsSecurityModule__ = createSecurityModule() - runtime.__holoFormsNextHeadersImport__ = async () => ({ - headers: () => new Headers({ - cookie: 'XSRF-TOKEN=login-token', - 'X-CSRF-TOKEN': 'login-token', - 'x-forwarded-host': 'app.test', - 'x-forwarded-proto': 'https', - referer: 'http://%', - }), - }) - - const formData = new FormData() - formData.set('email', 'ava@example.com') - - const submission = await validate(formData, login, { - csrf: true, - }) - - expect(submission.valid).toBe(true) - }) - - it('preserves cookie semantics for request-like header arrays during csrf validation', async () => { - const login = schema({ - email: field.string().required().email(), - }) - - ;(globalThis as typeof globalThis & { __holoFormsSecurityModule__?: unknown }).__holoFormsSecurityModule__ = createSecurityModule() - - const submission = await validate({ - method: 'POST', - path: '/login', - headers: { - cookie: [ - 'tracking=1', - 'XSRF-TOKEN=login-token', - ], - 'X-CSRF-TOKEN': 'login-token', - host: 'app.test', - }, - body: new URLSearchParams({ - email: 'ava@example.com', - }), - }, login, { - csrf: true, - }) - - expect(submission.valid).toBe(true) - if (!submission.valid) { - throw new Error('Expected csrf validation success.') - } - - expect(submission.data).toEqual({ - email: 'ava@example.com', - }) - }) - it('reuses embedded Request instances when normalizing request-like inputs', () => { const webRequest = new Request('https://app.test/web', { method: 'POST', @@ -1097,98 +960,6 @@ describe('@holo-js/forms contracts', () => { expect(formsInternals.normalizeRequestLikeInput(null)).toBeUndefined() }) - it('falls back to empty values when request inspection cannot be replayed after a security failure', async () => { - const login = schema({ - email: field.string().required().email(), - }) - - ;(globalThis as typeof globalThis & { __holoFormsSecurityModule__?: unknown }).__holoFormsSecurityModule__ = createSecurityModule() - - const request = new Request('https://app.test/login', { - method: 'POST', - body: new URLSearchParams({ - email: 'ava@example.com', - }), - }) - - await request.text() - - const failure = await validate(request, login, { - csrf: true, - }) - - expect(failure.valid).toBe(false) - if (failure.valid) { - throw new Error('Expected security failure.') - } - - expect(failure.values).toEqual({}) - expect(failure.errors.flatten()).toEqual({ - _root: ['CSRF token mismatch.'], - }) - expect(failure.fail()).toEqual({ - ok: false, - status: 419, - valid: false, - values: {}, - errors: { - _root: ['CSRF token mismatch.'], - }, - }) - }) - - it('preserves existing root validation errors when returning security failures', async () => { - const base = schema({ - email: field.string().required().email(), - }) - const login = { - ...base, - '~standard': { - ...base['~standard'], - async validate(value: unknown) { - const result = await base['~standard'].validate(value) - - if (result.issues) { - return result - } - - return { - issues: [ - { - message: 'Passwords do not match.', - }, - ], - } - }, - }, - } as typeof base - - ;(globalThis as typeof globalThis & { __holoFormsSecurityModule__?: unknown }).__holoFormsSecurityModule__ = createSecurityModule() - - const failure = await validate(new Request('https://app.test/login', { - method: 'POST', - body: new URLSearchParams({ - email: 'ava@example.com', - }), - }), login, { - csrf: true, - }) - - expect(failure.valid).toBe(false) - if (failure.valid) { - throw new Error('Expected combined root failure.') - } - - expect(failure.errors.get('_root')).toEqual([ - 'Passwords do not match.', - 'CSRF token mismatch.', - ]) - expect(failure.fail().errors._root).toEqual([ - 'Passwords do not match.', - 'CSRF token mismatch.', - ]) - }) - it('validates throttled requests only once per submission', async () => { let validateCalls = 0 @@ -1211,15 +982,12 @@ describe('@holo-js/forms contracts', () => { const result = await validate(new Request('https://app.test/login', { method: 'POST', headers: { - cookie: 'XSRF-TOKEN=login-token', - 'X-CSRF-TOKEN': 'login-token', 'x-forwarded-for': '203.0.113.7', }, body: new URLSearchParams({ email: 'ava@example.com', }), }), login, { - csrf: true, throttle: 'login', }) @@ -1280,11 +1048,6 @@ describe('@holo-js/forms contracts', () => { }) ;(globalThis as typeof globalThis & { __holoFormsSecurityModule__?: unknown }).__holoFormsSecurityModule__ = { - csrf: { - async verify() { - return undefined - }, - }, async rateLimit() { throw new Error('security exploded') }, diff --git a/packages/forms/tests/contracts.type.test.ts b/packages/forms/tests/contracts.type.test.ts index c132fac3..4f7678b7 100644 --- a/packages/forms/tests/contracts.type.test.ts +++ b/packages/forms/tests/contracts.type.test.ts @@ -64,9 +64,7 @@ describe('@holo-js/forms typing', () => { const emailErrors = failure.errors.email async function expectsTypedSubmission(request: Request) { - const submission = await validate(request, registerUser, { - csrf: false, - }) + const submission = await validate(request, registerUser) if (submission.valid) { const typedName: string = submission.data.name @@ -90,7 +88,6 @@ describe('@holo-js/forms typing', () => { async function expectsTypedSecuritySubmission(request: Request) { const submission = await validate(request, registerUser, { - csrf: false, throttle: 'login', }) @@ -118,7 +115,6 @@ describe('@holo-js/forms typing', () => { async function expectsTypedEventSubmission(event: FormRequestLikeInput) { const submission = await validate(event, registerUser, { - csrf: false, throttle: 'login', }) diff --git a/packages/forms/tests/security.test.ts b/packages/forms/tests/security.test.ts index 885bb20e..8445000c 100644 --- a/packages/forms/tests/security.test.ts +++ b/packages/forms/tests/security.test.ts @@ -187,12 +187,8 @@ describe('@holo-js/forms security helpers', () => { it('loads the server security entrypoint through the Vitest literal import branch', async () => { vi.resetModules() - const verify = vi.fn(async () => {}) const rateLimit = vi.fn(async () => ({ limited: false })) vi.doMock('@holo-js/security', () => ({ - csrf: { - verify, - }, rateLimit, })) @@ -200,7 +196,6 @@ describe('@holo-js/forms security helpers', () => { const mod = await import('../src/security') const security = await mod.loadSecurityModule() - expect(security.csrf.verify).toBe(verify) expect(security.rateLimit).toBe(rateLimit) } finally { vi.doUnmock('@holo-js/security') @@ -458,7 +453,7 @@ console.log(JSON.stringify(client.getSecurityClientConfig().csrf)) }) }) - it('fails clearly when the configured csrf token cookie is missing', async () => { + it('returns no client csrf field when the configured csrf token cookie is missing', async () => { ;(globalThis as typeof globalThis & { __holoFormsSecurityClientModule__?: unknown }).__holoFormsSecurityClientModule__ = { getSecurityClientConfig() { return { @@ -474,10 +469,10 @@ console.log(JSON.stringify(client.getSecurityClientConfig().csrf)) cookie: 'tracking=one', } as Document - await expect(getClientCsrfField()).rejects.toThrow('Missing CSRF cookie "XSRF-TOKEN"') + await expect(getClientCsrfField()).resolves.toBeUndefined() }) - it('fails clearly when csrf helpers run without a browser document or a test override', async () => { + it('returns no client csrf field without a browser document and still reports missing server security imports', async () => { ;(globalThis as typeof globalThis & { __holoFormsSecurityImport__?: () => Promise }).__holoFormsSecurityImport__ = async () => { throw new Error('Cannot find package @holo-js/security') } @@ -485,7 +480,7 @@ console.log(JSON.stringify(client.getSecurityClientConfig().csrf)) throw new Error('Cannot find package @holo-js/security') } - await expect(getClientCsrfField()).rejects.toBeInstanceOf(FormContractError) + await expect(getClientCsrfField()).resolves.toBeUndefined() await expect(loadSecurityModule()).rejects.toBeInstanceOf(FormContractError) await expect(loadSecurityClientModule()).rejects.toBeInstanceOf(FormContractError) }) diff --git a/packages/security/package.json b/packages/security/package.json index 7b4bf070..1e6aa459 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -20,6 +20,21 @@ "import": "./dist/contracts.mjs", "default": "./dist/contracts.mjs" }, + "./next/server": { + "types": "./dist/next/server.d.ts", + "import": "./dist/next/server.mjs", + "default": "./dist/next/server.mjs" + }, + "./nuxt/server": { + "types": "./dist/nuxt/server.d.ts", + "import": "./dist/nuxt/server.mjs", + "default": "./dist/nuxt/server.mjs" + }, + "./sveltekit/server": { + "types": "./dist/sveltekit/server.d.ts", + "import": "./dist/sveltekit/server.mjs", + "default": "./dist/sveltekit/server.mjs" + }, "./drivers/redis-adapter": { "types": "./dist/drivers/redis-adapter.d.ts", "import": "./dist/drivers/redis-adapter.mjs", @@ -41,16 +56,26 @@ "@holo-js/config": "catalog:" }, "peerDependencies": { + "h3": "catalog:", + "next": "catalog:", "ioredis": "catalog:" }, "peerDependenciesMeta": { + "h3": { + "optional": true + }, + "next": { + "optional": true + }, "ioredis": { "optional": true } }, "devDependencies": { "@types/node": "catalog:", + "h3": "catalog:", "ioredis": "catalog:", + "next": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/security/src/client-config.ts b/packages/security/src/client-config.ts new file mode 100644 index 00000000..9150dece --- /dev/null +++ b/packages/security/src/client-config.ts @@ -0,0 +1,98 @@ +import type { NormalizedHoloSecurityConfig } from '@holo-js/config' +import type { SecurityClientConfig } from './contracts' + +export const SECURITY_CLIENT_CONFIG_COOKIE = 'HOLO-CSRF-CONFIG' + +const DEFAULT_SECURITY_CLIENT_CONFIG: SecurityClientConfig = Object.freeze({ + csrf: Object.freeze({ + field: '_token', + cookie: 'XSRF-TOKEN', + }), +}) + +function safeDecodeCookieValue(value: string): string | undefined { + try { + return decodeURIComponent(value) + } catch { + return undefined + } +} + +function parseCookieHeader(header: string | null | undefined): Readonly> { + if (!header) { + return Object.freeze({}) + } + + const entries = header + .split(';') + .map(segment => segment.trim()) + .filter(Boolean) + .map((segment) => { + const separator = segment.indexOf('=') + if (separator <= 0) { + return undefined + } + + const name = safeDecodeCookieValue(segment.slice(0, separator)) + const value = safeDecodeCookieValue(segment.slice(separator + 1)) + + return name && typeof value === 'string' + ? [name, value] as const + : undefined + }) + .filter((entry): entry is readonly [string, string] => typeof entry !== 'undefined') + + return Object.freeze(Object.fromEntries(entries)) +} + +function isSecurityClientConfig(value: unknown): value is SecurityClientConfig { + return !!value + && typeof value === 'object' + && !!(value as { readonly csrf?: unknown }).csrf + && typeof (value as { readonly csrf: { readonly field?: unknown } }).csrf.field === 'string' + && typeof (value as { readonly csrf: { readonly cookie?: unknown } }).csrf.cookie === 'string' +} + +export function getDefaultSecurityClientConfig(): SecurityClientConfig { + return DEFAULT_SECURITY_CLIENT_CONFIG +} + +export function createSecurityClientConfig(config: NormalizedHoloSecurityConfig): SecurityClientConfig { + return Object.freeze({ + csrf: Object.freeze({ + field: config.csrf.field, + cookie: config.csrf.cookie, + }), + }) +} + +export function serializeSecurityClientConfig(config: SecurityClientConfig): string { + return JSON.stringify(config) +} + +export function readSecurityClientConfigFromCookies(cookieHeader: string | null | undefined): SecurityClientConfig | undefined { + const raw = parseCookieHeader(cookieHeader)[SECURITY_CLIENT_CONFIG_COOKIE] + if (!raw) { + return undefined + } + + try { + const parsed = JSON.parse(raw) as unknown + if (!isSecurityClientConfig(parsed)) { + return undefined + } + + return Object.freeze({ + csrf: Object.freeze({ + field: parsed.csrf.field, + cookie: parsed.csrf.cookie, + }), + }) + } catch { + return undefined + } +} + +export const securityClientConfigInternals = { + parseCookieHeader, +} diff --git a/packages/security/src/client.ts b/packages/security/src/client.ts index cf1be93f..074bf0ee 100644 --- a/packages/security/src/client.ts +++ b/packages/security/src/client.ts @@ -1,62 +1,27 @@ -import type { SecurityClientBindings, SecurityClientConfig } from './contracts' +import { + getDefaultSecurityClientConfig, + readSecurityClientConfigFromCookies, + securityClientConfigInternals, +} from './client-config' +import type { SecurityClientConfig } from './contracts' export type { - SecurityClientBindings, SecurityClientConfig, } from './contracts' -type RuntimeSecurityClientState = { - bindings?: SecurityClientConfig -} - -const DEFAULT_SECURITY_CLIENT_CONFIG: SecurityClientConfig = Object.freeze({ - csrf: Object.freeze({ - field: '_token', - cookie: 'XSRF-TOKEN', - }), -}) - -function getDefaultSecurityClientConfig(): SecurityClientConfig { - return DEFAULT_SECURITY_CLIENT_CONFIG -} - -function getSecurityClientState(): RuntimeSecurityClientState { - const runtime = globalThis as typeof globalThis & { - __holoSecurityClient__?: RuntimeSecurityClientState +type BrowserLikeGlobal = typeof globalThis & { + readonly document?: { + readonly cookie?: string } - - runtime.__holoSecurityClient__ ??= {} - return runtime.__holoSecurityClient__ -} - -function normalizeSecurityClientConfig(bindings?: SecurityClientBindings): SecurityClientConfig { - const defaults = getDefaultSecurityClientConfig() - const csrf = Object.freeze({ - field: bindings?.config?.csrf?.field ?? defaults.csrf.field, - cookie: bindings?.config?.csrf?.cookie ?? defaults.csrf.cookie, - }) - - return Object.freeze({ - csrf, - }) -} - -export function configureSecurityClient(bindings?: SecurityClientBindings): void { - getSecurityClientState().bindings = bindings - ? normalizeSecurityClientConfig(bindings) - : undefined } export function getSecurityClientConfig(): SecurityClientConfig { - return getSecurityClientState().bindings ?? DEFAULT_SECURITY_CLIENT_CONFIG -} - -export function resetSecurityClient(): void { - getSecurityClientState().bindings = undefined + const runtime = globalThis as BrowserLikeGlobal + return readSecurityClientConfigFromCookies(runtime.document?.cookie) ?? getDefaultSecurityClientConfig() } export const securityClientInternals = { getDefaultSecurityClientConfig, - getSecurityClientState, - normalizeSecurityClientConfig, + readSecurityClientConfigFromCookies, + ...securityClientConfigInternals, } diff --git a/packages/security/src/contracts.ts b/packages/security/src/contracts.ts index e9dde650..addcb306 100644 --- a/packages/security/src/contracts.ts +++ b/packages/security/src/contracts.ts @@ -56,13 +56,13 @@ export interface SecurityClientConfig { } } -export interface SecurityClientBindings { - readonly config?: { - readonly csrf?: Partial - } +export interface SecurityCsrfField { + readonly name: string + readonly value: string } -export interface SecurityCsrfField { +export interface SecurityCsrfInput { + readonly type: 'hidden' readonly name: string readonly value: string } @@ -87,6 +87,7 @@ export interface SecurityClearRateLimitOptions { export interface SecurityCsrfFacade { token(request: Request): Promise field(request: Request): Promise + input(request: Request): Promise cookie(request: Request, token?: string): Promise verify(request: Request): Promise } diff --git a/packages/security/src/csrf.ts b/packages/security/src/csrf.ts index 9281029c..4ce246e8 100644 --- a/packages/security/src/csrf.ts +++ b/packages/security/src/csrf.ts @@ -1,5 +1,11 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' -import { SecurityCsrfError, type SecurityCsrfFacade, type SecurityCsrfField, type SecurityProtectOptions } from './contracts' +import { + SecurityCsrfError, + type SecurityCsrfFacade, + type SecurityCsrfField, + type SecurityCsrfInput, + type SecurityProtectOptions, +} from './contracts' import { rateLimit } from './rate-limit' import { getSecurityRuntime } from './runtime' @@ -163,6 +169,38 @@ function getCookieToken(request: Request): string | undefined { return parseCookieHeader(request.headers.get('cookie'))[cookie] } +function getHeaderToken(request: Request): string | undefined { + const { header } = getSecurityRuntime().config.csrf + return request.headers.get(header)?.trim() || undefined +} + +function getRequestOrigin(request: Request): string | undefined { + const origin = request.headers.get('origin')?.trim() + if (origin) { + return origin + } + + const referer = request.headers.get('referer')?.trim() + if (!referer) { + return undefined + } + + try { + return new URL(referer).origin + } catch { + return undefined + } +} + +function isSameOriginRequest(request: Request): boolean { + const requestOrigin = getRequestOrigin(request) + if (!requestOrigin) { + return false + } + + return requestOrigin === new URL(request.url).origin +} + async function readFormToken(request: Request): Promise { const { field } = getSecurityRuntime().config.csrf try { @@ -174,16 +212,6 @@ async function readFormToken(request: Request): Promise { } } -async function getRequestToken(request: Request): Promise { - const { header } = getSecurityRuntime().config.csrf - const headerToken = request.headers.get(header)?.trim() - if (headerToken) { - return headerToken - } - - return await readFormToken(request) -} - async function verifyRequest( request: Request, options: { readonly allowExcludedPath: boolean }, @@ -197,13 +225,25 @@ async function verifyRequest( } const cookieToken = getCookieToken(request) - const requestToken = await getRequestToken(request) - if ( - !cookieToken - || !requestToken - || cookieToken !== requestToken - || !isValidSignedCsrfToken(cookieToken) - ) { + if (!cookieToken || !isValidSignedCsrfToken(cookieToken)) { + throw new SecurityCsrfError() + } + + const headerToken = getHeaderToken(request) + if (headerToken) { + if (headerToken !== cookieToken) { + throw new SecurityCsrfError() + } + + return + } + + if (isSameOriginRequest(request)) { + return + } + + const requestToken = await readFormToken(request) + if (requestToken !== cookieToken) { throw new SecurityCsrfError() } } @@ -257,6 +297,16 @@ export async function field(request: Request): Promise { }) } +export async function input(request: Request): Promise { + const csrfField = await field(request) + + return Object.freeze({ + type: 'hidden' as const, + name: csrfField.name, + value: csrfField.value, + }) +} + export async function cookie(request: Request, explicitToken?: string): Promise { const config = getSecurityRuntime().config.csrf const value = explicitToken @@ -286,6 +336,7 @@ export async function protect(request: Request, options: SecurityProtectOptions export const csrf = Object.freeze({ token, field, + input, cookie, verify, }) satisfies SecurityCsrfFacade @@ -295,8 +346,10 @@ export const csrfInternals = { generatedTokenCache, getForwardedProto, getCookieToken, + getHeaderToken, isSecureRequest, - getRequestToken, + isSameOriginRequest, + readFormToken, isExcludedPath, isSafeMethod, matchesPathPattern, diff --git a/packages/security/src/framework-shim.d.ts b/packages/security/src/framework-shim.d.ts new file mode 100644 index 00000000..fb288e33 --- /dev/null +++ b/packages/security/src/framework-shim.d.ts @@ -0,0 +1,53 @@ +declare module 'next/server' { + export const NextResponse: { + next(): Response & { + readonly cookies: { + set( + name: string, + value: string, + options?: { + readonly path?: string + readonly secure?: boolean + readonly sameSite?: 'lax' | 'strict' | 'none' + readonly httpOnly?: boolean + }, + ): void + } + } + } +} + +declare module 'h3' { + export type H3Event = { + readonly node: { + readonly req: { + readonly headers: Record + } + } + } + + export function createError(input: { + readonly statusCode: number + readonly statusMessage?: string + readonly message?: string + }): Error + export function defineEventHandler( + handler: (event: H3Event) => TValue | Promise, + ): (event: H3Event) => TValue | Promise + export function getCookie(event: H3Event, name: string): string | undefined + export function getMethod(event: H3Event): string + export function getRequestHeaders(event: H3Event): Record + export function getRequestURL(event: H3Event): URL + export function readRawBody(event: H3Event, encoding: false): Promise + export function setCookie( + event: H3Event, + name: string, + value: string, + options?: { + readonly path?: string + readonly secure?: boolean + readonly sameSite?: 'lax' | 'strict' | 'none' + readonly httpOnly?: boolean + }, + ): void +} diff --git a/packages/security/src/index.ts b/packages/security/src/index.ts index b834d169..da9f2f52 100644 --- a/packages/security/src/index.ts +++ b/packages/security/src/index.ts @@ -13,6 +13,7 @@ import { csrfInternals, cookie as createCsrfCookie, field as createCsrfField, + input as createCsrfInput, isSecureRequest, protect, token as createCsrfToken, @@ -91,6 +92,7 @@ export { csrf, createCsrfCookie, createCsrfField, + createCsrfInput, createCsrfToken, defineRateLimiter, defineSecurityRuntimeBindings, @@ -113,10 +115,10 @@ export { } export type { SecurityClearRateLimitOptions, - SecurityClientBindings, SecurityClientConfig, SecurityCsrfFacade, SecurityCorsFacade, + SecurityCsrfInput, SecurityCsrfField, SecurityDefaultRateLimitKeyResolver, SecurityProtectOptions, diff --git a/packages/security/src/next/server.ts b/packages/security/src/next/server.ts new file mode 100644 index 00000000..ebffd016 --- /dev/null +++ b/packages/security/src/next/server.ts @@ -0,0 +1,94 @@ +import { csrf, isSecureRequest, protect } from '../index' +import { SecurityCsrfError } from '../contracts' +import { + SECURITY_CLIENT_CONFIG_COOKIE, + createSecurityClientConfig, + serializeSecurityClientConfig, +} from '../client-config' +import { getSecurityRuntime } from '../runtime' + +type NextCsrfRequest = Request & { + readonly nextUrl?: URL +} + +type NextResponseCookieOptions = { + readonly path?: string + readonly secure?: boolean + readonly sameSite?: 'lax' | 'strict' | 'none' + readonly httpOnly?: boolean +} + +type NextResponseWithCookies = Response & { + readonly cookies: { + set(name: string, value: string, options?: NextResponseCookieOptions): void + } +} + +type NextServerModule = { + readonly NextResponse: { + next(): NextResponseWithCookies + } +} + +export type NextCsrfMiddleware = ( + request: NextCsrfRequest, +) => Response | undefined | Promise + +function isSafeMethod(method: string): boolean { + const normalized = method.trim().toUpperCase() + return normalized === 'GET' || normalized === 'HEAD' +} + +function createCsrfErrorResponse(error: SecurityCsrfError): Response { + return new Response(error.message, { + status: error.status, + headers: { + 'content-type': 'text/plain; charset=utf-8', + }, + }) +} + +async function issueCsrfCookie(request: NextCsrfRequest): Promise { + const { config } = getSecurityRuntime() + + if (!config.csrf.enabled || !isSafeMethod(request.method)) { + return undefined + } + + const { NextResponse } = await import('next/server') as NextServerModule + const response = NextResponse.next() + response.cookies.set(config.csrf.cookie, await csrf.token(request), { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: isSecureRequest(request), + }) + response.cookies.set(SECURITY_CLIENT_CONFIG_COOKIE, serializeSecurityClientConfig(createSecurityClientConfig(config)), { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: isSecureRequest(request), + }) + + return response +} + +export function csrfProtection(): NextCsrfMiddleware { + return async (request) => { + try { + await protect(request) + } catch (error) { + if (error instanceof SecurityCsrfError) { + return createCsrfErrorResponse(error) + } + + throw error + } + + return await issueCsrfCookie(request) + } +} + +export const nextSecurityInternals = { + issueCsrfCookie, +} diff --git a/packages/security/src/nuxt/server.ts b/packages/security/src/nuxt/server.ts new file mode 100644 index 00000000..3a84ea08 --- /dev/null +++ b/packages/security/src/nuxt/server.ts @@ -0,0 +1,91 @@ +import { + createError, + defineEventHandler, + getMethod, + getRequestHeaders, + getRequestURL, + setCookie, +} from 'h3' +import type { H3Event } from 'h3' +import { csrf, isSecureRequest, protect } from '../index' +import { SecurityCsrfError } from '../contracts' +import { + SECURITY_CLIENT_CONFIG_COOKIE, + createSecurityClientConfig, + serializeSecurityClientConfig, +} from '../client-config' +import { getSecurityRuntime } from '../runtime' + +function isSafeMethod(method: string): boolean { + const normalized = method.trim().toUpperCase() + return normalized === 'GET' || normalized === 'HEAD' +} + +function createHeaders(event: H3Event): Headers { + const headers = new Headers() + for (const [name, value] of Object.entries(getRequestHeaders(event))) { + if (typeof value === 'string') { + headers.append(name, value) + } + } + + return headers +} + +async function createRequest(event: H3Event): Promise { + const method = getMethod(event) + const headers = createHeaders(event) + + return new Request(getRequestURL(event), { + method, + headers, + }) +} + +async function issueCsrfCookie(event: H3Event, request: Request): Promise { + const { config } = getSecurityRuntime() + + if (!config.csrf.enabled || !isSafeMethod(request.method)) { + return + } + + setCookie(event, config.csrf.cookie, await csrf.token(request), { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: isSecureRequest(request), + }) + setCookie(event, SECURITY_CLIENT_CONFIG_COOKIE, serializeSecurityClientConfig(createSecurityClientConfig(config)), { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: isSecureRequest(request), + }) +} + +export function csrfProtection(): ReturnType { + return defineEventHandler(async (event) => { + const request = await createRequest(event) + + try { + await protect(request) + } catch (error) { + if (error instanceof SecurityCsrfError) { + throw createError({ + statusCode: error.status, + statusMessage: error.message, + message: error.message, + }) + } + + throw error + } + + await issueCsrfCookie(event, request) + }) +} + +export const nuxtSecurityInternals = { + createRequest, + issueCsrfCookie, +} diff --git a/packages/security/src/sveltekit/server.ts b/packages/security/src/sveltekit/server.ts new file mode 100644 index 00000000..f2d12475 --- /dev/null +++ b/packages/security/src/sveltekit/server.ts @@ -0,0 +1,103 @@ +import { csrf, isSecureRequest, protect } from '../index' +import { getSecurityRuntime } from '../runtime' +import { SecurityCsrfError } from '../contracts' +import { + SECURITY_CLIENT_CONFIG_COOKIE, + createSecurityClientConfig, + serializeSecurityClientConfig, +} from '../client-config' + +type SvelteKitCookieOptions = { + path: string + secure?: boolean + httpOnly?: boolean + sameSite?: 'lax' | 'strict' | 'none' +} + +type SvelteKitCsrfEvent = { + readonly url: URL + readonly request: Request + readonly cookies: { + get(name: string): string | undefined + set(name: string, value: string, options: SvelteKitCookieOptions): void + } +} + +type SvelteKitResolveOptions = { + readonly transformPageChunk?: (input: { + readonly html: string + readonly done: boolean + }) => string | Promise + readonly filterSerializedResponseHeaders?: (name: string, value: string) => boolean + readonly preload?: (input: { + readonly type: 'js' | 'css' | 'font' | 'asset' + readonly path: string + }) => boolean +} + +export type SvelteKitCsrfHandleInput = { + readonly event: TEvent + readonly resolve: (event: TEvent, options?: SvelteKitResolveOptions) => Response | Promise +} + +export type SvelteKitCsrfHandle = ( + input: SvelteKitCsrfHandleInput, +) => Response | Promise + +function isSafeMethod(method: string): boolean { + const normalized = method.trim().toUpperCase() + return normalized === 'GET' || normalized === 'HEAD' +} + +async function issueCsrfCookie(event: SvelteKitCsrfEvent): Promise { + const runtime = getSecurityRuntime() + const { config } = runtime + + if (!config.csrf.enabled || !isSafeMethod(event.request.method)) { + return + } + + event.cookies.set(config.csrf.cookie, await csrf.token(event.request), { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: isSecureRequest(event.request), + }) + event.cookies.set(SECURITY_CLIENT_CONFIG_COOKIE, serializeSecurityClientConfig(createSecurityClientConfig(config)), { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: isSecureRequest(event.request), + }) +} + +function createCsrfErrorResponse(error: SecurityCsrfError): Response { + return new Response(error.message, { + status: error.status, + headers: { + 'content-type': 'text/plain; charset=utf-8', + }, + }) +} + +export function csrfProtection(): SvelteKitCsrfHandle { + return async ({ event, resolve }) => { + try { + await protect(event.request) + } catch (error) { + if (error instanceof SecurityCsrfError) { + return createCsrfErrorResponse(error) + } + + throw error + } + + await issueCsrfCookie(event) + + return resolve(event) + } +} + +export const svelteKitSecurityInternals = { + issueCsrfCookie, +} diff --git a/packages/security/tests/client.test.ts b/packages/security/tests/client.test.ts index 7441087c..c01e2953 100644 --- a/packages/security/tests/client.test.ts +++ b/packages/security/tests/client.test.ts @@ -1,12 +1,24 @@ import { afterEach, describe, expect, it } from 'vitest' -import { configureSecurityClient, getSecurityClientConfig, resetSecurityClient, securityClientInternals } from '../src/client' +import { getSecurityClientConfig, securityClientInternals } from '../src/client' +import { SECURITY_CLIENT_CONFIG_COOKIE, serializeSecurityClientConfig } from '../src/client-config' + +const browserGlobal = globalThis as typeof globalThis & { + document?: { + cookie?: string + } +} +const originalDocument = browserGlobal.document afterEach(() => { - resetSecurityClient() + if (typeof originalDocument === 'undefined') { + delete browserGlobal.document + } else { + browserGlobal.document = originalDocument + } }) describe('@holo-js/security client config', () => { - it('returns default browser csrf settings when no client override is configured', () => { + it('returns default browser csrf settings when the middleware config cookie is missing', () => { const config = getSecurityClientConfig() expect(config).toEqual({ @@ -19,23 +31,28 @@ describe('@holo-js/security client config', () => { expect(Object.isFrozen(config.csrf)).toBe(true) }) - it('normalizes and resets browser client config overrides', () => { - configureSecurityClient({ - config: { + it('reads csrf settings from the middleware-issued config cookie', () => { + browserGlobal.document = { + cookie: `${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(serializeSecurityClientConfig({ csrf: { field: '_csrf', + cookie: 'csrf-token', }, - }, - }) + }))}`, + } expect(getSecurityClientConfig()).toEqual({ csrf: { field: '_csrf', - cookie: 'XSRF-TOKEN', + cookie: 'csrf-token', }, }) + }) - resetSecurityClient() + it('falls back to defaults when the middleware config cookie is malformed', () => { + browserGlobal.document = { + cookie: `${SECURITY_CLIENT_CONFIG_COOKIE}=not-json`, + } expect(getSecurityClientConfig()).toEqual({ csrf: { @@ -43,14 +60,17 @@ describe('@holo-js/security client config', () => { cookie: 'XSRF-TOKEN', }, }) + }) - configureSecurityClient() - expect(getSecurityClientConfig()).toEqual({ + it('ignores malformed cookie segments and invalid config payloads', () => { + expect(securityClientInternals.parseCookieHeader('broken; %=bad; =missing-name; ok=value')).toEqual({ + ok: 'value', + }) + expect(securityClientInternals.readSecurityClientConfigFromCookies(`${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(JSON.stringify({ csrf: { - field: '_token', - cookie: 'XSRF-TOKEN', + field: '_csrf', }, - }) + }))}`)).toBeUndefined() }) it('exposes the browser client runtime internals for tests', () => { @@ -60,15 +80,14 @@ describe('@holo-js/security client config', () => { cookie: 'XSRF-TOKEN', }, }) - expect(securityClientInternals.normalizeSecurityClientConfig({ - config: { - csrf: { - cookie: 'csrf-token', - }, + expect(securityClientInternals.readSecurityClientConfigFromCookies(`${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(JSON.stringify({ + csrf: { + field: '_csrf', + cookie: 'csrf-token', }, - })).toEqual({ + }))}`)).toEqual({ csrf: { - field: '_token', + field: '_csrf', cookie: 'csrf-token', }, }) diff --git a/packages/security/tests/client.type.test.ts b/packages/security/tests/client.type.test.ts index 331d0d09..63475789 100644 --- a/packages/security/tests/client.type.test.ts +++ b/packages/security/tests/client.type.test.ts @@ -1,8 +1,6 @@ import { describe, it } from 'vitest' import { - configureSecurityClient, getSecurityClientConfig, - type SecurityClientBindings, type SecurityClientConfig, } from '../src/client' @@ -14,34 +12,6 @@ describe('@holo-js/security client typing', () => { ? ((() => TValue extends TRight ? 1 : 2) extends (() => TValue extends TLeft ? 1 : 2) ? true : false) : false - const bindings: SecurityClientBindings = { - config: { - csrf: { - field: '_csrf', - cookie: 'csrf-token', - }, - }, - } - - const fieldOnlyBindings: SecurityClientBindings = { - config: { - csrf: { - field: '_csrf', - }, - }, - } - const cookieOnlyBindings: SecurityClientBindings = { - config: { - csrf: { - cookie: 'csrf-token', - }, - }, - } - - configureSecurityClient(bindings) - configureSecurityClient(fieldOnlyBindings) - configureSecurityClient(cookieOnlyBindings) - const config = getSecurityClientConfig() type ConfigAssertion = Expect> diff --git a/packages/security/tests/docs-smoke.test.ts b/packages/security/tests/docs-smoke.test.ts index a5063c36..3e154d04 100644 --- a/packages/security/tests/docs-smoke.test.ts +++ b/packages/security/tests/docs-smoke.test.ts @@ -21,8 +21,9 @@ describe('security documentation smoke checks', () => { expect(security).toContain('createHmac') expect(security).toContain('APP_KEY') expect(security).not.toContain('createStableEmailHash(') - expect(security).toContain('csrf.field(request)') + expect(security).toContain('csrf.input(request)') expect(security).toContain('csrf.cookie(request)') + expect(security).toContain('csrfProtection()') expect(security).toContain('protect(request, {') expect(security).toContain("throttle: 'api'") expect(security).toContain('419') @@ -46,18 +47,17 @@ describe('security documentation smoke checks', () => { expect(security).toContain('| `memory` | No | No |') expect(security).toContain('| `file` | Yes, on the same machine | No |') expect(security).toContain('| `redis` | Yes | Yes |') - expect(security).toContain("configureSecurityClient") expect(security).toContain('server-only') expect(security).toContain('fully typed') expect(serverValidation).toContain("throttle: 'login'") expect(serverValidation).toContain("throttle: 'register'") - expect(serverValidation).toContain('csrf: true') - expect(clientUsage).toContain('csrf: true') + expect(serverValidation).toContain("throttle: 'login'") + expect(clientUsage).toContain('CSRF') expect(clientUsage).toContain('not a client option') - expect(clientUsage).toContain("configureSecurityClient") + expect(clientUsage).toContain('config/security.ts') expect(frameworkIntegration).toContain("throttle: 'login'") - expect(frameworkIntegration).toContain('csrf: true') + expect(frameworkIntegration).toContain('CSRF') expect(frameworkIntegration).toContain('does not expose `throttle`') }) }) diff --git a/packages/security/tests/framework-middleware.test.ts b/packages/security/tests/framework-middleware.test.ts new file mode 100644 index 00000000..b857874f --- /dev/null +++ b/packages/security/tests/framework-middleware.test.ts @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defineSecurityConfig } from '@holo-js/config' +import { + configureSecurityRuntime, + csrf, + resetSecurityRuntime, +} from '../src' +import { SECURITY_CLIENT_CONFIG_COOKIE } from '../src/client-config' + +function configureSecurity(except: readonly string[] = []): void { + configureSecurityRuntime({ + config: defineSecurityConfig({ + csrf: { + enabled: true, + except, + }, + }), + csrfSigningKey: 'test-signing-key', + }) +} + +afterEach(() => { + vi.doUnmock('h3') + vi.doUnmock('next/server') + vi.resetModules() + resetSecurityRuntime() +}) + +describe('@holo-js/security framework csrf middleware', () => { + it('wires Next csrf cookies and 419 responses without auth', async () => { + vi.doMock('next/server', () => ({ + NextResponse: { + next() { + const response = new Response(null, { + headers: { + 'x-middleware-next': '1', + }, + }) + + return Object.assign(response, { + cookies: { + set(name: string, value: string, options: { readonly path?: string, readonly sameSite?: string, readonly secure?: boolean, readonly httpOnly?: boolean }) { + response.headers.append('set-cookie', [ + `${name}=${encodeURIComponent(value)}`, + options.path ? `Path=${options.path}` : undefined, + options.sameSite ? `SameSite=${options.sameSite[0]?.toUpperCase()}${options.sameSite.slice(1)}` : undefined, + options.secure ? 'Secure' : undefined, + options.httpOnly ? 'HttpOnly' : undefined, + ].filter((attribute): attribute is string => typeof attribute === 'string').join('; ')) + }, + }, + }) + }, + }, + })) + configureSecurity() + + const { csrfProtection } = await import('../src/next/server') + const getRequest = Object.assign(new Request('https://app.test/login'), { + cookies: { + get: vi.fn(() => undefined), + }, + nextUrl: new URL('https://app.test/login'), + }) + const getResponse = await csrfProtection()(getRequest) + const token = decodeURIComponent(getResponse?.headers.get('set-cookie')?.split(';', 1)[0]?.slice('XSRF-TOKEN='.length) ?? '') + + expect(getResponse?.headers.get('x-middleware-next')).toBe('1') + expect(getResponse?.headers.get('set-cookie')).toContain('XSRF-TOKEN=') + expect(getResponse?.headers.get('set-cookie')).toContain(`${SECURITY_CLIENT_CONFIG_COOKIE}=`) + expect(getResponse?.headers.get('set-cookie')).toContain(encodeURIComponent('"cookie":"XSRF-TOKEN"')) + expect(getResponse?.headers.get('set-cookie')).toContain(encodeURIComponent('"field":"_token"')) + expect(getResponse?.headers.get('set-cookie')).toContain('Secure') + + const existingCookieRequest = Object.assign(new Request('https://app.test/login', { + headers: { + cookie: `XSRF-TOKEN=${token}`, + }, + }), { + cookies: { + get: vi.fn(() => token), + }, + }) + const existingCookieResponse = await csrfProtection()(existingCookieRequest) + expect(existingCookieResponse?.headers.get('set-cookie')).toContain(`XSRF-TOKEN=${encodeURIComponent(token)}`) + + const headRequest = Object.assign(new Request('https://app.test/login', { + method: 'HEAD', + headers: { + cookie: `XSRF-TOKEN=${token}`, + }, + }), { + cookies: { + get: vi.fn(() => ({ value: token })), + }, + }) + const headResponse = await csrfProtection()(headRequest) + expect(headResponse?.headers.get('set-cookie')).toContain(`XSRF-TOKEN=${encodeURIComponent(token)}`) + + resetSecurityRuntime() + await expect(csrfProtection()(Object.assign(new Request('https://app.test/login', { + method: 'POST', + }), { + cookies: { + get: vi.fn(() => undefined), + }, + }))).rejects.toThrow(/Security runtime/) + configureSecurity() + + const denied = await csrfProtection()(Object.assign(new Request('https://app.test/login', { + method: 'POST', + body: new URLSearchParams({ + email: 'ava@example.com', + }), + }), { + cookies: { + get: vi.fn(() => undefined), + }, + })) + expect(denied?.status).toBe(419) + + const allowed = await csrfProtection()(Object.assign(new Request('https://app.test/login', { + method: 'POST', + headers: { + cookie: `XSRF-TOKEN=${token}`, + }, + body: new URLSearchParams({ + _token: token, + }), + }), { + cookies: { + get: vi.fn(() => ({ value: token })), + }, + })) + expect(allowed).toBeUndefined() + }) + + it('wires Nuxt csrf cookies, request body verification, and exceptions without auth', async () => { + const writes: Array<{ + readonly name: string + readonly value: string + readonly options: object + }> = [] + const state = { + method: 'GET', + url: new URL('https://app.test/login'), + headers: {} as Record, + cookie: undefined as string | undefined, + body: undefined as Buffer | undefined, + } + vi.doMock('h3', () => ({ + createError(input: { readonly statusCode: number, readonly message?: string }) { + return Object.assign(new Error(input.message), { statusCode: input.statusCode }) + }, + defineEventHandler(handler: TValue) { + return handler + }, + getCookie(_event: unknown, name: string) { + return name === 'XSRF-TOKEN' ? state.cookie : undefined + }, + getMethod() { + return state.method + }, + getRequestHeaders() { + return state.headers + }, + getRequestURL() { + return state.url + }, + async readRawBody() { + return state.body + }, + setCookie(_event: unknown, name: string, value: string, options: object) { + writes.push({ name, value, options }) + }, + })) + configureSecurity(['/webhooks/*']) + + const { csrfProtection } = await import('../src/nuxt/server') + const middleware = csrfProtection() + await middleware({ node: { req: { headers: {} } } }) + const token = writes[0]?.value ?? '' + + expect(writes).toEqual([ + { + name: 'XSRF-TOKEN', + value: token, + options: { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: true, + }, + }, + { + name: SECURITY_CLIENT_CONFIG_COOKIE, + value: JSON.stringify({ + csrf: { + field: '_token', + cookie: 'XSRF-TOKEN', + }, + }), + options: { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: true, + }, + }, + ]) + + state.method = 'POST' + state.headers = { + cookie: `XSRF-TOKEN=${token}`, + 'X-CSRF-TOKEN': token, + 'content-type': 'application/x-www-form-urlencoded', + } + state.cookie = token + state.body = Buffer.from(new URLSearchParams({ _token: token }).toString()) + await expect(middleware({ node: { req: { headers: {} } } })).resolves.toBeUndefined() + + state.method = 'HEAD' + state.headers = { + 'x-array': undefined, + } + await expect(middleware({ node: { req: { headers: {} } } })).resolves.toBeUndefined() + + state.body = Buffer.from('') + state.method = 'POST' + await expect(middleware({ node: { req: { headers: {} } } })).rejects.toMatchObject({ + statusCode: 419, + }) + + state.url = new URL('https://app.test/webhooks/stripe') + await expect(middleware({ node: { req: { headers: {} } } })).resolves.toBeUndefined() + + resetSecurityRuntime() + state.url = new URL('https://app.test/login') + await expect(middleware({ node: { req: { headers: {} } } })).rejects.toThrow(/Security runtime/) + }) +}) diff --git a/packages/security/tests/package.test.ts b/packages/security/tests/package.test.ts index 76e4cc66..e349af7e 100644 --- a/packages/security/tests/package.test.ts +++ b/packages/security/tests/package.test.ts @@ -417,6 +417,11 @@ describe('@holo-js/security csrf', () => { name: '_token', value: signedToken, }) + await expect(csrf.input(request)).resolves.toEqual({ + type: 'hidden', + name: '_token', + value: signedToken, + }) await expect(csrf.cookie(request)).resolves.toBe(`XSRF-TOKEN=${encodeURIComponent(signedToken)}; Path=/; SameSite=Lax; Secure`) const proxiedRequest = new Request('http://app.test/register', { @@ -457,6 +462,11 @@ describe('@holo-js/security csrf', () => { name: '_token', value: token, }) + await expect(csrf.input(request)).resolves.toEqual({ + type: 'hidden', + name: '_token', + value: token, + }) await expect(csrf.cookie(request, token)).resolves.toBe(`XSRF-TOKEN=${encodeURIComponent(token)}; Path=/; SameSite=Lax`) }) @@ -506,6 +516,18 @@ describe('@holo-js/security csrf', () => { }) await expect(csrf.verify(blankHeaderRequest)).resolves.toBeUndefined() + const nativeFormRequest = new Request('https://app.test/login', { + method: 'POST', + headers: { + cookie: `XSRF-TOKEN=${formToken}`, + origin: 'https://app.test', + }, + body: new URLSearchParams({ + email: 'ava@example.com', + }), + }) + await expect(csrf.verify(nativeFormRequest)).resolves.toBeUndefined() + const fileTokenFormData = new FormData() fileTokenFormData.set('_token', new Blob(['not-a-string'])) await expect(csrf.verify(new Request('https://app.test/login', { @@ -538,6 +560,37 @@ describe('@holo-js/security csrf', () => { name: 'SecurityCsrfError', }) + const signedToken = securityExports.csrfInternals.encodeCsrfToken('same-origin-token') + await expect(csrf.verify(new Request('https://app.test/login', { + method: 'POST', + headers: { + cookie: `XSRF-TOKEN=${signedToken}`, + 'X-CSRF-TOKEN': securityExports.csrfInternals.encodeCsrfToken('wrong-token'), + }, + }))).rejects.toBeInstanceOf(SecurityCsrfError) + + await expect(csrf.verify(new Request('https://app.test/login', { + method: 'POST', + headers: { + cookie: `XSRF-TOKEN=${signedToken}`, + origin: 'https://evil.test', + }, + body: new URLSearchParams({ + email: 'ava@example.com', + }), + }))).rejects.toBeInstanceOf(SecurityCsrfError) + + await expect(csrf.verify(new Request('https://app.test/login', { + method: 'POST', + headers: { + cookie: `XSRF-TOKEN=${signedToken}`, + referer: 'not a url', + }, + body: new URLSearchParams({ + email: 'ava@example.com', + }), + }))).rejects.toBeInstanceOf(SecurityCsrfError) + await expect(csrf.verify(new Request('https://app.test/login', { method: 'POST', }))).rejects.toBeInstanceOf(SecurityCsrfError) diff --git a/packages/security/tests/sveltekit.test.ts b/packages/security/tests/sveltekit.test.ts new file mode 100644 index 00000000..ea9c578c --- /dev/null +++ b/packages/security/tests/sveltekit.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defineSecurityConfig } from '@holo-js/config' +import { + configureSecurityRuntime, + csrf, + resetSecurityRuntime, +} from '../src' +import { SECURITY_CLIENT_CONFIG_COOKIE } from '../src/client-config' +import { csrfProtection } from '../src/sveltekit/server' + +type CookieWrite = { + readonly name: string + readonly value: string + readonly options: { + readonly path: string + readonly secure?: boolean + readonly httpOnly?: boolean + readonly sameSite?: 'lax' | 'strict' | 'none' + } +} + +function configureSecurity(except: readonly string[] = []): void { + configureSecurityRuntime({ + config: defineSecurityConfig({ + csrf: { + enabled: true, + except, + }, + }), + csrfSigningKey: 'test-signing-key', + }) +} + +function createEvent(request: Request, cookieValue?: string): { + readonly event: { + readonly url: URL + readonly request: Request + readonly cookies: { + get(name: string): string | undefined + set(name: string, value: string, options: CookieWrite['options']): void + } + } + readonly writes: CookieWrite[] +} { + const writes: CookieWrite[] = [] + + return { + event: { + url: new URL(request.url), + request, + cookies: { + get(name: string) { + return name === 'XSRF-TOKEN' ? cookieValue : undefined + }, + set(name, value, options) { + writes.push({ name, value, options }) + }, + }, + }, + writes, + } +} + +afterEach(() => { + resetSecurityRuntime() +}) + +describe('@holo-js/security SvelteKit csrf middleware', () => { + it('issues the readable csrf cookie on safe requests before page loads render csrf.input()', async () => { + configureSecurity() + const request = new Request('https://app.test/login') + const { event, writes } = createEvent(request) + const resolve = vi.fn(() => new Response('ok')) + + const response = await csrfProtection()({ event, resolve }) + const input = await csrf.input(request) + + expect(response.status).toBe(200) + expect(resolve).toHaveBeenCalledOnce() + expect(writes).toEqual([ + { + name: 'XSRF-TOKEN', + value: input.value, + options: { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: true, + }, + }, + { + name: SECURITY_CLIENT_CONFIG_COOKIE, + value: JSON.stringify({ + csrf: { + field: '_token', + cookie: 'XSRF-TOKEN', + }, + }), + options: { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: true, + }, + }, + ]) + expect(input).toEqual({ + type: 'hidden', + name: '_token', + value: writes[0]?.value, + }) + }) + + it('issues the readable csrf cookie for HEAD requests', async () => { + configureSecurity() + const { event, writes } = createEvent(new Request('https://app.test/login', { + method: 'HEAD', + })) + const resolve = vi.fn(() => new Response(null)) + + const response = await csrfProtection()({ event, resolve }) + + expect(response.status).toBe(200) + expect(resolve).toHaveBeenCalledOnce() + expect(writes).toHaveLength(2) + }) + + it('refreshes invalid csrf cookies on safe requests', async () => { + configureSecurity() + const { event, writes } = createEvent(new Request('https://app.test/login'), 'existing-token') + const resolve = vi.fn(() => new Response('ok')) + + const response = await csrfProtection()({ event, resolve }) + + expect(response.status).toBe(200) + expect(resolve).toHaveBeenCalledOnce() + expect(writes).toHaveLength(2) + expect(writes[0]?.name).toBe('XSRF-TOKEN') + expect(writes[0]?.value).not.toBe('existing-token') + }) + + it('rejects unsafe requests with a 419 response before the route action runs', async () => { + configureSecurity() + const { event } = createEvent(new Request('https://app.test/login', { + method: 'POST', + body: new URLSearchParams({ + email: 'editor@example.com', + }), + })) + const resolve = vi.fn(() => new Response('should not run')) + + const response = await csrfProtection()({ event, resolve }) + + expect(response.status).toBe(419) + await expect(response.text()).resolves.toBe('CSRF token mismatch.') + expect(resolve).not.toHaveBeenCalled() + }) + + it('propagates non-csrf failures', async () => { + const { event } = createEvent(new Request('https://app.test/login', { + method: 'POST', + })) + const resolve = vi.fn(() => new Response('should not run')) + + await expect(csrfProtection()({ event, resolve })).rejects.toThrow(/Security runtime/) + expect(resolve).not.toHaveBeenCalled() + }) + + it('skips configured csrf exceptions', async () => { + configureSecurity(['/webhooks/*']) + const { event } = createEvent(new Request('https://app.test/webhooks/stripe', { + method: 'POST', + })) + const resolve = vi.fn(() => new Response('ok')) + + const response = await csrfProtection()({ event, resolve }) + + expect(response.status).toBe(200) + expect(resolve).toHaveBeenCalledOnce() + }) + + it('does nothing when csrf is disabled', async () => { + configureSecurityRuntime({ + config: defineSecurityConfig({ + csrf: { + enabled: false, + }, + }), + csrfSigningKey: 'test-signing-key', + }) + const { event, writes } = createEvent(new Request('https://app.test/login')) + const resolve = vi.fn(() => new Response('ok')) + + const response = await csrfProtection()({ event, resolve }) + + expect(response.status).toBe(200) + expect(resolve).toHaveBeenCalledOnce() + expect(writes).toEqual([]) + }) +}) diff --git a/packages/security/tsup.config.ts b/packages/security/tsup.config.ts index da50e285..b4783fb2 100644 --- a/packages/security/tsup.config.ts +++ b/packages/security/tsup.config.ts @@ -7,6 +7,9 @@ export default defineConfig({ index: 'src/index.ts', client: 'src/client.ts', contracts: 'src/contracts.ts', + 'next/server': 'src/next/server.ts', + 'nuxt/server': 'src/nuxt/server.ts', + 'sveltekit/server': 'src/sveltekit/server.ts', 'drivers/redis-adapter': 'src/drivers/redis-adapter.ts', }, format: ['esm'], @@ -14,6 +17,7 @@ export default defineConfig({ clean: true, outDir, outExtension: () => ({ js: '.mjs' }), + external: ['h3', 'next/server'], esbuildOptions(options) { options.logLevel = 'warning' }, From 3a9a0d6a60bf8b5053dc7ee6ac40b15384461d95 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Tue, 26 May 2026 12:22:05 +0300 Subject: [PATCH 2/3] Avoid redundant CSRF cookie writes --- packages/security/src/next/server.ts | 44 +++++-- packages/security/src/nuxt/server.ts | 48 ++++++-- .../tests/framework-middleware.test.ts | 112 ++++++++++++++++-- 3 files changed, 176 insertions(+), 28 deletions(-) diff --git a/packages/security/src/next/server.ts b/packages/security/src/next/server.ts index ebffd016..b44a74f9 100644 --- a/packages/security/src/next/server.ts +++ b/packages/security/src/next/server.ts @@ -1,4 +1,4 @@ -import { csrf, isSecureRequest, protect } from '../index' +import { csrf, csrfInternals, isSecureRequest, protect } from '../index' import { SecurityCsrfError } from '../contracts' import { SECURITY_CLIENT_CONFIG_COOKIE, @@ -9,6 +9,9 @@ import { getSecurityRuntime } from '../runtime' type NextCsrfRequest = Request & { readonly nextUrl?: URL + readonly cookies?: { + get(name: string): string | { readonly value?: string } | undefined + } } type NextResponseCookieOptions = { @@ -48,6 +51,15 @@ function createCsrfErrorResponse(error: SecurityCsrfError): Response { }) } +function getRequestCookie(request: NextCsrfRequest, name: string): string | undefined { + const cookie = request.cookies?.get(name) + if (typeof cookie === 'string') { + return cookie + } + + return typeof cookie?.value === 'string' ? cookie.value : undefined +} + async function issueCsrfCookie(request: NextCsrfRequest): Promise { const { config } = getSecurityRuntime() @@ -55,20 +67,32 @@ async function issueCsrfCookie(request: NextCsrfRequest): Promise { const method = getMethod(event) const headers = createHeaders(event) + const body = isSafeMethod(method) + ? undefined + : await readRawBody(event, false) return new Request(getRequestURL(event), { method, headers, + body: createRequestBody(body), }) } @@ -49,18 +65,30 @@ async function issueCsrfCookie(event: H3Event, request: Request): Promise return } - setCookie(event, config.csrf.cookie, await csrf.token(request), { - httpOnly: false, - path: '/', - sameSite: 'lax', - secure: isSecureRequest(request), - }) - setCookie(event, SECURITY_CLIENT_CONFIG_COOKIE, serializeSecurityClientConfig(createSecurityClientConfig(config)), { + const existingCsrfToken = getCookie(event, config.csrf.cookie) + const shouldIssueCsrfToken = !existingCsrfToken + || !csrfInternals.isValidSignedCsrfToken(existingCsrfToken) + const clientConfig = serializeSecurityClientConfig(createSecurityClientConfig(config)) + const shouldIssueClientConfig = getCookie(event, SECURITY_CLIENT_CONFIG_COOKIE) !== clientConfig + + if (!shouldIssueCsrfToken && !shouldIssueClientConfig) { + return + } + + const cookieOptions = { httpOnly: false, path: '/', - sameSite: 'lax', + sameSite: 'lax' as const, secure: isSecureRequest(request), - }) + } + + if (shouldIssueCsrfToken) { + setCookie(event, config.csrf.cookie, await csrf.token(request), cookieOptions) + } + + if (shouldIssueClientConfig) { + setCookie(event, SECURITY_CLIENT_CONFIG_COOKIE, clientConfig, cookieOptions) + } } export function csrfProtection(): ReturnType { diff --git a/packages/security/tests/framework-middleware.test.ts b/packages/security/tests/framework-middleware.test.ts index b857874f..4102a5fd 100644 --- a/packages/security/tests/framework-middleware.test.ts +++ b/packages/security/tests/framework-middleware.test.ts @@ -3,9 +3,15 @@ import { defineSecurityConfig } from '@holo-js/config' import { configureSecurityRuntime, csrf, + csrfInternals, resetSecurityRuntime, } from '../src' -import { SECURITY_CLIENT_CONFIG_COOKIE } from '../src/client-config' +import { + SECURITY_CLIENT_CONFIG_COOKIE, + createSecurityClientConfig, + serializeSecurityClientConfig, +} from '../src/client-config' +import { getSecurityRuntime } from '../src/runtime' function configureSecurity(except: readonly string[] = []): void { configureSecurityRuntime({ @@ -56,6 +62,7 @@ describe('@holo-js/security framework csrf middleware', () => { configureSecurity() const { csrfProtection } = await import('../src/next/server') + const clientConfig = serializeSecurityClientConfig(createSecurityClientConfig(getSecurityRuntime().config)) const getRequest = Object.assign(new Request('https://app.test/login'), { cookies: { get: vi.fn(() => undefined), @@ -74,28 +81,70 @@ describe('@holo-js/security framework csrf middleware', () => { const existingCookieRequest = Object.assign(new Request('https://app.test/login', { headers: { - cookie: `XSRF-TOKEN=${token}`, + cookie: `XSRF-TOKEN=${token}; ${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(clientConfig)}`, }, }), { cookies: { - get: vi.fn(() => token), + get: vi.fn((name: string) => { + if (name === 'XSRF-TOKEN') return { value: token } + if (name === SECURITY_CLIENT_CONFIG_COOKIE) return { value: clientConfig } + return undefined + }), }, }) const existingCookieResponse = await csrfProtection()(existingCookieRequest) - expect(existingCookieResponse?.headers.get('set-cookie')).toContain(`XSRF-TOKEN=${encodeURIComponent(token)}`) + expect(existingCookieResponse).toBeUndefined() + + const staleConfigRequest = Object.assign(new Request('https://app.test/login', { + headers: { + cookie: `XSRF-TOKEN=${token}; ${SECURITY_CLIENT_CONFIG_COOKIE}=stale`, + }, + }), { + cookies: { + get: vi.fn((name: string) => { + if (name === 'XSRF-TOKEN') return token + if (name === SECURITY_CLIENT_CONFIG_COOKIE) return { value: 'stale' } + return undefined + }), + }, + }) + const staleConfigResponse = await csrfProtection()(staleConfigRequest) + expect(staleConfigResponse?.headers.get('set-cookie')).not.toContain(`XSRF-TOKEN=${encodeURIComponent(token)}`) + expect(staleConfigResponse?.headers.get('set-cookie')).toContain(`${SECURITY_CLIENT_CONFIG_COOKIE}=`) + + const invalidCsrfRequest = Object.assign(new Request('https://app.test/login', { + headers: { + cookie: `XSRF-TOKEN=forged-token; ${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(clientConfig)}`, + }, + }), { + cookies: { + get: vi.fn((name: string) => { + if (name === 'XSRF-TOKEN') return { value: 'forged-token' } + if (name === SECURITY_CLIENT_CONFIG_COOKIE) return { value: clientConfig } + return undefined + }), + }, + }) + const invalidCsrfResponse = await csrfProtection()(invalidCsrfRequest) + expect(invalidCsrfResponse?.headers.get('set-cookie')).toContain('XSRF-TOKEN=') + expect(invalidCsrfResponse?.headers.get('set-cookie')).not.toContain(`${SECURITY_CLIENT_CONFIG_COOKIE}=`) const headRequest = Object.assign(new Request('https://app.test/login', { method: 'HEAD', headers: { - cookie: `XSRF-TOKEN=${token}`, + cookie: `XSRF-TOKEN=${token}; ${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(clientConfig)}`, }, }), { cookies: { - get: vi.fn(() => ({ value: token })), + get: vi.fn((name: string) => { + if (name === 'XSRF-TOKEN') return { value: token } + if (name === SECURITY_CLIENT_CONFIG_COOKIE) return { value: clientConfig } + return undefined + }), }, }) const headResponse = await csrfProtection()(headRequest) - expect(headResponse?.headers.get('set-cookie')).toContain(`XSRF-TOKEN=${encodeURIComponent(token)}`) + expect(headResponse).toBeUndefined() resetSecurityRuntime() await expect(csrfProtection()(Object.assign(new Request('https://app.test/login', { @@ -146,6 +195,7 @@ describe('@holo-js/security framework csrf middleware', () => { url: new URL('https://app.test/login'), headers: {} as Record, cookie: undefined as string | undefined, + clientConfigCookie: undefined as string | undefined, body: undefined as Buffer | undefined, } vi.doMock('h3', () => ({ @@ -156,7 +206,9 @@ describe('@holo-js/security framework csrf middleware', () => { return handler }, getCookie(_event: unknown, name: string) { - return name === 'XSRF-TOKEN' ? state.cookie : undefined + if (name === 'XSRF-TOKEN') return state.cookie + if (name === SECURITY_CLIENT_CONFIG_COOKIE) return state.clientConfigCookie + return undefined }, getMethod() { return state.method @@ -180,6 +232,7 @@ describe('@holo-js/security framework csrf middleware', () => { const middleware = csrfProtection() await middleware({ node: { req: { headers: {} } } }) const token = writes[0]?.value ?? '' + const clientConfig = serializeSecurityClientConfig(createSecurityClientConfig(getSecurityRuntime().config)) expect(writes).toEqual([ { @@ -209,6 +262,49 @@ describe('@holo-js/security framework csrf middleware', () => { }, ]) + writes.length = 0 + state.cookie = token + state.clientConfigCookie = clientConfig + state.headers = { + cookie: `XSRF-TOKEN=${token}; ${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(clientConfig)}`, + } + await middleware({ node: { req: { headers: {} } } }) + expect(writes).toEqual([]) + + state.clientConfigCookie = 'stale' + await middleware({ node: { req: { headers: {} } } }) + expect(writes).toEqual([ + { + name: SECURITY_CLIENT_CONFIG_COOKIE, + value: clientConfig, + options: { + httpOnly: false, + path: '/', + sameSite: 'lax', + secure: true, + }, + }, + ]) + writes.length = 0 + + state.cookie = csrfInternals.encodeCsrfToken('old-token') + state.clientConfigCookie = clientConfig + state.headers = { + cookie: `XSRF-TOKEN=${state.cookie}; ${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(clientConfig)}`, + } + await middleware({ node: { req: { headers: {} } } }) + expect(writes).toEqual([]) + writes.length = 0 + + state.cookie = 'forged-token' + state.headers = { + cookie: `XSRF-TOKEN=forged-token; ${SECURITY_CLIENT_CONFIG_COOKIE}=${encodeURIComponent(clientConfig)}`, + } + await middleware({ node: { req: { headers: {} } } }) + expect(writes).toHaveLength(1) + expect(writes[0]?.name).toBe('XSRF-TOKEN') + writes.length = 0 + state.method = 'POST' state.headers = { cookie: `XSRF-TOKEN=${token}`, From da54d8880a7ec8646139c0ac7a82147bf9904a13 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Tue, 26 May 2026 14:28:55 +0300 Subject: [PATCH 3/3] Tighten CSRF cookie middleware assertion --- packages/security/tests/framework-middleware.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/security/tests/framework-middleware.test.ts b/packages/security/tests/framework-middleware.test.ts index 4102a5fd..fb6d62a5 100644 --- a/packages/security/tests/framework-middleware.test.ts +++ b/packages/security/tests/framework-middleware.test.ts @@ -109,8 +109,9 @@ describe('@holo-js/security framework csrf middleware', () => { }, }) const staleConfigResponse = await csrfProtection()(staleConfigRequest) - expect(staleConfigResponse?.headers.get('set-cookie')).not.toContain(`XSRF-TOKEN=${encodeURIComponent(token)}`) - expect(staleConfigResponse?.headers.get('set-cookie')).toContain(`${SECURITY_CLIENT_CONFIG_COOKIE}=`) + const staleConfigSetCookie = staleConfigResponse?.headers.get('set-cookie') + expect(staleConfigSetCookie).not.toContain('XSRF-TOKEN=') + expect(staleConfigSetCookie).toContain(`${SECURITY_CLIENT_CONFIG_COOKIE}=`) const invalidCsrfRequest = Object.assign(new Request('https://app.test/login', { headers: {