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
+22 -1
View File
@@ -1239,7 +1239,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
try {
const url = new URL(request.url);
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
if (request.method === 'POST') {
try {
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
} catch {
return errorResponse('Backup attachment download payload is invalid', 400);
}
} else {
input = {
blobName: url.searchParams.get('blobName') || '',
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
};
}
const verificationError = await requireBackupUserVerification(
actorUser,
String(input.masterPasswordHash || ''),
env
);
if (verificationError) return verificationError;
const blobName = ensureBackupBlobName(String(input.blobName || ''));
const object = await getBlobObject(env, blobName);
if (!object) {
return errorResponse('Backup attachment blob not found', 404);
+1 -1
View File
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
return handleAdminExportBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/blob' && method === 'GET') {
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
return handleDownloadAdminBackupAttachment(request, env, actorUser);
}
+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(
+8 -5
View File
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
export async function downloadAdminBackupAttachmentBlob(
authedFetch: AuthedFetch,
blobName: string
blobName: string,
masterPasswordHash: string
): Promise<Uint8Array> {
const params = new URLSearchParams();
params.set('blobName', blobName);
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
const resp = await authedFetch('/api/admin/backup/blob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blobName, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
return new Uint8Array(await resp.arrayBuffer());
}
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
});
for (const attachment of manifest.attachmentBlobs || []) {
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName, masterPasswordHash);
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
}