web-player: fix open redirect via returnTo, require SESSION_SECRET and validate API inputs

This commit is contained in:
2026-09-24 04:58:08 +02:00
parent b2f450dcf7
commit 3e546d4a1e
12 changed files with 64 additions and 13 deletions
+1
View File
@@ -1,4 +1,5 @@
export const EXPLORE_PAGE_SIZE = 27;
export const MAX_PAGE_SIZE = 100;
export const SEARCH_DEBOUNCE_MS = 450;
export const SEARCH_MIN_LENGTH = 2;
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { safeReturnTo } from './auth';
describe('safeReturnTo', () => {
it('keeps local paths', () => {
expect(safeReturnTo('/')).toBe('/');
expect(safeReturnTo('/playlists/1?tab=x#top')).toBe('/playlists/1?tab=x#top');
});
it('falls back to / for missing values', () => {
expect(safeReturnTo(null)).toBe('/');
expect(safeReturnTo(undefined)).toBe('/');
expect(safeReturnTo('')).toBe('/');
});
it('rejects absolute and protocol-relative URLs', () => {
expect(safeReturnTo('https://example.com')).toBe('/');
expect(safeReturnTo('//example.com')).toBe('/');
expect(safeReturnTo('/\\example.com')).toBe('/');
expect(safeReturnTo('javascript:alert(1)')).toBe('/');
expect(safeReturnTo('relative/path')).toBe('/');
});
});
+7
View File
@@ -18,6 +18,13 @@ export interface Session extends SessionUser {
expiresAt: number;
}
export function safeReturnTo(value: string | null | undefined): string {
if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
return '/';
}
return value;
}
let configPromise: Promise<client.Configuration> | undefined;
export function getOidcConfig(): Promise<client.Configuration> {
configPromise ??= (async () => {
+8 -1
View File
@@ -1,6 +1,7 @@
import { env } from '$env/dynamic/private';
const DAY_SECONDS = 60 * 60 * 24;
const MIN_SESSION_SECRET_LENGTH = 32;
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
function positiveInt(value: string | undefined, fallback: number): number {
@@ -35,7 +36,13 @@ export const authConfig = {
return required('AUTH_POST_LOGOUT_URI', env.AUTH_POST_LOGOUT_URI);
},
get sessionSecret() {
return env.SESSION_SECRET;
const secret = required('SESSION_SECRET', env.SESSION_SECRET);
if (secret.length < MIN_SESSION_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long.`
);
}
return secret;
},
get sessionTtlSeconds() {
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
+3 -1
View File
@@ -10,7 +10,9 @@ export async function proxyCoverImage(
): Promise<Response> {
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`);
const upstream = await fetch(
`${apiConfig.baseUrl}/${kind}/${encodeURIComponent(id)}/cover?size=${safeSize}`
);
if (!upstream.ok) {
return new Response(null, { status: upstream.status });