mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
fix(security): harden jwt config and password rotation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user