fix(security): validate share proxy targets
This commit is contained in:
@@ -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) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
})[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);
|
||||||
|
|||||||
Reference in New Issue
Block a user