From 4ab4deb8d2d592af7248488062c1a1303fc72ee8 Mon Sep 17 00:00:00 2001 From: Sascha Date: Sun, 19 Apr 2026 13:50:35 +0200 Subject: [PATCH 1/2] fix: fixed auth for http --- sake/src/hooks.server.ts | 2 +- sake/src/lib/server/auth/cookies.ts | 91 ++++++++++++++-- sake/src/lib/server/auth/responseSignals.ts | 16 ++- sake/src/routes/api/auth/bootstrap/+server.ts | 7 +- sake/src/routes/api/auth/login/+server.ts | 7 +- .../src/routes/api/auth/logout-all/+server.ts | 7 +- sake/src/routes/api/auth/logout/+server.ts | 7 +- sake/src/routes/api/search/+server.ts | 5 +- .../routes/api/zlibrary/download/+server.ts | 5 +- sake/src/routes/api/zlibrary/queue/+server.ts | 5 +- .../src/routes/api/zlibrary/search/+server.ts | 5 +- sake/tests/auth/cookies.test.ts | 100 ++++++++++++++++++ 12 files changed, 226 insertions(+), 31 deletions(-) create mode 100644 sake/tests/auth/cookies.test.ts diff --git a/sake/src/hooks.server.ts b/sake/src/hooks.server.ts index 772b12d..8900cab 100644 --- a/sake/src/hooks.server.ts +++ b/sake/src/hooks.server.ts @@ -266,7 +266,7 @@ const authHandle: Handle = async ({ event, resolve }) => { const hasStaleSessionCookie = Boolean(sessionToken) && !apiKey && !event.locals.auth; if (hasStaleSessionCookie) { - clearSakeSessionCookie(cookies, url); + clearSakeSessionCookie(cookies, event); event.locals.logger?.info( { event: 'auth.session_cookie.cleared', pathname, method }, 'Cleared stale session cookie' diff --git a/sake/src/lib/server/auth/cookies.ts b/sake/src/lib/server/auth/cookies.ts index 2a37cad..4a00afe 100644 --- a/sake/src/lib/server/auth/cookies.ts +++ b/sake/src/lib/server/auth/cookies.ts @@ -1,13 +1,88 @@ import type { Cookies } from '@sveltejs/kit'; import { SAKE_SESSION_COOKIE_NAME } from '$lib/server/auth/constants'; -function isSecureRequest(url: URL): boolean { - return url.protocol === 'https:'; +interface CookieSecurityContext { + request: Request; + url: URL; + platform?: { + req?: { + socket?: { + encrypted?: boolean; + }; + }; + }; +} + +function getFirstHeaderToken(value: string | null): string | null { + if (!value) { + return null; + } + + const [first] = value.split(','); + const normalized = first?.trim().toLowerCase(); + return normalized ? normalized : null; +} + +function getForwardedProto(request: Request): 'http' | 'https' | null { + const forwarded = request.headers.get('forwarded'); + if (forwarded) { + const match = forwarded.match(/(?:^|[;,]\s*)proto="?([^;,\s"]+)"?/i); + const proto = match?.[1]?.trim().toLowerCase(); + if (proto === 'http' || proto === 'https') { + return proto; + } + } + + const xForwardedProto = getFirstHeaderToken(request.headers.get('x-forwarded-proto')); + if (xForwardedProto === 'http' || xForwardedProto === 'https') { + return xForwardedProto; + } + + const xForwardedSsl = getFirstHeaderToken(request.headers.get('x-forwarded-ssl')); + if (xForwardedSsl === 'on') { + return 'https'; + } + if (xForwardedSsl === 'off') { + return 'http'; + } + + const frontEndHttps = getFirstHeaderToken(request.headers.get('front-end-https')); + if (frontEndHttps === 'on') { + return 'https'; + } + if (frontEndHttps === 'off') { + return 'http'; + } + + return null; +} + +function getSocketEncryptionState(context: CookieSecurityContext): boolean | null { + const socket = context.platform?.req?.socket; + if (!socket) { + return null; + } + + return socket.encrypted === true; +} + +export function isSecureRequest(context: CookieSecurityContext): boolean { + const forwardedProto = getForwardedProto(context.request); + if (forwardedProto) { + return forwardedProto === 'https'; + } + + const socketEncrypted = getSocketEncryptionState(context); + if (socketEncrypted !== null) { + return socketEncrypted; + } + + return context.url.protocol === 'https:'; } export function setSakeSessionCookie( cookies: Cookies, - url: URL, + context: CookieSecurityContext, token: string, expiresAt: string ): void { @@ -15,26 +90,26 @@ export function setSakeSessionCookie( path: '/', httpOnly: true, sameSite: 'lax', - secure: isSecureRequest(url), + secure: isSecureRequest(context), expires: new Date(expiresAt) }); } -export function clearSakeSessionCookie(cookies: Cookies, url: URL): void { +export function clearSakeSessionCookie(cookies: Cookies, context: CookieSecurityContext): void { cookies.delete(SAKE_SESSION_COOKIE_NAME, { path: '/', httpOnly: true, sameSite: 'lax', - secure: isSecureRequest(url) + secure: isSecureRequest(context) }); } -export function clearZlibraryCookies(cookies: Cookies, url: URL): void { +export function clearZlibraryCookies(cookies: Cookies, context: CookieSecurityContext): void { const cookieOptions = { path: '/', httpOnly: true, sameSite: 'lax' as const, - secure: isSecureRequest(url) + secure: isSecureRequest(context) }; cookies.delete('userId', cookieOptions); diff --git a/sake/src/lib/server/auth/responseSignals.ts b/sake/src/lib/server/auth/responseSignals.ts index 3de461b..5d671e2 100644 --- a/sake/src/lib/server/auth/responseSignals.ts +++ b/sake/src/lib/server/auth/responseSignals.ts @@ -6,11 +6,23 @@ import { clearZlibraryCookies } from '$lib/server/auth/cookies'; import { errorResponse, withResponseHeader } from '$lib/server/http/api'; import type { Cookies } from '@sveltejs/kit'; +interface CookieSecurityContext { + request: Request; + url: URL; + platform?: { + req?: { + socket?: { + encrypted?: boolean; + }; + }; + }; +} + export function zlibraryAuthFailureResponse( message: string, status: number, cookies: Cookies, - url: URL + context: CookieSecurityContext ): Response { const response = errorResponse(message, status); @@ -18,6 +30,6 @@ export function zlibraryAuthFailureResponse( return response; } - clearZlibraryCookies(cookies, url); + clearZlibraryCookies(cookies, context); return withResponseHeader(response, SAKE_CLEAR_ZLIBRARY_AUTH_HEADER_NAME, 'true'); } diff --git a/sake/src/routes/api/auth/bootstrap/+server.ts b/sake/src/routes/api/auth/bootstrap/+server.ts index 1a37b90..c46c64e 100644 --- a/sake/src/routes/api/auth/bootstrap/+server.ts +++ b/sake/src/routes/api/auth/bootstrap/+server.ts @@ -8,7 +8,8 @@ import { toLogError } from '$lib/server/infrastructure/logging/logger'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ request, locals, cookies, url }) => { +export const POST: RequestHandler = async (event) => { + const { request, locals, cookies } = event; const requestLogger = getRequestLogger(locals); const ipAddress = getRequestIp(request); let body: { @@ -63,8 +64,8 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) => return errorResponse(result.error.message, result.error.status); } - setSakeSessionCookie(cookies, url, result.value.sessionToken, result.value.sessionExpiresAt); - clearZlibraryCookies(cookies, url); + setSakeSessionCookie(cookies, event, result.value.sessionToken, result.value.sessionExpiresAt); + clearZlibraryCookies(cookies, event); return json({ success: true, diff --git a/sake/src/routes/api/auth/login/+server.ts b/sake/src/routes/api/auth/login/+server.ts index d4bb417..2cc52d8 100644 --- a/sake/src/routes/api/auth/login/+server.ts +++ b/sake/src/routes/api/auth/login/+server.ts @@ -8,7 +8,8 @@ import { toLogError } from '$lib/server/infrastructure/logging/logger'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ request, locals, cookies, url }) => { +export const POST: RequestHandler = async (event) => { + const { request, locals, cookies } = event; const requestLogger = getRequestLogger(locals); const ipAddress = getRequestIp(request); let body: { username?: unknown; password?: unknown }; @@ -66,8 +67,8 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) => return errorResponse(result.error.message, result.error.status); } - setSakeSessionCookie(cookies, url, result.value.sessionToken, result.value.sessionExpiresAt); - clearZlibraryCookies(cookies, url); + setSakeSessionCookie(cookies, event, result.value.sessionToken, result.value.sessionExpiresAt); + clearZlibraryCookies(cookies, event); return json({ success: true, diff --git a/sake/src/routes/api/auth/logout-all/+server.ts b/sake/src/routes/api/auth/logout-all/+server.ts index bb3e96e..31dbad1 100644 --- a/sake/src/routes/api/auth/logout-all/+server.ts +++ b/sake/src/routes/api/auth/logout-all/+server.ts @@ -6,7 +6,8 @@ import { toLogError } from '$lib/server/infrastructure/logging/logger'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ locals, cookies, url }) => { +export const POST: RequestHandler = async (event) => { + const { locals, cookies } = event; const requestLogger = getRequestLogger(locals); if (locals.auth?.type !== 'session') { @@ -30,8 +31,8 @@ export const POST: RequestHandler = async ({ locals, cookies, url }) => { return errorResponse(result.error.message, result.error.status); } - clearSakeSessionCookie(cookies, url); - clearZlibraryCookies(cookies, url); + clearSakeSessionCookie(cookies, event); + clearZlibraryCookies(cookies, event); return json({ success: true }); } catch (err: unknown) { diff --git a/sake/src/routes/api/auth/logout/+server.ts b/sake/src/routes/api/auth/logout/+server.ts index 7d8f7de..bf5ffd6 100644 --- a/sake/src/routes/api/auth/logout/+server.ts +++ b/sake/src/routes/api/auth/logout/+server.ts @@ -7,7 +7,8 @@ import { toLogError } from '$lib/server/infrastructure/logging/logger'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ locals, cookies, url }) => { +export const POST: RequestHandler = async (event) => { + const { locals, cookies } = event; const requestLogger = getRequestLogger(locals); const sessionToken = cookies.get(SAKE_SESSION_COOKIE_NAME); @@ -28,8 +29,8 @@ export const POST: RequestHandler = async ({ locals, cookies, url }) => { return errorResponse(result.error.message, result.error.status); } - clearSakeSessionCookie(cookies, url); - clearZlibraryCookies(cookies, url); + clearSakeSessionCookie(cookies, event); + clearZlibraryCookies(cookies, event); return json({ success: true }); } catch (err: unknown) { diff --git a/sake/src/routes/api/search/+server.ts b/sake/src/routes/api/search/+server.ts index d99670e..3b0152f 100644 --- a/sake/src/routes/api/search/+server.ts +++ b/sake/src/routes/api/search/+server.ts @@ -9,7 +9,8 @@ import type { SearchBooksRequest } from '$lib/types/Search/SearchBooksRequest'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; -export const POST: RequestHandler = async ({ request, locals, cookies, url }) => { +export const POST: RequestHandler = async (event) => { + const { request, locals, cookies } = event; const requestLogger = getRequestLogger(locals); let parsedRequest: SearchBooksRequest; @@ -43,7 +44,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) => 'Search rejected' ); if (locals.zuser && isAuthenticationFailureStatus(result.error.status)) { - return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, url); + return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, event); } return errorResponse(result.error.message, result.error.status); } diff --git a/sake/src/routes/api/zlibrary/download/+server.ts b/sake/src/routes/api/zlibrary/download/+server.ts index 82313c1..88186b8 100644 --- a/sake/src/routes/api/zlibrary/download/+server.ts +++ b/sake/src/routes/api/zlibrary/download/+server.ts @@ -8,7 +8,8 @@ import type { ZDownloadBookRequest } from '$lib/types/ZLibrary/Requests/ZDownloa import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; -export const POST: RequestHandler = async ({ request, locals, cookies, url }) => { +export const POST: RequestHandler = async (event) => { + const { request, locals, cookies } = event; const requestLogger = getRequestLogger(locals); let body: ZDownloadBookRequest; try { @@ -51,7 +52,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) => }, 'Download rejected' ); - return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, url); + return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, event); } if (body.downloadToDevice === false) { diff --git a/sake/src/routes/api/zlibrary/queue/+server.ts b/sake/src/routes/api/zlibrary/queue/+server.ts index 676d97f..1cc1a7a 100644 --- a/sake/src/routes/api/zlibrary/queue/+server.ts +++ b/sake/src/routes/api/zlibrary/queue/+server.ts @@ -11,7 +11,8 @@ import { json } from '@sveltejs/kit'; /** * Queue a book for download to library (async, returns immediately) */ -export const POST: RequestHandler = async ({ request, locals, cookies, url }) => { +export const POST: RequestHandler = async (event) => { + const { request, locals, cookies } = event; const requestLogger = getRequestLogger(locals); let body: ZDownloadBookRequest; try { @@ -54,7 +55,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) => }, 'Queue request rejected' ); - return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, url); + return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, event); } return json(result.value); diff --git a/sake/src/routes/api/zlibrary/search/+server.ts b/sake/src/routes/api/zlibrary/search/+server.ts index ec2df43..88e5b3d 100644 --- a/sake/src/routes/api/zlibrary/search/+server.ts +++ b/sake/src/routes/api/zlibrary/search/+server.ts @@ -10,7 +10,8 @@ import { json } from '@sveltejs/kit'; // ------------------------------- // POST /api/zlibrary/search // ------------------------------- -export const POST: RequestHandler = async ({ request, locals, cookies, url }) => { +export const POST: RequestHandler = async (event) => { + const { request, locals, cookies } = event; const requestLogger = getRequestLogger(locals); requestLogger.warn( { @@ -48,7 +49,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) => }, 'Search rejected' ); - return zlibraryAuthFailureResponse(searchResult.error.message, searchResult.error.status, cookies, url); + return zlibraryAuthFailureResponse(searchResult.error.message, searchResult.error.status, cookies, event); } return json(searchResult.value, { diff --git a/sake/tests/auth/cookies.test.ts b/sake/tests/auth/cookies.test.ts new file mode 100644 index 0000000..fafad5f --- /dev/null +++ b/sake/tests/auth/cookies.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { Cookies } from '@sveltejs/kit'; +import { setSakeSessionCookie } from '$lib/server/auth/cookies'; + +function createCookieRecorder() { + const sets: Array<{ + name: string; + value: string; + options: Record; + }> = []; + + const cookies = { + get() { + return undefined; + }, + getAll() { + return []; + }, + set(name: string, value: string, options: Record) { + sets.push({ name, value, options }); + }, + delete() {}, + serialize() { + return ''; + } + } as unknown as Cookies; + + return { cookies, sets }; +} + +describe('setSakeSessionCookie', () => { + test('does not mark cookies as secure for direct HTTP adapter-node requests', () => { + const { cookies, sets } = createCookieRecorder(); + + setSakeSessionCookie( + cookies, + { + url: new URL('https://127.0.0.1:4174/api/auth/bootstrap'), + request: new Request('https://127.0.0.1:4174/api/auth/bootstrap'), + platform: { + req: { + socket: {} + } + } + }, + 'test-token', + '2026-05-19T11:38:25.149Z' + ); + + assert.equal(sets.length, 1); + assert.equal(sets[0]?.name, 'sake_session'); + assert.equal(sets[0]?.options.secure, false); + }); + + test('keeps cookies secure when a proxy reports https', () => { + const { cookies, sets } = createCookieRecorder(); + + setSakeSessionCookie( + cookies, + { + url: new URL('https://sake.example/api/auth/login'), + request: new Request('https://sake.example/api/auth/login', { + headers: { + 'x-forwarded-proto': 'https' + } + }), + platform: { + req: { + socket: { + encrypted: false + } + } + } + }, + 'test-token', + '2026-05-19T11:38:25.149Z' + ); + + assert.equal(sets.length, 1); + assert.equal(sets[0]?.options.secure, true); + }); + + test('falls back to the request URL when transport hints are unavailable', () => { + const { cookies, sets } = createCookieRecorder(); + + setSakeSessionCookie( + cookies, + { + url: new URL('http://localhost:5173/api/auth/login'), + request: new Request('http://localhost:5173/api/auth/login') + }, + 'test-token', + '2026-05-19T11:38:25.149Z' + ); + + assert.equal(sets.length, 1); + assert.equal(sets[0]?.options.secure, false); + }); +}); From c1dda10ddb65d7f90bb5296d2ca827dac467489e Mon Sep 17 00:00:00 2001 From: Sascha Date: Sun, 19 Apr 2026 16:39:13 +0200 Subject: [PATCH 2/2] crypto fallback --- sake/src/lib/client/stores/toastStore.svelte.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/sake/src/lib/client/stores/toastStore.svelte.ts b/sake/src/lib/client/stores/toastStore.svelte.ts index daff080..1f98046 100644 --- a/sake/src/lib/client/stores/toastStore.svelte.ts +++ b/sake/src/lib/client/stores/toastStore.svelte.ts @@ -7,13 +7,27 @@ export interface Toast { duration?: number; } +function generateId(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // crypto.randomUUID is only available in secure contexts (HTTPS/localhost). + // Fall back to getRandomValues which works over plain HTTP too. + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + export class ToastStore { toasts = $state([]); constructor() { } add(message: string, type: ToastType = 'info', duration: number = 3000) { - const id = crypto.randomUUID(); + const id = generateId(); const toast: Toast = { id, message, type, duration }; this.toasts.push(toast);