Harden backup blob and remote endpoint handling

This commit is contained in:
shuaiplus
2026-07-06 18:17:55 +08:00
parent 7ac6ae50bb
commit 5c8f01be59
5 changed files with 84 additions and 26 deletions
+16 -1
View File
@@ -124,11 +124,26 @@ function assertBackupEndpointHostAllowed(hostname: string, label: string): void
if (!normalized) throw new Error(`${label} host is required`);
if (
normalized === 'localhost' ||
normalized === 'localhost.localdomain' ||
normalized.endsWith('.localhost.localdomain') ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local') ||
normalized.endsWith('.home.arpa') ||
normalized.endsWith('.internal') ||
normalized.endsWith('.lan') ||
normalized === 'metadata.google.internal'
normalized === 'metadata.google.internal' ||
normalized === 'localtest.me' ||
normalized.endsWith('.localtest.me') ||
normalized === 'lvh.me' ||
normalized.endsWith('.lvh.me') ||
normalized === 'vcap.me' ||
normalized.endsWith('.vcap.me') ||
normalized === 'nip.io' ||
normalized.endsWith('.nip.io') ||
normalized === 'sslip.io' ||
normalized.endsWith('.sslip.io') ||
normalized === 'xip.io' ||
normalized.endsWith('.xip.io')
) {
throw new Error(`${label} host is not allowed`);
}
+37 -18
View File
@@ -241,19 +241,38 @@ function webDavFullPath(config: WebDavBackupDestination, relativePath: string):
return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath));
}
async function fetchBackupEndpoint(
input: string | URL,
init: RequestInit,
label: string
): Promise<Response> {
const url = normalizeBackupEndpointUrl(input.toString(), label);
const response = await fetch(url, {
...init,
redirect: 'manual',
});
if (response.status >= 300 && response.status < 400) {
throw new Error(`${label} must not redirect`);
}
if (response.redirected) {
throw new Error(`${label} must not redirect`);
}
return response;
}
async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise<void> {
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
let current = '';
for (const segment of segments) {
current = buildJoinedPath(current, segment);
const url = buildWebDavUrl(baseUrl, current);
const response = await fetch(url, {
const response = await fetchBackupEndpoint(url, {
method: 'MKCOL',
headers: {
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
}, 'WebDAV directory URL');
if ([200, 201, 204, 405].includes(response.status)) continue;
throw new Error(`WebDAV directory creation failed: ${response.status}`);
}
}
@@ -270,13 +289,13 @@ async function ensureWebDavDirectoryCached(
current = buildJoinedPath(current, segment);
if (ensuredDirectories.has(current)) continue;
const url = buildWebDavUrl(baseUrl, current);
const response = await fetch(url, {
const response = await fetchBackupEndpoint(url, {
method: 'MKCOL',
headers: {
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
}, 'WebDAV directory URL');
if ([200, 201, 204, 405].includes(response.status)) {
ensuredDirectories.add(current);
continue;
}
@@ -303,7 +322,7 @@ async function putToWebDav(
}
}
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remoteFilePath), {
method: 'PUT',
headers: {
Authorization: authHeader,
@@ -311,7 +330,7 @@ async function putToWebDav(
'Content-Length': String(bytes.byteLength),
},
body: bytes,
});
}, 'WebDAV upload URL');
if (!response.ok) {
throw new Error(`WebDAV upload failed: ${response.status}`);
@@ -340,7 +359,7 @@ async function listWebDavEntries(config: WebDavBackupDestination, relativePath:
const currentPath = normalizeRelativePath(relativePath);
const targetFullPath = webDavFullPath(config, currentPath);
const authHeader = toBasicAuthHeader(config.username, config.password);
const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), {
const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, targetFullPath), {
method: 'PROPFIND',
headers: {
Authorization: authHeader,
@@ -348,7 +367,7 @@ async function listWebDavEntries(config: WebDavBackupDestination, relativePath:
'Content-Type': 'application/xml; charset=utf-8',
},
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
});
}, 'WebDAV listing URL');
if (response.status === 404) {
return {
provider: 'webdav',
@@ -408,12 +427,12 @@ async function downloadFromWebDav(config: WebDavBackupDestination, relativePath:
}
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, normalized);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
}, 'WebDAV download URL');
if (!response.ok) {
throw new Error(`WebDAV download failed: ${response.status}`);
}
@@ -429,12 +448,12 @@ async function downloadFromWebDav(config: WebDavBackupDestination, relativePath:
async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<void> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'DELETE',
headers: {
Authorization: authHeader,
},
});
}, 'WebDAV delete URL');
if (!response.ok && response.status !== 404) {
throw new Error(`WebDAV delete failed: ${response.status}`);
}
@@ -447,12 +466,12 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'HEAD',
headers: {
Authorization: authHeader,
},
});
}, 'WebDAV stat URL');
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`WebDAV existence check failed: ${response.status}`);
@@ -519,7 +538,7 @@ async function signedS3Request(
config.region || 'auto'
);
return fetch(url.toString(), {
return fetchBackupEndpoint(url, {
method,
headers: {
Authorization: authorization,
@@ -528,7 +547,7 @@ async function signedS3Request(
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
},
body,
});
}, 'S3 endpoint URL');
}
async function putToS3(