feat(pagination): add pagination utility functions for handling page size and continuation tokens

- Introduced `PaginationRequest` interface to define pagination parameters.
- Implemented `parsePagination` function to extract and validate pagination parameters from a URL.
- Added `encodeContinuationToken` and `decodeContinuationToken` functions for managing continuation tokens.
- Ensured that pagination respects maximum page size limits defined in configuration.
This commit is contained in:
shuaiplus
2026-02-18 20:59:46 +08:00
parent c53819e178
commit b6d4113e21
17 changed files with 668 additions and 232 deletions
+7 -2
View File
@@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
-- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY,
CREATE TABLE IF NOT EXISTS login_attempts_ip (
ip TEXT PRIMARY KEY,
attempts INTEGER NOT NULL,
locked_until INTEGER,
updated_at INTEGER NOT NULL
@@ -92,3 +92,8 @@ CREATE TABLE IF NOT EXISTS api_rate_limits (
PRIMARY KEY (identifier, window_start)
);
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
jti TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
+104
View File
@@ -0,0 +1,104 @@
export const LIMITS = {
auth: {
// Access token lifetime in seconds.
// 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
// Refresh token random byte length.
// 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32,
// Attachment download token lifetime in seconds.
// 附件下载令牌有效期(秒)。
fileDownloadTokenTtlSeconds: 300,
// Minimum required JWT secret length.
// JWT 密钥最小长度要求。
jwtSecretMinLength: 32,
// Default PBKDF2 iterations for account creation/prelogin fallback.
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
defaultKdfIterations: 600000,
},
rateLimit: {
// Max failed login attempts before temporary lock.
// 触发临时锁定前允许的最大登录失败次数。
loginMaxAttempts: 5,
// Login lock duration in minutes.
// 登录锁定时长(分钟)。
loginLockoutMinutes: 2,
// Write API request budget per minute.
// 写操作 API 每分钟请求配额。
apiWriteRequestsPerMinute: 120,
// /api/sync read request budget per minute.
// /api/sync 读请求每分钟配额。
syncReadRequestsPerMinute: 1000,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
// Probability to run low-frequency cleanup on request path.
// 在请求路径中触发低频清理的概率。
cleanupProbability: 0.05,
// Minimum interval between login-attempt cleanup runs.
// 登录尝试表清理的最小间隔。
loginIpCleanupIntervalMs: 10 * 60 * 1000,
// Minimum interval between API-window cleanup runs.
// API 窗口计数清理的最小间隔。
apiWindowCleanupIntervalMs: 5 * 60 * 1000,
// Retention window for login IP records.
// 登录 IP 记录保留时长。
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
// Number of historical API windows to keep.
// 保留的历史 API 窗口数量。
apiWindowRetentionWindows: 120,
},
cleanup: {
// Minimum interval between refresh-token cleanup runs.
// refresh_token 表清理最小间隔。
refreshTokenCleanupIntervalMs: 30 * 60 * 1000,
// Minimum interval between used attachment token cleanup runs.
// 已使用附件令牌表清理最小间隔。
attachmentTokenCleanupIntervalMs: 10 * 60 * 1000,
// Probability to trigger cleanup during requests.
// 请求过程中触发清理的概率。
cleanupProbability: 0.05,
},
attachment: {
// Max attachment upload size in bytes.
// 附件上传大小上限(字节)。
maxFileSizeBytes: 100 * 1024 * 1024,
},
pagination: {
// Default page size when client does not specify pageSize.
// 客户端未传 pageSize 时的默认分页大小。
defaultPageSize: 100,
// Hard maximum page size accepted by server.
// 服务端允许的最大分页大小。
maxPageSize: 500,
},
cors: {
// Browser preflight cache max age in seconds.
// 浏览器预检请求缓存时长(秒)。
preflightMaxAgeSeconds: 86400,
},
cache: {
// Icon proxy cache TTL in seconds.
// 图标代理缓存时长(秒)。
iconTtlSeconds: 604800,
// In-memory /api/sync response cache TTL (milliseconds).
// /api/sync 内存缓存有效期(毫秒)。
syncResponseTtlMs: 30 * 1000,
// Max in-memory /api/sync cache entries per isolate.
// 每个 isolate 的 /api/sync 最大缓存条目数。
syncResponseMaxEntries: 64,
},
performance: {
// Max IDs per SQL batch when moving ciphers in bulk.
// 批量移动密码项时每批 SQL 的最大 ID 数量。
bulkMoveChunkSize: 200,
},
compatibility: {
// Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2025.12.0',
},
} as const;
+25 -2
View File
@@ -3,12 +3,23 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
function looksLikeEncString(value: string): boolean {
if (!value) return false;
const firstDot = value.indexOf('.');
if (firstDot <= 0 || firstDot === value.length - 1) return false;
const payload = value.slice(firstDot + 1);
const parts = payload.split('|');
// Bitwarden encrypted payloads should have at least IV + ciphertext.
return parts.length >= 2;
}
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < 32) return 'too_short';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
@@ -63,6 +74,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (!privateKey || !publicKey) {
return errorResponse('Private key and public key are required', 400);
}
if (!looksLikeEncString(key)) {
return errorResponse('key is not a valid encrypted string', 400);
}
if (!looksLikeEncString(privateKey)) {
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
}
// Create user
const user: User = {
@@ -74,7 +91,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
privateKey: privateKey,
publicKey: publicKey,
kdfType: body.kdf ?? 0,
kdfIterations: body.kdfIterations ?? 600000,
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
kdfMemory: body.kdfMemory,
kdfParallelism: body.kdfParallelism,
securityStamp: generateUUID(),
@@ -178,6 +195,12 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
if (body.key) user.key = body.key;
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
if (body.publicKey) user.publicKey = body.publicKey;
if (body.key && !looksLikeEncString(body.key)) {
return errorResponse('key is not a valid encrypted string', 400);
}
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
+3 -3
View File
@@ -4,6 +4,7 @@ import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
import { cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits';
// Format file size to human readable
function formatSize(bytes: number): string {
@@ -86,7 +87,7 @@ export async function handleCreateAttachment(
}
// Maximum file size: 100MB
const MAX_FILE_SIZE = 100 * 1024 * 1024;
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
// Upload attachment file content
@@ -211,7 +212,7 @@ export async function handlePublicDownloadAttachment(
attachmentId: string
): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < 32 || secret === DEFAULT_DEV_SECRET) {
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return errorResponse('Server configuration error', 500);
}
@@ -259,7 +260,6 @@ export async function handlePublicDownloadAttachment(
'Content-Type': 'application/octet-stream',
'Content-Length': String(object.size),
'Cache-Control': 'private, no-cache',
'Access-Control-Allow-Origin': '*',
},
});
}
+22 -8
View File
@@ -3,6 +3,7 @@ import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
// Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null {
@@ -53,15 +54,28 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
// GET /api/ciphers
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const ciphers = await storage.getAllCiphers(userId);
// Filter out soft-deleted ciphers unless specifically requested
const url = new URL(request.url);
const includeDeleted = url.searchParams.get('deleted') === 'true';
const filteredCiphers = includeDeleted
? ciphers
: ciphers.filter(c => !c.deletedAt);
const pagination = parsePagination(url);
let filteredCiphers: Cipher[];
let continuationToken: string | null = null;
if (pagination) {
const pageRows = await storage.getCiphersPage(
userId,
includeDeleted,
pagination.limit + 1,
pagination.offset
);
const hasNext = pageRows.length > pagination.limit;
filteredCiphers = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + filteredCiphers.length) : null;
} else {
const ciphers = await storage.getAllCiphers(userId);
filteredCiphers = includeDeleted
? ciphers
: ciphers.filter(c => !c.deletedAt);
}
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id));
@@ -75,7 +89,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
return jsonResponse({
data: cipherResponses,
object: 'list',
continuationToken: null,
continuationToken: continuationToken,
});
}
+15 -2
View File
@@ -2,6 +2,7 @@ import { Env, Folder, FolderResponse } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
// Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse {
@@ -16,12 +17,24 @@ function folderToResponse(folder: Folder): FolderResponse {
// GET /api/folders
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const folders = await storage.getAllFolders(userId);
const url = new URL(request.url);
const pagination = parsePagination(url);
let folders: Folder[];
let continuationToken: string | null = null;
if (pagination) {
const pageRows = await storage.getFoldersPage(userId, pagination.limit + 1, pagination.offset);
const hasNext = pageRows.length > pagination.limit;
folders = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + folders.length) : null;
} else {
folders = await storage.getAllFolders(userId);
}
return jsonResponse({
data: folders.map(folderToResponse),
object: 'list',
continuationToken: null,
continuationToken: continuationToken,
});
}
+4 -3
View File
@@ -3,6 +3,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits';
// POST /identity/connect/token
export async function handleToken(request: Request, env: Env): Promise<Response> {
@@ -74,7 +75,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const response: TokenResponse = {
access_token: accessToken,
expires_in: 7200,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
refresh_token: refreshToken,
Key: user.key,
@@ -127,7 +128,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const response: TokenResponse = {
access_token: accessToken,
expires_in: 7200,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
refresh_token: newRefreshToken,
Key: user.key,
@@ -184,7 +185,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
const kdfType = user?.kdfType ?? 0;
const kdfIterations = user?.kdfIterations ?? 600000;
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
const kdfMemory = user?.kdfMemory;
const kdfParallelism = user?.kdfParallelism;
+13 -3
View File
@@ -1,7 +1,8 @@
import { Env, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { handleRegisterPage } from './setupPage';
import { jsonResponse, errorResponse, htmlResponse } from '../utils/response';
import { renderRegisterPageHTML } from '../setup/pageTemplate';
import { LIMITS } from '../config/limits';
type JwtSecretState = 'missing' | 'default' | 'too_short';
@@ -10,10 +11,19 @@ function getJwtSecretState(env: Env): JwtSecretState | null {
if (!secret) return 'missing';
// Block common "forgot to change" sample value (matches .dev.vars.example)
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < 32) return 'too_short';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(renderRegisterPageHTML(jwtState));
}
// GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
+51 -2
View File
@@ -1,7 +1,40 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits';
interface SyncCacheEntry {
body: string;
expiresAt: number;
}
const syncResponseCache = new Map<string, SyncCacheEntry>();
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
}
function readSyncCache(key: string): string | null {
const hit = syncResponseCache.get(key);
if (!hit) return null;
if (hit.expiresAt <= Date.now()) {
syncResponseCache.delete(key);
return null;
}
return hit.body;
}
function writeSyncCache(key: string, body: string): void {
if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) {
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
if (oldestKey) syncResponseCache.delete(oldestKey);
}
syncResponseCache.set(key, {
body,
expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs,
});
}
// GET /api/sync
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
@@ -15,6 +48,16 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
return errorResponse('User not found', 404);
}
const revisionDate = await storage.getRevisionDate(userId);
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
const cachedBody = readSyncCache(cacheKey);
if (cachedBody) {
return new Response(cachedBody, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id));
@@ -107,5 +150,11 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
object: 'sync',
};
return jsonResponse(syncResponse);
const body = JSON.stringify(syncResponse);
writeSyncCache(cacheKey, body);
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
+23 -5
View File
@@ -1,11 +1,12 @@
import { Env } from './types';
import { handleRequest } from './router';
import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
// Per-isolate flag. Each Worker isolate may have its own copy of this flag,
// but initializeDatabase() is idempotent (uses CREATE TABLE IF NOT EXISTS),
// so redundant calls are harmless and fast (single SELECT check).
// Per-isolate flags. Each Worker isolate may have its own copy of these flags.
// initializeDatabase() only validates schema presence, so retries are cheap.
let dbInitialized = false;
let dbInitError: string | null = null;
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
@@ -15,12 +16,29 @@ export default {
const storage = new StorageService(env.DB);
await storage.initializeDatabase();
dbInitialized = true;
dbInitError = null;
} catch (error) {
console.error('Failed to initialize database:', error);
// Continue anyway - the error will surface when actual DB operations are attempted
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
}
}
return handleRequest(request, env);
if (dbInitError) {
const resp = jsonResponse(
{
error: 'Database not initialized',
error_description: dbInitError,
ErrorModel: {
Message: dbInitError,
Object: 'error',
},
},
500
);
return applyCors(request, resp);
}
const resp = await handleRequest(request, env);
return applyCors(request, resp);
},
};
+34 -11
View File
@@ -2,6 +2,7 @@ import { Env, DEFAULT_DEV_SECRET } from './types';
import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse, jsonResponse } from './utils/response';
import { LIMITS } from './config/limits';
// Identity handlers
import { handleToken, handlePrelogin } from './handlers/identity';
@@ -78,7 +79,7 @@ function handleNwFavicon(): Response {
status: 200,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'public, max-age=604800',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
},
});
}
@@ -87,8 +88,11 @@ 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 normalized = hostname.toLowerCase().replace(/\.$/, '');
// Slightly relaxed domain validation:
// - keep strict label boundaries (no leading/trailing hyphen)
// - allow punycode TLD (e.g. xn--...)
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
if (domainPattern.test(normalized)) return true;
@@ -124,7 +128,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom
redirect: 'follow',
cf: {
cacheEverything: true,
cacheTtl: 604800,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
});
@@ -134,8 +138,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': 'public, max-age=604800', // 7 days
'Access-Control-Allow-Origin': '*',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days
},
});
await cache.put(cacheKey, iconResponse.clone());
@@ -155,7 +158,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Handle CORS preflight
if (method === 'OPTIONS') {
return handleCors();
return handleCors(request);
}
// Route matching
@@ -252,7 +255,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
// When Vaultwarden bumps their version, update this value accordingly.
// Vaultwarden source: src/api/core/mod.rs → fn config()
version: '2025.12.0',
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
@@ -277,7 +280,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Version endpoint (some clients probe this to validate the server)
if (path === '/api/version' && method === 'GET') {
return jsonResponse('2025.12.0'); // Keep in sync with config.version above
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
}
// Registration endpoint (no auth required, but only works once)
@@ -290,7 +293,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// If JWT_SECRET is not safely configured, block any other endpoints.
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < 32 || secret === DEFAULT_DEV_SECRET) {
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
@@ -304,12 +307,32 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
}
const userId = payload.sub;
const clientId = getClientIdentifier(request);
// Dedicated read rate limiting for heavy sync endpoint.
if (path === '/api/sync' && method === 'GET') {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
}
}
// API rate limiting only for write operations (keep reads frictionless)
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
if (isWriteMethod) {
const rateLimit = new RateLimitService(env.DB);
const clientId = getClientIdentifier(request);
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
if (!rateLimitCheck.allowed) {
+97 -18
View File
@@ -1,3 +1,5 @@
import { LIMITS } from '../config/limits';
// D1-backed rate limiting.
// Notes:
// - Login attempts are tracked per client IP.
@@ -6,19 +8,60 @@
// Rate limit configuration
const CONFIG = {
// Friendly default: short cooldown instead of long lockouts.
LOGIN_MAX_ATTEMPTS: 5,
LOGIN_LOCKOUT_MINUTES: 2,
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
API_WRITE_REQUESTS_PER_MINUTE: 120,
API_WINDOW_SECONDS: 60,
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
// Dedicated budget for GET /api/sync reads.
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};
export class RateLimitService {
private static loginIpTableReady = false;
private static lastLoginIpCleanupAt = 0;
private static lastApiWindowCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
constructor(private db: D1Database) {}
private shouldRunCleanup(lastRunAt: number, intervalMs: number): boolean {
const now = Date.now();
if (now - lastRunAt < intervalMs) return false;
return Math.random() < RateLimitService.PERIODIC_CLEANUP_PROBABILITY;
}
private async maybeCleanupLoginAttemptsIp(nowMs: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastLoginIpCleanupAt, RateLimitService.LOGIN_IP_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = nowMs - RateLimitService.LOGIN_IP_RETENTION_MS;
await this.db
.prepare(
'DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)'
)
.bind(cutoff, nowMs)
.run();
RateLimitService.lastLoginIpCleanupAt = nowMs;
}
private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS);
await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run();
RateLimitService.lastApiWindowCleanupAt = Date.now();
}
private async ensureLoginIpTable(): Promise<void> {
if (RateLimitService.loginIpTableReady) return;
@@ -45,6 +88,7 @@ export class RateLimitService {
const key = ip.trim() || 'unknown';
const now = Date.now();
await this.maybeCleanupLoginAttemptsIp(now);
const row = await this.db
.prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
@@ -77,10 +121,11 @@ export class RateLimitService {
const key = ip.trim() || 'unknown';
const now = Date.now();
await this.maybeCleanupLoginAttemptsIp(now);
// D1 in Workers forbids raw BEGIN/COMMIT statements.
// Use a single atomic UPSERT to increment attempts.
// This is concurrency-safe because the row is keyed by email.
// This is concurrency-safe because the row is keyed by IP.
await this.db
.prepare(
'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
@@ -113,26 +158,30 @@ export class RateLimitService {
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
}
// Atomically consume one write budget unit for the current fixed window.
// Atomically consume one 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 }> {
private async consumeFixedWindowBudget(
identifier: string,
maxRequests: number,
windowSeconds: number
): 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 windowStart = nowSec - (nowSec % windowSeconds);
const windowEnd = windowStart + windowSeconds;
await this.maybeCleanupApiWindows(windowStart, windowSeconds);
const row = await this.db
const writeResult = 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 ' +
'WHERE api_rate_limits.count < ? ' +
'RETURNING count'
'WHERE api_rate_limits.count < ?'
)
.bind(identifier, windowStart, CONFIG.API_WRITE_REQUESTS_PER_MINUTE)
.first<{ count: number }>();
.bind(identifier, windowStart, maxRequests)
.run();
// No returned row means conflict happened and WHERE prevented the increment:
// current count is already at/above the configured limit.
if (!row) {
// No changed row means conflict happened and WHERE prevented increment:
// current count is already at/above configured limit.
if ((writeResult.meta.changes ?? 0) === 0) {
return {
allowed: false,
remaining: 0,
@@ -140,9 +189,39 @@ export class RateLimitService {
};
}
const remaining = Math.max(0, CONFIG.API_WRITE_REQUESTS_PER_MINUTE - row.count);
const row = await this.db
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
.bind(identifier, windowStart)
.first<{ count: number }>();
if (!row) {
return {
allowed: true,
remaining: 0,
};
}
const remaining = Math.max(0, maxRequests - row.count);
return { allowed: true, remaining };
}
// Write budget for POST/PUT/DELETE/PATCH requests.
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
}
// Read budget for GET /api/sync.
async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.SYNC_READ_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
}
}
export function getClientIdentifier(request: Request): string {
+154 -141
View File
@@ -1,4 +1,5 @@
import { User, Cipher, Folder, Attachment } from '../types';
import { LIMITS } from '../config/limits';
// D1-backed storage.
// Contract:
@@ -8,13 +9,20 @@ import { User, Cipher, Folder, Attachment } from '../types';
export class StorageService {
private static attachmentTokenTableReady = false;
private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
constructor(private db: D1Database) {}
/**
* D1 .bind() throws on `undefined` values. This helper converts every
* `undefined` in the argument list to `null` so we never hit that runtime
* error especially important after the opaque-passthrough change where
* error - especially important after the opaque-passthrough change where
* client-supplied JSON may omit fields we later reference as columns.
*/
private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement {
@@ -32,133 +40,83 @@ export class StorageService {
return `sha256:${digest}`;
}
private shouldRunPeriodicCleanup(lastRunAt: number, intervalMs: number): boolean {
const now = Date.now();
if (now - lastRunAt < intervalMs) return false;
return Math.random() < StorageService.PERIODIC_CLEANUP_PROBABILITY;
}
private async maybeCleanupExpiredRefreshTokens(nowMs: number): Promise<void> {
if (!this.shouldRunPeriodicCleanup(StorageService.lastRefreshTokenCleanupAt, StorageService.REFRESH_TOKEN_CLEANUP_INTERVAL_MS)) {
return;
}
await this.db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').bind(nowMs).run();
StorageService.lastRefreshTokenCleanupAt = nowMs;
}
// --- Database initialization ---
// Idempotent auto-init for environments where D1 migrations have not been applied
// (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql —
// keep both in sync when changing the schema.
// One-click deploy requires zero manual migration steps.
// This method idempotently creates required schema objects on first request.
async initializeDatabase(): Promise<void> {
// Check if database is already initialized by looking for the config table
try {
const result = await this.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='config'")
.first<{ name: string }>();
if (result?.name === 'config') {
// Database already initialized
return;
}
} catch (e) {
// If error occurs, assume database needs initialization
console.log('Initializing database...');
if (StorageService.schemaVerified) return;
const schemaStatements = [
'PRAGMA foreign_keys = ON',
'CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
'CREATE TABLE IF NOT EXISTS users (' +
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE TABLE IF NOT EXISTS ciphers (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
'CREATE TABLE IF NOT EXISTS folders (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
'CREATE TABLE IF NOT EXISTS attachments (' +
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
'size_name TEXT NOT NULL, key TEXT, ' +
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
'PRIMARY KEY (identifier, window_start))',
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
];
for (const stmt of schemaStatements) {
await this.db.prepare(stmt).run();
}
// Execute initialization SQL
const initSQL = `
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT,
master_password_hash TEXT NOT NULL,
key TEXT NOT NULL,
private_key TEXT,
public_key TEXT,
kdf_type INTEGER NOT NULL,
kdf_iterations INTEGER NOT NULL,
kdf_memory INTEGER,
kdf_parallelism INTEGER,
security_stamp TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_revisions (
user_id TEXT PRIMARY KEY,
revision_date TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ciphers (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type INTEGER NOT NULL,
folder_id TEXT,
name TEXT,
notes TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL,
reprompt INTEGER,
key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
cipher_id TEXT NOT NULL,
file_name TEXT NOT NULL,
size INTEGER NOT NULL,
size_name TEXT NOT NULL,
key TEXT,
FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY,
attempts INTEGER NOT NULL,
locked_until INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS api_rate_limits (
identifier TEXT NOT NULL,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (identifier, window_start)
);
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
`.trim();
// Split by semicolon and execute each statement
const statements = initSQL.split(';').filter(s => s.trim().length > 0);
for (const stmt of statements) {
if (stmt.trim()) {
await this.db.prepare(stmt).run();
}
}
console.log('Database initialized successfully');
StorageService.schemaVerified = true;
}
// --- Config / setup ---
@@ -335,6 +293,21 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
}
async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise<Cipher[]> {
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
const res = await this.db
.prepare(
`SELECT data FROM ciphers
WHERE user_id = ?
${whereDeleted}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?`
)
.bind(userId, limit, offset)
.all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
}
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
if (ids.length === 0) return [];
// D1 doesn't support binding arrays directly; build placeholders.
@@ -347,20 +320,25 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
if (ids.length === 0) return;
const now = new Date().toISOString();
const uniqueIds = Array.from(new Set(ids));
const patch = JSON.stringify({
folderId,
updatedAt: now,
});
const chunkSize = LIMITS.performance.bulkMoveChunkSize;
// D1 forbids raw BEGIN/COMMIT statements in this runtime.
// For this endpoint, we accept per-row updates and then bump revision once.
// Concurrency: each cipher write is an UPSERT on its PK, no shared index.
for (const id of ids) {
const row = await this.db
.prepare('SELECT data FROM ciphers WHERE id = ? AND user_id = ?')
.bind(id, userId)
.first<{ data: string }>();
if (!row?.data) continue;
const cipher = JSON.parse(row.data) as Cipher;
cipher.folderId = folderId;
cipher.updatedAt = now;
await this.saveCipher(cipher);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await this.db
.prepare(
`UPDATE ciphers
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(folderId, now, patch, userId, ...chunk)
.run();
}
await this.updateRevisionDate(userId);
@@ -428,6 +406,22 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
}));
}
async getFoldersPage(userId: string, limit: number, offset: number): 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 LIMIT ? OFFSET ?'
)
.bind(userId, limit, offset)
.all<any>();
return (res.results || []).map(r => ({
id: r.id,
userId: r.user_id,
name: r.name,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}
// --- Attachments ---
async getAttachment(id: string): Promise<Attachment | null> {
@@ -531,7 +525,8 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
// --- Refresh tokens ---
async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000);
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
await this.maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await this.refreshTokenKey(token);
await this.db.prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' +
@@ -543,6 +538,7 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
async getRefreshTokenUserId(token: string): Promise<string | null> {
const now = Date.now();
await this.maybeCleanupExpiredRefreshTokens(now);
const tokenKey = await this.refreshTokenKey(token);
let row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
@@ -585,7 +581,17 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
.bind(userId)
.first<{ revision_date: string }>();
return row?.revision_date || new Date().toISOString();
if (row?.revision_date) return row.revision_date;
const date = new Date().toISOString();
await this.db
.prepare(
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
'ON CONFLICT(user_id) DO NOTHING'
)
.bind(userId, date)
.run();
return date;
}
async updateRevisionDate(userId: string): Promise<string> {
@@ -620,8 +626,15 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
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();
if (
this.shouldRunPeriodicCleanup(
StorageService.lastAttachmentTokenCleanupAt,
StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS
)
) {
await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run();
StorageService.lastAttachmentTokenCleanupAt = nowMs;
}
const expiresAtMs = expUnixSeconds * 1000;
const result = await this.db.prepare(
@@ -1,11 +1,10 @@
import { Env } from '../types';
import { StorageService } from '../services/storage';
import { htmlResponse } from '../utils/response';
import { LIMITS } from '../config/limits';
type JwtSecretState = 'missing' | 'default' | 'too_short';
export type JwtSecretState = 'missing' | 'default' | 'too_short';
function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {
export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {
const jwtStateJson = JSON.stringify(jwtState);
const defaultKdfIterations = LIMITS.auth.defaultKdfIterations;
return `<!DOCTYPE html>
<html lang="en">
@@ -1117,7 +1116,7 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {
}
try {
const iterations = 600000;
const iterations = ${defaultKdfIterations};
const masterKey = await pbkdf2(password, email, iterations, 32);
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
@@ -1212,12 +1211,3 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {
</body>
</html>`;
}
export async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(renderRegisterPageHTML(jwtState));
}
+4 -3
View File
@@ -1,4 +1,5 @@
import { JWTPayload } from '../types';
import { LIMITS } from '../config/limits';
// Base64 URL encode
function base64UrlEncode(data: Uint8Array): string {
@@ -19,7 +20,7 @@ function base64UrlDecode(str: string): Uint8Array {
}
// Create JWT
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = 7200): Promise<string> {
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
@@ -90,7 +91,7 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
// Create refresh token (simple random string)
export function createRefreshToken(): string {
const bytes = new Uint8Array(32);
const bytes = new Uint8Array(LIMITS.auth.refreshTokenRandomBytes);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
@@ -116,7 +117,7 @@ export async function createFileDownloadToken(
cipherId,
attachmentId,
jti: createRefreshToken(),
exp: now + 300, // 5 minutes
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds, // 5 minutes
};
const encoder = new TextEncoder();
+38
View File
@@ -0,0 +1,38 @@
import { LIMITS } from '../config/limits';
const MAX_PAGE_SIZE = LIMITS.pagination.maxPageSize;
export interface PaginationRequest {
limit: number;
offset: number;
}
export function parsePagination(url: URL): PaginationRequest | null {
const pageSizeRaw = url.searchParams.get('pageSize');
const continuationToken = url.searchParams.get('continuationToken');
if (!pageSizeRaw && !continuationToken) return null;
const pageSize = pageSizeRaw ? Number(pageSizeRaw) : LIMITS.pagination.defaultPageSize;
if (!Number.isInteger(pageSize) || pageSize <= 0) return null;
const limit = Math.min(pageSize, MAX_PAGE_SIZE);
const offset = decodeContinuationToken(continuationToken);
return { limit, offset };
}
export function encodeContinuationToken(offset: number): string {
return btoa(String(offset));
}
export function decodeContinuationToken(token: string | null): number {
if (!token) return 0;
try {
const decoded = atob(token);
const offset = Number(decoded);
if (!Number.isInteger(offset) || offset < 0) return 0;
return offset;
} catch {
return 0;
}
}
+69 -14
View File
@@ -1,10 +1,68 @@
import { LIMITS } from '../config/limits';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version';
function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins.
if (origin === 'null') return true;
if (origin.startsWith('chrome-extension://')) return true;
if (origin.startsWith('moz-extension://')) return true;
if (origin.startsWith('safari-web-extension://')) return true;
if (origin.startsWith('app://')) return true;
if (origin.startsWith('capacitor://')) return true;
if (origin.startsWith('ionic://')) return true;
return false;
}
function getAllowedOrigin(request: Request): string | null {
const origin = request.headers.get('Origin');
if (!origin) return null;
const targetOrigin = new URL(request.url).origin;
if (origin === targetOrigin) return origin;
if (isTrustedClientOrigin(origin)) return origin;
return null;
}
function buildCorsHeaders(request: Request): Record<string, string> {
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': CORS_METHODS,
'Access-Control-Allow-Headers': CORS_HEADERS,
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
};
const allowedOrigin = getAllowedOrigin(request);
if (allowedOrigin) {
headers['Access-Control-Allow-Origin'] = allowedOrigin;
headers['Vary'] = 'Origin';
}
return headers;
}
export function applyCors(
request: Request,
response: Response
): Response {
const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request);
for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
// JSON response helper
export function jsonResponse(data: any, status: number = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...getCorsHeaders(),
...headers,
},
});
@@ -40,21 +98,19 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
);
}
// CORS headers
export function getCorsHeaders(): Record<string, string> {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version',
'Access-Control-Max-Age': '86400',
};
}
// Handle CORS preflight
export function handleCors(): Response {
export function handleCors(request: Request): Response {
const origin = request.headers.get('Origin');
if (origin) {
const allowedOrigin = getAllowedOrigin(request);
if (!allowedOrigin) {
return new Response(null, { status: 403 });
}
}
return new Response(null, {
status: 204,
headers: getCorsHeaders(),
headers: buildCorsHeaders(request),
});
}
@@ -64,7 +120,6 @@ export function htmlResponse(html: string, status: number = 200): Response {
status,
headers: {
'Content-Type': 'text/html; charset=utf-8',
...getCorsHeaders(),
},
});
}