fix(security): validate share proxy targets
This commit is contained in:
@@ -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:
|
||||
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />',
|
||||
'<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />',
|
||||
iconSize: [44, 44],
|
||||
iconAnchor: [22, 22],
|
||||
popupAnchor: [0, -22]
|
||||
@@ -1588,10 +1623,10 @@ function htmlPage(): string {
|
||||
|
||||
marker.bindPopup(
|
||||
'<div class="thumb">' +
|
||||
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
|
||||
'<strong>' + photo.name + '</strong>' +
|
||||
'<small>' + formatDate(photo.capturedAt) + '</small>' +
|
||||
'<button type="button" data-open="' + photo.id + '">Open</button>' +
|
||||
'<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />' +
|
||||
'<strong>' + escapeHtml(photo.name) + '</strong>' +
|
||||
'<small>' + escapeHtml(formatDate(photo.capturedAt)) + '</small>' +
|
||||
'<button type="button" data-open="' + escapeHtml(photo.id) + '">Open</button>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
@@ -1606,8 +1641,10 @@ function htmlPage(): string {
|
||||
const item = document.createElement("article");
|
||||
item.className = "photo" + (photo.id === state.activePhotoId ? " active" : "");
|
||||
item.innerHTML =
|
||||
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
|
||||
'<div><strong>' + photo.name + '</strong><span>' + formatDate(photo.capturedAt) + '</span></div>';
|
||||
'<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />' +
|
||||
'<div><strong>' + escapeHtml(photo.name) + '</strong><span>' +
|
||||
escapeHtml(formatDate(photo.capturedAt)) +
|
||||
'</span></div>';
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user