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
This commit is contained in:
2026-03-25 14:32:48 +03:00
parent 014de026eb
commit 215c8fdd46
2 changed files with 27 additions and 1 deletions
+2
View File
@@ -18,6 +18,8 @@ declare global {
id: string; id: string;
expiresAt: Date; expiresAt: Date;
} | null; } | null;
/** API token scope — set when auth is via Bearer token, null for JWT sessions */
apiTokenScope: 'read' | 'write' | 'admin' | null;
} }
interface PageData { interface PageData {
+25 -1
View File
@@ -27,6 +27,7 @@ export const handle: Handle = async ({ event, resolve }) => {
// Initialize locals // Initialize locals
event.locals.user = null; event.locals.user = null;
event.locals.session = null; event.locals.session = null;
event.locals.apiTokenScope = null;
const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE); const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE);
const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE); const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE);
@@ -111,6 +112,7 @@ export const handle: Handle = async ({ event, resolve }) => {
id: user.id, id: user.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000) expiresAt: new Date(Date.now() + 15 * 60 * 1000)
}; };
event.locals.apiTokenScope = tokenResult.scope as 'read' | 'write' | 'admin';
} }
} catch { } catch {
// API token validation failed — continue as unauthenticated // API token validation failed — continue as unauthenticated
@@ -118,9 +120,31 @@ export const handle: Handle = async ({ event, resolve }) => {
} }
} }
// Route protection
const { pathname } = event.url; 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)) { if (!event.locals.user && !isPublicPath(pathname)) {
// Check if this is a guest-accessible board route // Check if this is a guest-accessible board route
const boardMatch = pathname.match(/^\/boards\/([^/]+)/); const boardMatch = pathname.match(/^\/boards\/([^/]+)/);