mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Harden backup blob and remote endpoint handling
This commit is contained in:
+22
-1
@@ -1239,7 +1239,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
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);
|
const object = await getBlobObject(env, blobName);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Backup attachment blob not found', 404);
|
return errorResponse('Backup attachment blob not found', 404);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleAdminExportBackup(request, env, actorUser);
|
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);
|
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,11 +124,26 @@ function assertBackupEndpointHostAllowed(hostname: string, label: string): void
|
|||||||
if (!normalized) throw new Error(`${label} host is required`);
|
if (!normalized) throw new Error(`${label} host is required`);
|
||||||
if (
|
if (
|
||||||
normalized === 'localhost' ||
|
normalized === 'localhost' ||
|
||||||
|
normalized === 'localhost.localdomain' ||
|
||||||
|
normalized.endsWith('.localhost.localdomain') ||
|
||||||
normalized.endsWith('.localhost') ||
|
normalized.endsWith('.localhost') ||
|
||||||
normalized.endsWith('.local') ||
|
normalized.endsWith('.local') ||
|
||||||
|
normalized.endsWith('.home.arpa') ||
|
||||||
normalized.endsWith('.internal') ||
|
normalized.endsWith('.internal') ||
|
||||||
normalized.endsWith('.lan') ||
|
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`);
|
throw new Error(`${label} host is not allowed`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,19 +241,38 @@ function webDavFullPath(config: WebDavBackupDestination, relativePath: string):
|
|||||||
return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath));
|
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> {
|
async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise<void> {
|
||||||
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||||
let current = '';
|
let current = '';
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
current = buildJoinedPath(current, segment);
|
current = buildJoinedPath(current, segment);
|
||||||
const url = buildWebDavUrl(baseUrl, current);
|
const url = buildWebDavUrl(baseUrl, current);
|
||||||
const response = await fetch(url, {
|
const response = await fetchBackupEndpoint(url, {
|
||||||
method: 'MKCOL',
|
method: 'MKCOL',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
}, 'WebDAV directory URL');
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
|
if ([200, 201, 204, 405].includes(response.status)) continue;
|
||||||
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,13 +289,13 @@ async function ensureWebDavDirectoryCached(
|
|||||||
current = buildJoinedPath(current, segment);
|
current = buildJoinedPath(current, segment);
|
||||||
if (ensuredDirectories.has(current)) continue;
|
if (ensuredDirectories.has(current)) continue;
|
||||||
const url = buildWebDavUrl(baseUrl, current);
|
const url = buildWebDavUrl(baseUrl, current);
|
||||||
const response = await fetch(url, {
|
const response = await fetchBackupEndpoint(url, {
|
||||||
method: 'MKCOL',
|
method: 'MKCOL',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
}, 'WebDAV directory URL');
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
if ([200, 201, 204, 405].includes(response.status)) {
|
||||||
ensuredDirectories.add(current);
|
ensuredDirectories.add(current);
|
||||||
continue;
|
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',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
@@ -311,7 +330,7 @@ async function putToWebDav(
|
|||||||
'Content-Length': String(bytes.byteLength),
|
'Content-Length': String(bytes.byteLength),
|
||||||
},
|
},
|
||||||
body: bytes,
|
body: bytes,
|
||||||
});
|
}, 'WebDAV upload URL');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`WebDAV upload failed: ${response.status}`);
|
throw new Error(`WebDAV upload failed: ${response.status}`);
|
||||||
@@ -340,7 +359,7 @@ async function listWebDavEntries(config: WebDavBackupDestination, relativePath:
|
|||||||
const currentPath = normalizeRelativePath(relativePath);
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
const targetFullPath = webDavFullPath(config, currentPath);
|
const targetFullPath = webDavFullPath(config, currentPath);
|
||||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
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',
|
method: 'PROPFIND',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
@@ -348,7 +367,7 @@ async function listWebDavEntries(config: WebDavBackupDestination, relativePath:
|
|||||||
'Content-Type': 'application/xml; charset=utf-8',
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
},
|
},
|
||||||
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
|
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
|
||||||
});
|
}, 'WebDAV listing URL');
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return {
|
return {
|
||||||
provider: 'webdav',
|
provider: 'webdav',
|
||||||
@@ -408,12 +427,12 @@ async function downloadFromWebDav(config: WebDavBackupDestination, relativePath:
|
|||||||
}
|
}
|
||||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
const remotePath = webDavFullPath(config, normalized);
|
const remotePath = webDavFullPath(config, normalized);
|
||||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
}, 'WebDAV download URL');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`WebDAV download failed: ${response.status}`);
|
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> {
|
async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<void> {
|
||||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
const remotePath = webDavFullPath(config, relativePath);
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
}, 'WebDAV delete URL');
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
throw new Error(`WebDAV delete failed: ${response.status}`);
|
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> {
|
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
const remotePath = webDavFullPath(config, relativePath);
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
}, 'WebDAV stat URL');
|
||||||
if (response.status === 404) return null;
|
if (response.status === 404) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
||||||
@@ -519,7 +538,7 @@ async function signedS3Request(
|
|||||||
config.region || 'auto'
|
config.region || 'auto'
|
||||||
);
|
);
|
||||||
|
|
||||||
return fetch(url.toString(), {
|
return fetchBackupEndpoint(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authorization,
|
Authorization: authorization,
|
||||||
@@ -528,7 +547,7 @@ async function signedS3Request(
|
|||||||
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
|
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
});
|
}, 'S3 endpoint URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function putToS3(
|
async function putToS3(
|
||||||
|
|||||||
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
|
|||||||
|
|
||||||
export async function downloadAdminBackupAttachmentBlob(
|
export async function downloadAdminBackupAttachmentBlob(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
blobName: string
|
blobName: string,
|
||||||
|
masterPasswordHash: string
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/blob', {
|
||||||
params.set('blobName', blobName);
|
method: 'POST',
|
||||||
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ blobName, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
||||||
return new Uint8Array(await resp.arrayBuffer());
|
return new Uint8Array(await resp.arrayBuffer());
|
||||||
}
|
}
|
||||||
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
|
|||||||
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||||
});
|
});
|
||||||
for (const attachment of manifest.attachmentBlobs || []) {
|
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;
|
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user