From aeae58d0d8667856b0ecde5c372cbaae013d617e Mon Sep 17 00:00:00 2001 From: ReaperOfVeriod Date: Sun, 25 Oct 2026 06:57:31 +0100 Subject: [PATCH] validate client server origins --- CONTRIBUTING.md | 3 +++ src/App.svelte | 31 +++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4106f8..2eaa2e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,3 +4,6 @@ Run formatting, linting, type checks, tests, and the AppImage build before submi The client uses Prettier for formatting and `svelte-check` for the lint/check gate. Run `npm run format:check`, `npm run lint`, `npm run check`, and `npm run build` before pushing changes. + +The server address is treated as untrusted input. Client requests accept only +HTTP(S) origins without embedded credentials, query strings, or fragments. diff --git a/src/App.svelte b/src/App.svelte index 94c6021..598a548 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,9 +8,23 @@ return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; } + function validatedServerAddress(value: string): string { + const candidate = value.trim(); + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + throw new Error('The server address must be a valid HTTP or HTTPS URL.'); + } + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || !parsed.hostname) { + throw new Error('The server address must use HTTP or HTTPS without credentials or URL data.'); + } + return parsed.toString().replace(/\/$/, ''); + } + async function saveNativeSession() { if (!nativeClientAvailable() || !username || !serverAddress || !sessionToken) return; - const settings: ClientSettings = { server_address: serverAddress, username }; + const settings: ClientSettings = { server_address: validatedServerAddress(serverAddress), username }; await invoke('save_client_settings', { settings }); await invoke('store_server_session', { settings, session: sessionToken }); } @@ -24,7 +38,7 @@ if (!nativeClientAvailable()) return; try { const settings = await invoke('load_client_settings'); - serverAddress = settings.server_address; + serverAddress = validatedServerAddress(settings.server_address); username = settings.username; } catch { authError = 'Native settings could not be read. Enter the server address and username again.'; @@ -50,10 +64,12 @@ async function restoreNativeSession() { if (!nativeClientAvailable() || !username || !serverAddress) return; try { - const settings: ClientSettings = { server_address: serverAddress, username }; + const safeServerAddress = validatedServerAddress(serverAddress); + serverAddress = safeServerAddress; + const settings: ClientSettings = { server_address: safeServerAddress, username }; const storedSession = await invoke('load_server_session', { settings }); if (!storedSession) return; - const response = await fetch(`${serverAddress}/api/v1/auth/session`, { + const response = await fetch(`${safeServerAddress}/api/v1/auth/session`, { headers: { authorization: `Bearer ${storedSession}` }, }); if (!response.ok) { @@ -325,7 +341,9 @@ authBusy = true; authError = ''; try { - const response = await fetch(`${serverAddress}/api/v1/auth/login`, { + const safeServerAddress = validatedServerAddress(serverAddress); + serverAddress = safeServerAddress; + const response = await fetch(`${safeServerAddress}/api/v1/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username, password }), @@ -449,7 +467,8 @@ function connectRealtime() { realtimeSocket?.close(); - const socketUrl = serverAddress.replace(/^http/, 'ws') + '/api/v1/ws'; + const safeServerAddress = validatedServerAddress(serverAddress); + const socketUrl = safeServerAddress.replace(/^http/, 'ws') + '/api/v1/ws'; realtimeState = 'CONNECTING'; const socket = new WebSocket(socketUrl); realtimeSocket = socket;