From d0dc31ce8600d9554bea8cecf50da0e0a6da5001 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 14 May 2026 22:46:29 +0800 Subject: [PATCH] feat: enhance attachment metadata handling and add change password URI support --- src/handlers/attachments.ts | 13 +- src/handlers/ciphers.ts | 197 +++++++++++++++++++++++++++- src/router-public.ts | 82 ++++++++++++ src/services/storage-cipher-repo.ts | 4 + src/utils/response.ts | 10 +- 5 files changed, 294 insertions(+), 12 deletions(-) diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index eae0a26..c1dea72 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -10,7 +10,7 @@ import { verifyAttachmentUploadToken, verifyFileDownloadToken, } from '../utils/jwt'; -import { cipherToResponse } from './ciphers'; +import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers'; import { LIMITS } from '../config/limits'; import { readActingDeviceIdentifier } from '../utils/device'; import { @@ -282,6 +282,7 @@ export async function handleGetAttachment( if (!attachment || attachment.cipherId !== cipherId) { return errorResponse('Attachment not found', 404); } + const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment; // Generate short-lived download token const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET); @@ -292,12 +293,12 @@ export async function handleGetAttachment( return jsonResponse({ object: 'attachment', - id: attachment.id, + id: responseAttachment.id, url: downloadUrl, - fileName: attachment.fileName, - key: attachment.key, - size: String(Number(attachment.size) || 0), - sizeName: attachment.sizeName, + fileName: responseAttachment.fileName, + key: responseAttachment.key, + size: String(Number(responseAttachment.size) || 0), + sizeName: responseAttachment.sizeName, }); } diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index eb60b96..4f30773 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -263,6 +263,196 @@ export function formatAttachments(attachments: Attachment[]): any[] | null { return formatted.length ? formatted : null; } +function formatAttachmentSize(bytes: number): string { + if (bytes < 1024) return `${bytes} Bytes`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +interface IncomingAttachmentMetadata { + id: string; + fileName?: unknown; + key?: unknown; + fileSize?: unknown; + hasFileName: boolean; + hasKey: boolean; + hasFileSize: boolean; +} + +function readIncomingAttachmentMetadataMap( + value: unknown, + options: { legacyFileNameMap?: boolean } = {} +): IncomingAttachmentMetadata[] { + if (!value || typeof value !== 'object') return []; + const out: IncomingAttachmentMetadata[] = []; + + if (Array.isArray(value)) { + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + const id = String(row.id ?? row.Id ?? '').trim(); + if (!id) continue; + const fileName = getAliasedProp(row, ['fileName', 'FileName']); + const key = getAliasedProp(row, ['key', 'Key']); + const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']); + out.push({ + id, + fileName: fileName.value, + key: key.value, + fileSize: fileSize.value, + hasFileName: fileName.present, + hasKey: key.present, + hasFileSize: fileSize.present, + }); + } + return out; + } + + for (const [rawId, rawValue] of Object.entries(value as Record)) { + const id = String(rawId || '').trim(); + if (!id) continue; + + if (options.legacyFileNameMap && (typeof rawValue === 'string' || rawValue == null)) { + out.push({ + id, + fileName: rawValue, + key: undefined, + fileSize: undefined, + hasFileName: rawValue != null, + hasKey: false, + hasFileSize: false, + }); + continue; + } + + if (!rawValue || typeof rawValue !== 'object') continue; + const row = rawValue as Record; + const fileName = getAliasedProp(row, ['fileName', 'FileName']); + const key = getAliasedProp(row, ['key', 'Key']); + const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']); + out.push({ + id, + fileName: fileName.value, + key: key.value, + fileSize: fileSize.value, + hasFileName: fileName.present, + hasKey: key.present, + hasFileSize: fileSize.present, + }); + } + + return out; +} + +function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata[] { + const merged = new Map(); + const legacy = getAliasedProp(source, ['attachments', 'Attachments']); + const current = getAliasedProp(source, ['attachments2', 'Attachments2']); + + if (legacy.present) { + for (const item of readIncomingAttachmentMetadataMap(legacy.value, { legacyFileNameMap: true })) { + merged.set(item.id, item); + } + } + + if (current.present) { + for (const item of readIncomingAttachmentMetadataMap(current.value)) { + const previous = merged.get(item.id); + merged.set(item.id, { + id: item.id, + fileName: item.hasFileName ? item.fileName : previous?.fileName, + key: item.hasKey ? item.key : previous?.key, + fileSize: item.hasFileSize ? item.fileSize : previous?.fileSize, + hasFileName: item.hasFileName || previous?.hasFileName || false, + hasKey: item.hasKey || previous?.hasKey || false, + hasFileSize: item.hasFileSize || previous?.hasFileSize || false, + }); + } + } + + return [...merged.values()]; +} + +function hasIncomingAttachmentMetadata(source: any): boolean { + return readIncomingAttachmentMetadata(source).length > 0; +} + +async function syncIncomingAttachmentMetadata( + storage: StorageService, + cipherId: string, + cipherData: any +): Promise { + const incoming = readIncomingAttachmentMetadata(cipherData); + if (!incoming.length) return; + + const currentById = new Map((await storage.getAttachmentsByCipher(cipherId)).map((attachment) => [attachment.id, attachment])); + for (const item of incoming) { + const attachment = currentById.get(item.id); + if (!attachment) continue; + + let changed = false; + if (item.hasFileName) { + const fileName = String(item.fileName || '').trim(); + if (isValidEncString(fileName) && fileName !== attachment.fileName) { + attachment.fileName = fileName; + changed = true; + } + } + + if (item.hasKey) { + const key = optionalEncString(item.key); + if (key !== attachment.key) { + attachment.key = key; + changed = true; + } + } + + if (item.hasFileSize) { + const size = Number(item.fileSize); + if (Number.isFinite(size) && size >= 0 && size !== Number(attachment.size || 0)) { + attachment.size = size; + attachment.sizeName = formatAttachmentSize(size); + changed = true; + } + } + + if (changed) { + await storage.saveAttachment(attachment); + } + } +} + +export function applyCipherEmbeddedAttachmentMetadata(cipherData: any, attachments: Attachment[]): Attachment[] { + const incoming = readIncomingAttachmentMetadata(cipherData); + if (!incoming.length || !attachments.length) return attachments; + + const incomingById = new Map(incoming.map((item) => [item.id, item])); + return attachments.map((attachment) => { + const item = incomingById.get(attachment.id); + if (!item) return attachment; + + const next: Attachment = { ...attachment }; + if (item.hasFileName) { + const fileName = String(item.fileName || '').trim(); + if (isValidEncString(fileName)) { + next.fileName = fileName; + } + } + if (item.hasKey) { + next.key = optionalEncString(item.key); + } + if (item.hasFileSize) { + const size = Number(item.fileSize); + if (Number.isFinite(size) && size >= 0) { + next.size = size; + next.sizeName = formatAttachmentSize(size); + } + } + return next; + }); +} + function normalizeCipherFieldsForCompatibility(fields: any): any[] | null { if (!Array.isArray(fields) || fields.length === 0) return null; const out = fields @@ -329,6 +519,7 @@ export function cipherToResponse( 'licenseNumber', ]); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); + const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments); return { // Pass through ALL stored cipher fields (known + unknown) @@ -350,7 +541,7 @@ export function cipherToResponse( }, object: 'cipherDetails', collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [], - attachments: formatAttachments(attachments), + attachments: formatAttachments(responseAttachments), name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name, notes: optionalEncString(cipher.notes), login: normalizedLogin, @@ -524,8 +715,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str const incomingSshKey = readCipherProp(cipherData, ['sshKey', 'SshKey']); const incomingPasswordHistory = readCipherProp(cipherData, ['passwordHistory', 'PasswordHistory']); const incomingRevisionDate = readCipherRevisionDate(cipherData); + const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData); - if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) { + if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) { return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); } @@ -580,6 +772,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str if (!folderOk) return errorResponse('Folder not found', 404); } + await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); diff --git a/src/router-public.ts b/src/router-public.ts index f1ccb8c..69c7676 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -77,6 +77,82 @@ function handleMissingWebsiteIcon(): Response { }); } +function isPrivateIpv4(hostname: string): boolean { + const parts = hostname.split('.').map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; + const [a, b] = parts; + return ( + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a === 0 + ); +} + +function isBlockedChangePasswordHost(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.+$/, ''); + return ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + normalized.endsWith('.local') || + normalized === '::1' || + normalized.startsWith('[') || + isPrivateIpv4(normalized) + ); +} + +function parsePublicHttpUrl(rawUri: string | null): URL | null { + if (!rawUri) return null; + try { + const url = new URL(rawUri); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + if (isBlockedChangePasswordHost(url.hostname)) return null; + return url; + } catch { + return null; + } +} + +async function handleChangePasswordUri(request: Request): Promise { + const sourceUrl = parsePublicHttpUrl(new URL(request.url).searchParams.get('uri')); + if (!sourceUrl) { + return jsonResponse({ uri: null }); + } + + const wellKnownUrl = new URL('/.well-known/change-password', sourceUrl.origin); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS); + try { + const response = await fetch(wellKnownUrl.toString(), { + method: 'GET', + redirect: 'manual', + signal: controller.signal, + cf: { + cacheEverything: true, + cacheTtl: LIMITS.cache.iconTtlSeconds, + }, + } as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } }); + + if (response.status < 300 || response.status >= 400) { + return jsonResponse({ uri: null }); + } + + const location = response.headers.get('Location'); + if (!location) return jsonResponse({ uri: null }); + + const targetUrl = parsePublicHttpUrl(new URL(location, wellKnownUrl).toString()); + if (!targetUrl) return jsonResponse({ uri: null }); + + return jsonResponse({ uri: targetUrl.toString() }); + } catch { + return jsonResponse({ uri: null }); + } finally { + clearTimeout(timeout); + } +} + function buildIconServiceBase(origin: string): string { return `${origin}/icons`; } @@ -284,6 +360,12 @@ export async function handlePublicRoute( return jsonResponse(await buildWebBootstrapResponse(env)); } + if (path === '/icons/change-password-uri' && method === 'GET') { + const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); + if (blocked) return blocked; + return handleChangePasswordUri(request); + } + const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); if (iconMatch && method === 'GET') { const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default'; diff --git a/src/services/storage-cipher-repo.ts b/src/services/storage-cipher-repo.ts index 6e86959..a9a6d80 100644 --- a/src/services/storage-cipher-repo.ts +++ b/src/services/storage-cipher-repo.ts @@ -39,6 +39,10 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([ 'favorite', 'reprompt', 'key', + 'attachments', + 'Attachments', + 'attachments2', + 'Attachments2', 'createdAt', 'created_at', 'creationDate', diff --git a/src/utils/response.ts b/src/utils/response.ts index 70ec46e..7906c52 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -38,11 +38,10 @@ function isWildcardCorsPath(path: string): boolean { function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } { const url = new URL(request.url); const origin = request.headers.get('Origin'); - if (isWildcardCorsPath(url.pathname)) { - return { allowOrigin: '*', allowCredentials: false }; - } if (!origin) { - return { allowOrigin: null, allowCredentials: false }; + return isWildcardCorsPath(url.pathname) + ? { allowOrigin: '*', allowCredentials: false } + : { allowOrigin: null, allowCredentials: false }; } if (origin === url.origin) { return { allowOrigin: origin, allowCredentials: true }; @@ -50,6 +49,9 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre if (isExtensionOrigin(origin)) { return { allowOrigin: origin, allowCredentials: true }; } + if (isWildcardCorsPath(url.pathname)) { + return { allowOrigin: '*', allowCredentials: false }; + } return { allowOrigin: null, allowCredentials: false }; }