fix(security): harden jwt config and password rotation

This commit is contained in:
shuaiplus
2026-07-02 17:20:51 +08:00
parent 439683d350
commit 60dd298dee
18 changed files with 46 additions and 54 deletions
+3 -6
View File
@@ -1,4 +1,4 @@
import { Env, User, DEFAULT_DEV_SECRET } from '../types';
import { Env, User } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -150,10 +150,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
return normalized ? normalized : null;
}
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
@@ -242,9 +241,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (unsafe) {
const message = unsafe === 'missing'
? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters';
: 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400);
}
+4 -6
View File
@@ -1,4 +1,4 @@
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types';
import { Env, Attachment, Cipher } from '../types';
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
@@ -405,10 +405,8 @@ export async function handlePublicDownloadAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return errorResponse('Server configuration error', 500);
}
const secret = getSafeJwtSecret(env);
if (!secret) return errorResponse('Server configuration error', 500);
const url = new URL(request.url);
const token = url.searchParams.get('token');
@@ -418,7 +416,7 @@ export async function handlePublicDownloadAttachment(
}
// Verify token
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
const claims = await verifyFileDownloadToken(token, secret);
if (!claims) {
return errorResponse('Invalid or expired token', 401);
}
+3 -5
View File
@@ -3,7 +3,6 @@ import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits';
import {
createSendAccessToken,
createSendFileDownloadToken,
@@ -113,10 +112,9 @@ export async function handleAccessSendFile(
idOrAccessId: string,
fileId: string
): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return errorResponse('Server configuration error', 500);
}
const safeSecret = getSafeJwtSecret(env);
if (!safeSecret.ok) return safeSecret.response;
const { secret } = safeSecret;
const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
+2 -2
View File
@@ -1,4 +1,4 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
import {
notifyUserSendCreate,
notifyUserSendDelete,
@@ -371,7 +371,7 @@ export function hasEmailAuth(send: Send): boolean {
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return { ok: false, response: errorResponse('Server configuration error', 500) };
}
return { ok: true, secret };
+2 -5
View File
@@ -1,5 +1,4 @@
import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import {
handleAccessSend,
handleAccessSendFile,
@@ -34,7 +33,7 @@ import { StorageService } from './services/storage';
import type { Env } from './types';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null;
type JwtUnsafeReason = 'missing' | 'too_short' | null;
export interface WebBootstrapResponse {
defaultKdfIterations: number;
@@ -308,9 +307,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
const jwtUnsafeReason =
!secret
? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength
: secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short'
: null;
const storage = new StorageService(env.DB);
+17 -7
View File
@@ -1,4 +1,4 @@
import { DEFAULT_DEV_SECRET, Env } from './types';
import { Env } from './types';
import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse } from './utils/response';
@@ -6,14 +6,24 @@ import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
if (method === 'OPTIONS') return true;
if (method === 'GET' && (path === '/api/web-bootstrap' || path === '/web-bootstrap')) return true;
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
return false;
}
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
@@ -85,14 +95,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
}
}
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue) {
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization');
const verified = await auth.verifyAccessTokenWithUser(authHeader);
-4
View File
@@ -19,10 +19,6 @@ export interface Env {
export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned';
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
// Attachment model
export interface Attachment {
id: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import { LIMITS } from '../config/limits';
import { DEFAULT_DEV_SECRET, Env } from '../types';
import { Env } from '../types';
import { errorResponse } from './response';
export interface DirectUploadPayload {
@@ -28,7 +28,7 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
export function getSafeJwtSecret(env: Env): string | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return null;
}
return secret;