From 215c8fdd46f5e1c27e26e611f8acc2f06bb8c853 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Mar 2026 14:32:48 +0300 Subject: [PATCH] fix: enforce API token scope on requests - Add apiTokenScope to App.Locals type definition - Store token scope in event.locals during API token auth - Block write operations (POST/PATCH/PUT/DELETE) for read-scoped tokens - Block admin paths for non-admin-scoped tokens - Returns 403 with descriptive error message --- src/app.d.ts | 2 ++ src/hooks.server.ts | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/app.d.ts b/src/app.d.ts index a3a50f7..46dcd8e 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -18,6 +18,8 @@ declare global { id: string; expiresAt: Date; } | null; + /** API token scope — set when auth is via Bearer token, null for JWT sessions */ + apiTokenScope: 'read' | 'write' | 'admin' | null; } interface PageData { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index ee31b47..9155239 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -27,6 +27,7 @@ export const handle: Handle = async ({ event, resolve }) => { // Initialize locals event.locals.user = null; event.locals.session = null; + event.locals.apiTokenScope = null; const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE); const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE); @@ -111,6 +112,7 @@ export const handle: Handle = async ({ event, resolve }) => { id: user.id, expiresAt: new Date(Date.now() + 15 * 60 * 1000) }; + event.locals.apiTokenScope = tokenResult.scope as 'read' | 'write' | 'admin'; } } catch { // API token validation failed — continue as unauthenticated @@ -118,9 +120,31 @@ export const handle: Handle = async ({ event, resolve }) => { } } - // Route protection const { pathname } = event.url; + // API token scope enforcement + if (event.locals.apiTokenScope) { + const method = event.request.method; + const scope = event.locals.apiTokenScope; + const isWriteMethod = method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE'; + const isAdminPath = pathname.startsWith('/api/admin') || pathname.startsWith('/admin'); + + if (scope === 'read' && isWriteMethod) { + return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope "read" does not allow write operations' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + if (scope !== 'admin' && isAdminPath) { + return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope does not allow admin operations' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + // Route protection + if (!event.locals.user && !isPublicPath(pathname)) { // Check if this is a guest-accessible board route const boardMatch = pathname.match(/^\/boards\/([^/]+)/);