fix: enhance attachment handling and folder deletion logic; improve error responses and rate limiting

This commit is contained in:
shuaiplus
2026-02-18 03:06:50 +08:00
parent 73db6c518b
commit e1f1c6f865
9 changed files with 166 additions and 52 deletions
+5 -1
View File
@@ -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',
+1
View File
@@ -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);
+12 -9
View File
@@ -12,12 +12,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
let body: Record<string, string>;
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<string, string>;
} 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<string, string>;
} 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<Response>
// 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<Response>
return jsonResponse(response);
}
return errorResponse('Unsupported grant type', 400);
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
}
// POST /identity/accounts/prelogin
-6
View File
@@ -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'));
+12 -7
View File
@@ -6,6 +6,9 @@ import { cipherToResponse } from './ciphers';
// GET /api/sync
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
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',
+66 -5
View File
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
}
@@ -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<Response> {
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<Respons
// Disable setup page (one-way)
if (path === '/setup/disable' && method === 'POST') {
if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403);
}
return handleDisableSetup(request, env);
}
@@ -222,6 +282,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Registration endpoint (no auth required, but only works once)
if (path === '/api/accounts/register' && method === 'POST') {
if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403);
}
return handleRegister(request, env);
}
@@ -247,7 +310,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (isWriteMethod) {
const rateLimit = new RateLimitService(env.DB);
const clientId = getClientIdentifier(request);
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId + ':write');
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
@@ -262,8 +325,6 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
},
});
}
await rateLimit.incrementApiCount(userId + ':' + clientId + ':write');
}
// Block account operations that could change password or delete user
+16 -24
View File
@@ -6,7 +6,7 @@
// Rate limit configuration
const CONFIG = {
// Friendly default: short cooldown instead of long lockouts.
LOGIN_MAX_ATTEMPTS: 8,
LOGIN_MAX_ATTEMPTS: 5,
LOGIN_LOCKOUT_MINUTES: 2,
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
@@ -113,18 +113,26 @@ export class RateLimitService {
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
}
async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
// 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<void> {
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 };
}
}
+52
View File
@@ -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<void> {
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<Folder[]> {
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<void> {
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<boolean> {
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;
}
}
+2
View File
@@ -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
};