From 6ac02c792a6a768091d402e6d7ceb54e208f394e Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 27 Apr 2026 11:02:10 -0400 Subject: [PATCH] fix(backend): use URL parser for registry scheme + template host check (#808) Two small hardenings flagged by CodeQL: - routes/registries.ts: drop the redundant startsWith block-list and rely solely on URL parsing + protocol allow-list. The startsWith pass was unreachable defense (any non-http/https scheme already fails the protocol check below it) and was tripping js/incomplete-url-scheme-check. - services/TemplateService.ts: replace the .includes('api.linuxserver.io') substring match with new URL(registryUrl).hostname comparison. The substring form would mis-classify a malicious admin-set URL like https://evil.example/api.linuxserver.io/... as the LSIO registry and apply the LSIO response parser to its payload. Hostname compare closes that. Behavioral parity for the happy path: every previously-accepted URL still parses; the LSIO branch still triggers when the hostname is exactly api.linuxserver.io. --- backend/src/routes/registries.ts | 11 ++++------- backend/src/services/TemplateService.ts | 4 +++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/registries.ts b/backend/src/routes/registries.ts index 5e3efa50..178c0480 100644 --- a/backend/src/routes/registries.ts +++ b/backend/src/routes/registries.ts @@ -11,17 +11,14 @@ function isValidRegistryUrl(url: string, type: string): boolean { if (type === 'dockerhub') return true; const trimmed = url.trim(); if (!trimmed) return false; - const lower = trimmed.toLowerCase(); - if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('file:') || lower.startsWith('ftp:')) { - return false; - } + let parsed: URL; try { - const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; - if (!parsed.hostname) return false; + parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + if (!parsed.hostname) return false; return true; } diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index 9b69d7fb..70f3bbd8 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -233,7 +233,9 @@ export class TemplateService { console.log(`[Templates] Fetching from registry: ${registryUrl}`); const debug = isDebugEnabled(); - if (registryUrl.includes('api.linuxserver.io')) { + let registryHost = ''; + try { registryHost = new URL(registryUrl).hostname.toLowerCase(); } catch { /* invalid URL, treated as non-LSIO */ } + if (registryHost === 'api.linuxserver.io') { const response = await axios.get(registryUrl, { timeout: 20_000 }); // Official LSIO API Schema Mapping const lsioApps = response.data?.data?.repositories?.linuxserver ?? {};