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
-5
View File
@@ -1,5 +0,0 @@
# JWT Secret for signing tokens (required)
# IMPORTANT: change this value before any real deployment.
# Generate one with: openssl rand -hex 32
# (Example only, 64 hex chars = 32 bytes)
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
+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 { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -150,10 +150,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
return normalized ? normalized : null; 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(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
@@ -242,9 +241,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (unsafe) { if (unsafe) {
const message = unsafe === 'missing' const message = unsafe === 'missing'
? 'JWT_SECRET is not set' ? 'JWT_SECRET is not set'
: unsafe === 'default' : 'JWT_SECRET must be at least 32 characters';
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400); 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 { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
@@ -405,10 +405,8 @@ export async function handlePublicDownloadAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const secret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret) return errorResponse('Server configuration error', 500);
return errorResponse('Server configuration error', 500);
}
const url = new URL(request.url); const url = new URL(request.url);
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
@@ -418,7 +416,7 @@ export async function handlePublicDownloadAttachment(
} }
// Verify token // Verify token
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET); const claims = await verifyFileDownloadToken(token, secret);
if (!claims) { if (!claims) {
return errorResponse('Invalid or expired token', 401); 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 { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type'; import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits';
import { import {
createSendAccessToken, createSendAccessToken,
createSendFileDownloadToken, createSendFileDownloadToken,
@@ -113,10 +112,9 @@ export async function handleAccessSendFile(
idOrAccessId: string, idOrAccessId: string,
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const safeSecret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) { if (!safeSecret.ok) return safeSecret.response;
return errorResponse('Server configuration error', 500); const { secret } = safeSecret;
}
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId); 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 { import {
notifyUserSendCreate, notifyUserSendCreate,
notifyUserSendDelete, notifyUserSendDelete,
@@ -371,7 +371,7 @@ export function hasEmailAuth(send: Send): boolean {
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } { export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
const secret = (env.JWT_SECRET || '').trim(); 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: false, response: errorResponse('Server configuration error', 500) };
} }
return { ok: true, secret }; return { ok: true, secret };
+2 -5
View File
@@ -1,5 +1,4 @@
import { LIMITS } from './config/limits'; import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import { import {
handleAccessSend, handleAccessSend,
handleAccessSendFile, handleAccessSendFile,
@@ -34,7 +33,7 @@ import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; 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 { export interface WebBootstrapResponse {
defaultKdfIterations: number; defaultKdfIterations: number;
@@ -308,9 +307,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
const jwtUnsafeReason = const jwtUnsafeReason =
!secret !secret
? 'missing' ? 'missing'
: secret === DEFAULT_DEV_SECRET : secret.length < LIMITS.auth.jwtSecretMinLength
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short' ? 'too_short'
: null; : null;
const storage = new StorageService(env.DB); 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 { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse } from './utils/response'; import { handleCors, errorResponse } from './utils/response';
@@ -6,14 +6,24 @@ import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated'; import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public'; 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(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; 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 { function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false; 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); 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); 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 auth = new AuthService(env);
const authHeader = request.headers.get('Authorization'); const authHeader = request.headers.get('Authorization');
const verified = await auth.verifyAccessTokenWithUser(authHeader); const verified = await auth.verifyAccessTokenWithUser(authHeader);
-4
View File
@@ -19,10 +19,6 @@ export interface Env {
export type UserRole = 'admin' | 'user'; export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned'; 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 // Attachment model
export interface Attachment { export interface Attachment {
id: string; id: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { DEFAULT_DEV_SECRET, Env } from '../types'; import { Env } from '../types';
import { errorResponse } from './response'; import { errorResponse } from './response';
export interface DirectUploadPayload { export interface DirectUploadPayload {
@@ -28,7 +28,7 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
export function getSafeJwtSecret(env: Env): string | null { export function getSafeJwtSecret(env: Env): string | null {
const secret = (env.JWT_SECRET || '').trim(); 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 null;
} }
return secret; return secret;
+2 -4
View File
@@ -5,7 +5,7 @@ import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface JwtWarningPageProps { interface JwtWarningPageProps {
reason: 'missing' | 'default' | 'too_short'; reason: 'missing' | 'too_short';
minLength: number; minLength: number;
} }
@@ -21,9 +21,7 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
const title = const title =
props.reason === 'missing' props.reason === 'missing'
? t('txt_jwt_title_missing') ? t('txt_jwt_title_missing')
: props.reason === 'default' : t('txt_jwt_title_too_short');
? t('txt_jwt_title_default')
: t('txt_jwt_title_too_short');
const isMissing = props.reason === 'missing'; const isMissing = props.reason === 'missing';
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace'); const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
+4 -1
View File
@@ -591,11 +591,14 @@ export async function changeMasterPassword(
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32); const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32); const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac); const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
if (userSym.length !== 64) {
throw new Error('Invalid profile key');
}
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32); const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32); const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac); const newKey = await encryptBw(userSym, nextEnc, nextMac);
const newMasterPasswordHash = bytesToBase64(nextHash); const newMasterPasswordHash = bytesToBase64(nextHash);
const resp = await authedFetch('/api/accounts/password', { const resp = await authedFetch('/api/accounts/password', {
+1 -1
View File
@@ -42,7 +42,7 @@ export interface PendingPasskeyPassword {
kdfIterations: number; kdfIterations: number;
} }
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; export type JwtUnsafeReason = 'missing' | 'too_short';
export interface BootstrapAppResult { export interface BootstrapAppResult {
defaultKdfIterations: number; defaultKdfIterations: number;
+1 -1
View File
@@ -669,7 +669,7 @@ const en: Record<string, string> = {
"txt_jwt_secret_value_label": "Value:", "txt_jwt_secret_value_label": "Value:",
"txt_jwt_secret_value_requirement": "Random string with at least {min} characters", "txt_jwt_secret_value_requirement": "Random string with at least {min} characters",
"txt_jwt_what_is": "What is JWT?", "txt_jwt_what_is": "What is JWT?",
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing, too short, or still using the sample value, the instance is not safe to use normally.", "txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing or too short, the instance is not safe to use normally.",
"txt_how_to_fix": "How to fix", "txt_how_to_fix": "How to fix",
"txt_jwt_fix_step_1": "Open your deployment environment variables.", "txt_jwt_fix_step_1": "Open your deployment environment variables.",
"txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.", "txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.",
+1 -1
View File
@@ -669,7 +669,7 @@ const es: Record<string, string> = {
"txt_jwt_secret_value_label": "Valor:", "txt_jwt_secret_value_label": "Valor:",
"txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres", "txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres",
"txt_jwt_what_is": "Qué es JWT", "txt_jwt_what_is": "Qué es JWT",
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente, es demasiado corta o todavía usa el valor de ejemplo, la instancia no es segura para uso normal.", "txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente o es demasiado corta, la instancia no es segura para uso normal.",
"txt_how_to_fix": "Cómo corregirlo", "txt_how_to_fix": "Cómo corregirlo",
"txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.", "txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.",
"txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.", "txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.",
+1 -1
View File
@@ -669,7 +669,7 @@ const ru: Record<string, string> = {
"txt_jwt_secret_value_label": "Значение:", "txt_jwt_secret_value_label": "Значение:",
"txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.", "txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.",
"txt_jwt_what_is": "Что такое JWT?", "txt_jwt_what_is": "Что такое JWT?",
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует, слишком короткий или все еще использует образец значения, обычное использование экземпляра небезопасно.", "txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует или слишком короткий, обычное использование экземпляра небезопасно.",
"txt_how_to_fix": "Как исправить", "txt_how_to_fix": "Как исправить",
"txt_jwt_fix_step_1": "Откройте переменные среды развертывания.", "txt_jwt_fix_step_1": "Откройте переменные среды развертывания.",
"txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.", "txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.",
+1 -1
View File
@@ -669,7 +669,7 @@ const zhCN: Record<string, string> = {
"txt_jwt_secret_value_label": "值:", "txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位随机字符", "txt_jwt_secret_value_requirement": "最低 {min} 位随机字符",
"txt_jwt_what_is": "JWT 是什么", "txt_jwt_what_is": "JWT 是什么",
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失过短,或者仍然使用示例值,实例就不能安全地正常使用。", "txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失过短,实例就不能安全地正常使用。",
"txt_how_to_fix": "处理步骤(添加 / 更换)", "txt_how_to_fix": "处理步骤(添加 / 更换)",
"txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。", "txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。",
"txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。", "txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。",
+1 -1
View File
@@ -669,7 +669,7 @@ const zhTW: Record<string, string> = {
"txt_jwt_secret_value_label": "值:", "txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符", "txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符",
"txt_jwt_what_is": "JWT 是什麼", "txt_jwt_what_is": "JWT 是什麼",
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失過短,或者仍然使用示例值,實例就不能安全地正常使用。", "txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失過短,實例就不能安全地正常使用。",
"txt_how_to_fix": "處理步驟(添加 / 更換)", "txt_how_to_fix": "處理步驟(添加 / 更換)",
"txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。", "txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。",
"txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。", "txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。",
+1 -1
View File
@@ -290,7 +290,7 @@ export interface ListResponse<T> {
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
defaultKdfIterations?: number; defaultKdfIterations?: number;
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null; jwtUnsafeReason?: 'missing' | 'too_short' | null;
jwtSecretMinLength?: number; jwtSecretMinLength?: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
} }