diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index de4cdae..caf6e15 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -234,7 +234,6 @@ export async function handlePublicDownloadAttachment( } const storage = new StorageService(env.DB); - // Verify attachment exists const attachment = await storage.getAttachment(attachmentId); @@ -250,6 +249,11 @@ export async function handlePublicDownloadAttachment( return errorResponse('Attachment file not found', 404); } + const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp); + if (!firstUse) { + return errorResponse('Invalid or expired token', 401); + } + return new Response(object.body, { headers: { 'Content-Type': 'application/octet-stream', diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 67ae181..0414c09 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -103,6 +103,7 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str return errorResponse('Folder not found', 404); } + await storage.clearFolderFromCiphers(userId, id); await storage.deleteFolder(id, userId); await storage.updateRevisionDate(userId); diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 23d2dc0..7633fa4 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -12,12 +12,15 @@ export async function handleToken(request: Request, env: Env): Promise let body: Record; const contentType = request.headers.get('content-type') || ''; - - if (contentType.includes('application/x-www-form-urlencoded')) { - const formData = await request.formData(); - body = Object.fromEntries(formData.entries()) as Record; - } else { - body = await request.json(); + try { + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await request.formData(); + body = Object.fromEntries(formData.entries()) as Record; + } else { + body = await request.json(); + } + } catch { + return identityErrorResponse('Invalid request payload', 'invalid_request', 400); } const grantType = body.grant_type; @@ -108,12 +111,12 @@ export async function handleToken(request: Request, env: Env): Promise // Refresh token const refreshToken = body.refresh_token; if (!refreshToken) { - return errorResponse('Refresh token is required', 400); + return identityErrorResponse('Refresh token is required', 'invalid_request', 400); } const result = await auth.refreshAccessToken(refreshToken); if (!result) { - return errorResponse('Invalid refresh token', 401); + return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); } // Revoke old refresh token (prevent reuse) @@ -158,7 +161,7 @@ export async function handleToken(request: Request, env: Env): Promise return jsonResponse(response); } - return errorResponse('Unsupported grant type', 400); + return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400); } // POST /identity/accounts/prelogin diff --git a/src/handlers/setupPage.ts b/src/handlers/setupPage.ts index e74176d..73d78b6 100644 --- a/src/handlers/setupPage.ts +++ b/src/handlers/setupPage.ts @@ -649,8 +649,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { creating: '正在创建…', doneTitle: '初始化完成', doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:', - important: '重要提示', - limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。', hideTitle: '隐藏初始化页', hideDesc: '隐藏后,初始化页对任何人都会返回 404。你的密码库仍可正常使用。', hideBtn: '隐藏初始化页', @@ -738,8 +736,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { creating: 'Creating…', doneTitle: 'Setup complete', doneDesc: 'Your server is ready. Use this URL in Bitwarden clients:', - important: 'Important', - limitations: 'Single user only: no additional users, no master password change. If forgotten, redeploy and register again.', hideTitle: 'Hide setup page', hideDesc: 'After hiding, this page returns 404 for everyone. Vault still works.', hideBtn: 'Hide setup page', @@ -843,8 +839,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { setText('submitBtn', t('create')); setText('t_done_title', t('doneTitle')); setText('t_done_desc', t('doneDesc')); - setText('t_important', t('important')); - setText('t_limitations', t('limitations')); setText('t_hide_title', t('hideTitle')); setText('t_hide_desc', t('hideDesc')); setText('hideBtn', t('hideBtn')); diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 50a8174..10fafdb 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -6,6 +6,9 @@ import { cipherToResponse } from './ciphers'; // GET /api/sync export async function handleSync(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); + const url = new URL(request.url); + const excludeDomainsParam = url.searchParams.get('excludeDomains'); + const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); const user = await storage.getUserById(userId); if (!user) { @@ -61,11 +64,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr folders: folderResponses, collections: [], ciphers: cipherResponses, - domains: { - equivalentDomains: [], - globalEquivalentDomains: [], - object: 'domains', - }, + domains: excludeDomains + ? null + : { + equivalentDomains: [], + globalEquivalentDomains: [], + object: 'domains', + }, policies: [], sends: [], // PascalCase for desktop/browser clients @@ -81,7 +86,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr }, MasterKeyEncryptedUserKey: user.key, MasterKeyWrappedUserKey: user.key, - Salt: user.email, + Salt: user.email.toLowerCase(), Object: 'masterPasswordUnlock', }, }, @@ -96,7 +101,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr }, masterKeyWrappedUserKey: user.key, masterKeyEncryptedUserKey: user.key, - salt: user.email, + salt: user.email.toLowerCase(), }, }, object: 'sync', diff --git a/src/router.ts b/src/router.ts index 659aa02..9ab3d06 100644 --- a/src/router.ts +++ b/src/router.ts @@ -49,6 +49,26 @@ import { handlePublicDownloadAttachment, } from './handlers/attachments'; +function isSameOriginWriteRequest(request: Request): boolean { + const targetOrigin = new URL(request.url).origin; + const origin = request.headers.get('Origin'); + if (origin) { + return origin === targetOrigin; + } + + const referer = request.headers.get('Referer'); + if (referer) { + try { + return new URL(referer).origin === targetOrigin; + } catch { + return false; + } + } + + // Require browser-origin evidence for setup/register write operations. + return false; +} + function getNwIconSvg(): string { return `NW`; } @@ -63,19 +83,54 @@ function handleNwFavicon(): Response { }); } +function isValidIconHostname(hostname: string): boolean { + if (!hostname) return false; + if (hostname.length > 253) return false; + + const normalized = hostname.toLowerCase(); + const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; + const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/; + + if (domainPattern.test(normalized)) return true; + if (!ipv4Pattern.test(normalized)) return false; + + const parts = normalized.split('.'); + return parts.every(p => { + const n = Number(p); + return Number.isInteger(n) && n >= 0 && n <= 255; + }); +} + // Icons handler - proxy to Bitwarden's official icon service async function handleGetIcon(request: Request, env: Env, hostname: string): Promise { try { + void env; + const normalizedHostname = hostname.toLowerCase(); + if (!isValidIconHostname(normalizedHostname)) { + return new Response(null, { status: 204 }); + } + + const cache = caches.default; + const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' }); + const cached = await cache.match(cacheKey); + if (cached) { + return cached; + } + // Use Bitwarden's official icon service - const iconUrl = `https://icons.bitwarden.net/${hostname}/icon.png`; + const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`; const resp = await fetch(iconUrl, { headers: { 'User-Agent': 'NodeWarden/1.0' }, redirect: 'follow', + cf: { + cacheEverything: true, + cacheTtl: 604800, + }, }); if (resp.ok) { const body = await resp.arrayBuffer(); - return new Response(body, { + const iconResponse = new Response(body, { status: 200, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'image/png', @@ -83,6 +138,8 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom 'Access-Control-Allow-Origin': '*', }, }); + await cache.put(cacheKey, iconResponse.clone()); + return iconResponse; } return new Response(null, { status: 204 }); @@ -116,6 +173,9 @@ export async function handleRequest(request: Request, env: Env): Promise { + // Atomically consume one write budget unit for the current fixed window. + // Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment. + async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { const nowSec = Math.floor(Date.now() / 1000); const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS; const row = await this.db - .prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?') - .bind(identifier, windowStart) + .prepare( + 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + + 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' + + 'WHERE api_rate_limits.count < ? ' + + 'RETURNING count' + ) + .bind(identifier, windowStart, CONFIG.API_WRITE_REQUESTS_PER_MINUTE) .first<{ count: number }>(); - const count = row?.count || 0; - if (count >= CONFIG.API_WRITE_REQUESTS_PER_MINUTE) { + // No returned row means conflict happened and WHERE prevented the increment: + // current count is already at/above the configured limit. + if (!row) { return { allowed: false, remaining: 0, @@ -132,24 +140,8 @@ export class RateLimitService { }; } - return { - allowed: true, - remaining: CONFIG.API_WRITE_REQUESTS_PER_MINUTE - count, - }; - } - - async incrementApiCount(identifier: string): Promise { - const nowSec = Math.floor(Date.now() / 1000); - const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); - - // Atomic increment via UPSERT. - await this.db - .prepare( - 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + - 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1' - ) - .bind(identifier, windowStart) - .run(); + const remaining = Math.max(0, CONFIG.API_WRITE_REQUESTS_PER_MINUTE - row.count); + return { allowed: true, remaining }; } } diff --git a/src/services/storage.ts b/src/services/storage.ts index a661bf9..ea27878 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -7,6 +7,8 @@ import { User, Cipher, Folder, Attachment } from '../types'; // - Revision date is maintained per user for Bitwarden sync. export class StorageService { + private static attachmentTokenTableReady = false; + constructor(private db: D1Database) {} /** @@ -395,6 +397,23 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run(); } + // Clear folder references from all ciphers owned by the user. + // Without this, deleting a folder leaves stale folderId values in cipher JSON. + async clearFolderFromCiphers(userId: string, folderId: string): Promise { + const now = new Date().toISOString(); + const res = await this.db + .prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?') + .bind(userId, folderId) + .all<{ data: string }>(); + + for (const row of (res.results || [])) { + const cipher = JSON.parse(row.data) as Cipher; + cipher.folderId = null; + cipher.updatedAt = now; + await this.saveCipher(cipher); + } + } + async getAllFolders(userId: string): Promise { const res = await this.db .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC') @@ -579,4 +598,37 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); .run(); return date; } + + // --- One-time attachment download tokens --- + + private async ensureUsedAttachmentDownloadTokenTable(): Promise { + if (StorageService.attachmentTokenTableReady) return; + + await this.db.prepare( + 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + + 'jti TEXT PRIMARY KEY, ' + + 'expires_at INTEGER NOT NULL' + + ')' + ).run(); + + StorageService.attachmentTokenTableReady = true; + } + + // Marks an attachment download token JTI as consumed. + // Returns true only on first use. Reuse returns false. + async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise { + await this.ensureUsedAttachmentDownloadTokenTable(); + + const nowMs = Date.now(); + // Best-effort cleanup of expired entries. + await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run(); + + const expiresAtMs = expUnixSeconds * 1000; + const result = await this.db.prepare( + 'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' + + 'ON CONFLICT(jti) DO NOTHING' + ).bind(jti, expiresAtMs).run(); + + return (result.meta.changes ?? 0) > 0; + } } diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index f1a399a..fc8f0b8 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -99,6 +99,7 @@ export function createRefreshToken(): string { export interface FileDownloadClaims { cipherId: string; attachmentId: string; + jti: string; exp: number; } @@ -114,6 +115,7 @@ export async function createFileDownloadToken( const payload: FileDownloadClaims = { cipherId, attachmentId, + jti: createRefreshToken(), exp: now + 300, // 5 minutes };