fix(security): validate share proxy targets

This commit is contained in:
2026-06-07 21:04:14 +02:00
parent 44588dae8f
commit d98ec5f23a

View File

@@ -19,8 +19,22 @@ type ShareListingEntry = {
isCollection: boolean; 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 { 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\/([^/?#]+)/); const publicShare = url.pathname.match(/\/s\/([^/?#]+)/);
if (publicShare) { if (publicShare) {
@@ -36,6 +50,17 @@ function resolveDavUrlFromShareUrl(shareUrl: string): string {
throw new Error("Please enter a public Nextcloud share link."); 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) { async function proxyUpstream(url: string, init?: RequestInit) {
const upstream = await fetch(url, init); const upstream = await fetch(url, init);
const body = await upstream.arrayBuffer(); const body = await upstream.arrayBuffer();
@@ -1018,6 +1043,16 @@ function htmlPage(): string {
return element; return element;
} }
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[character]);
}
function setTheme(theme) { function setTheme(theme) {
const resolvedTheme = theme === "light" ? "light" : "dark"; const resolvedTheme = theme === "light" ? "light" : "dark";
document.body.dataset.theme = resolvedTheme; document.body.dataset.theme = resolvedTheme;
@@ -1383,7 +1418,7 @@ function htmlPage(): string {
return L.divIcon({ return L.divIcon({
className: "photo-map-marker" + activeClass, className: "photo-map-marker" + activeClass,
html: html:
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />', '<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />',
iconSize: [44, 44], iconSize: [44, 44],
iconAnchor: [22, 22], iconAnchor: [22, 22],
popupAnchor: [0, -22] popupAnchor: [0, -22]
@@ -1588,10 +1623,10 @@ function htmlPage(): string {
marker.bindPopup( marker.bindPopup(
'<div class="thumb">' + '<div class="thumb">' +
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' + '<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />' +
'<strong>' + photo.name + '</strong>' + '<strong>' + escapeHtml(photo.name) + '</strong>' +
'<small>' + formatDate(photo.capturedAt) + '</small>' + '<small>' + escapeHtml(formatDate(photo.capturedAt)) + '</small>' +
'<button type="button" data-open="' + photo.id + '">Open</button>' + '<button type="button" data-open="' + escapeHtml(photo.id) + '">Open</button>' +
'</div>' '</div>'
); );
@@ -1606,8 +1641,10 @@ function htmlPage(): string {
const item = document.createElement("article"); const item = document.createElement("article");
item.className = "photo" + (photo.id === state.activePhotoId ? " active" : ""); item.className = "photo" + (photo.id === state.activePhotoId ? " active" : "");
item.innerHTML = item.innerHTML =
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' + '<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />' +
'<div><strong>' + photo.name + '</strong><span>' + formatDate(photo.capturedAt) + '</span></div>'; '<div><strong>' + escapeHtml(photo.name) + '</strong><span>' +
escapeHtml(formatDate(photo.capturedAt)) +
'</span></div>';
item.addEventListener("click", () => { item.addEventListener("click", () => {
state.activePhotoId = photo.id; state.activePhotoId = photo.id;
renderVisiblePhotos({ fitMap: false }); renderVisiblePhotos({ fitMap: false });
@@ -1813,8 +1850,12 @@ function htmlPage(): string {
return parseListing(await response.text(), davBaseUrl); return parseListing(await response.text(), davBaseUrl);
} }
async function readRemoteImage(entry, signal) { async function readRemoteImage(entry, shareUrlValue, signal) {
const response = await fetch("/api/nextcloud/blob?url=" + encodeURIComponent(entry.href), { const params = new URLSearchParams({
share: shareUrlValue.trim(),
url: entry.href
});
const response = await fetch("/api/nextcloud/blob?" + params.toString(), {
signal signal
}); });
if (!response.ok) { if (!response.ok) {
@@ -1872,7 +1913,7 @@ function htmlPage(): string {
} }
try { try {
const photo = await readRemoteImage(entry, controller.signal); const photo = await readRemoteImage(entry, importUrlInput.value, controller.signal);
if (photo.latitude !== null && photo.longitude !== null) { if (photo.latitude !== null && photo.longitude !== null) {
appendPhoto(photo); appendPhoto(photo);
loaded += 1; loaded += 1;
@@ -2005,16 +2046,32 @@ export function createRequestHandler() {
if (url.pathname === "/api/nextcloud/blob") { if (url.pathname === "/api/nextcloud/blob") {
const target = url.searchParams.get("url"); const target = url.searchParams.get("url");
const share = url.searchParams.get("share");
if (!target) { if (!target || !share) {
res.statusCode = 400; res.statusCode = 400;
res.setHeader("content-type", "application/json; charset=utf-8"); 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; return;
} }
try { try {
const upstream = await proxyUpstream(target); const upstream = await proxyUpstream(validatedTarget);
res.statusCode = upstream.status; res.statusCode = upstream.status;
res.setHeader("content-type", upstream.contentType); res.setHeader("content-type", upstream.contentType);
res.end(upstream.body); res.end(upstream.body);