From d98ec5f23a4ca773a410d22e2202424672f705f3 Mon Sep 17 00:00:00 2001 From: Arne Baeumler Date: Sun, 7 Jun 2026 21:04:14 +0200 Subject: [PATCH] fix(security): validate share proxy targets --- src/server/request-handler.ts | 85 +++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/src/server/request-handler.ts b/src/server/request-handler.ts index 3f96c56..394e266 100644 --- a/src/server/request-handler.ts +++ b/src/server/request-handler.ts @@ -19,8 +19,22 @@ type ShareListingEntry = { isCollection: boolean; }; +function parseHttpUrl(value: string, errorMessage: string): URL { + const url = new URL(value.trim()); + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(errorMessage); + } + + if (url.username || url.password) { + throw new Error("URL credentials are not allowed."); + } + + return url; +} + function resolveDavUrlFromShareUrl(shareUrl: string): string { - const url = new URL(shareUrl); + const url = parseHttpUrl(shareUrl, "Please enter a public Nextcloud share link."); const publicShare = url.pathname.match(/\/s\/([^/?#]+)/); if (publicShare) { @@ -36,6 +50,17 @@ function resolveDavUrlFromShareUrl(shareUrl: string): string { throw new Error("Please enter a public Nextcloud share link."); } +function resolveValidatedBlobUrl(targetUrl: string, shareUrl: string): string { + const target = parseHttpUrl(targetUrl, "Invalid image URL."); + const shareDavUrl = new URL(resolveDavUrlFromShareUrl(shareUrl)); + + if (target.origin !== shareDavUrl.origin || !target.pathname.startsWith(shareDavUrl.pathname)) { + throw new Error("Image URL is outside the requested Nextcloud share."); + } + + return target.toString(); +} + async function proxyUpstream(url: string, init?: RequestInit) { const upstream = await fetch(url, init); const body = await upstream.arrayBuffer(); @@ -1018,6 +1043,16 @@ function htmlPage(): string { return element; } + function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[character]); + } + function setTheme(theme) { const resolvedTheme = theme === "light" ? "light" : "dark"; document.body.dataset.theme = resolvedTheme; @@ -1383,7 +1418,7 @@ function htmlPage(): string { return L.divIcon({ className: "photo-map-marker" + activeClass, html: - '' + photo.name + '', + '' + escapeHtml(photo.name) + '', iconSize: [44, 44], iconAnchor: [22, 22], popupAnchor: [0, -22] @@ -1588,10 +1623,10 @@ function htmlPage(): string { marker.bindPopup( '
' + - '' + photo.name + '' + - '' + photo.name + '' + - '' + formatDate(photo.capturedAt) + '' + - '' + + '' + escapeHtml(photo.name) + '' + + '' + escapeHtml(photo.name) + '' + + '' + escapeHtml(formatDate(photo.capturedAt)) + '' + + '' + '
' ); @@ -1606,8 +1641,10 @@ function htmlPage(): string { const item = document.createElement("article"); item.className = "photo" + (photo.id === state.activePhotoId ? " active" : ""); item.innerHTML = - '' + photo.name + '' + - '
' + photo.name + '' + formatDate(photo.capturedAt) + '
'; + '' + escapeHtml(photo.name) + '' + + '
' + escapeHtml(photo.name) + '' + + escapeHtml(formatDate(photo.capturedAt)) + + '
'; item.addEventListener("click", () => { state.activePhotoId = photo.id; renderVisiblePhotos({ fitMap: false }); @@ -1813,8 +1850,12 @@ function htmlPage(): string { return parseListing(await response.text(), davBaseUrl); } - async function readRemoteImage(entry, signal) { - const response = await fetch("/api/nextcloud/blob?url=" + encodeURIComponent(entry.href), { + async function readRemoteImage(entry, shareUrlValue, signal) { + const params = new URLSearchParams({ + share: shareUrlValue.trim(), + url: entry.href + }); + const response = await fetch("/api/nextcloud/blob?" + params.toString(), { signal }); if (!response.ok) { @@ -1872,7 +1913,7 @@ function htmlPage(): string { } try { - const photo = await readRemoteImage(entry, controller.signal); + const photo = await readRemoteImage(entry, importUrlInput.value, controller.signal); if (photo.latitude !== null && photo.longitude !== null) { appendPhoto(photo); loaded += 1; @@ -2005,16 +2046,32 @@ export function createRequestHandler() { if (url.pathname === "/api/nextcloud/blob") { const target = url.searchParams.get("url"); + const share = url.searchParams.get("share"); - if (!target) { + if (!target || !share) { res.statusCode = 400; res.setHeader("content-type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ error: "missing `url` parameter" })); + res.end(JSON.stringify({ error: "missing `share` or `url` parameter" })); + return; + } + + let validatedTarget: string; + + try { + validatedTarget = resolveValidatedBlobUrl(target, share); + } catch (error) { + res.statusCode = 400; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + error: error instanceof Error ? error.message : "invalid image URL" + }) + ); return; } try { - const upstream = await proxyUpstream(target); + const upstream = await proxyUpstream(validatedTarget); res.statusCode = upstream.status; res.setHeader("content-type", upstream.contentType); res.end(upstream.body);