fix: address security issue

This commit is contained in:
shuaiplus
2026-06-23 17:48:41 +08:00
committed by Shuai
parent 5048cc0720
commit 7279668955
24 changed files with 613 additions and 114 deletions
+26 -8
View File
@@ -353,9 +353,15 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is required', 403);
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try {
await storage.createUser(user);
} catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409);
@@ -363,12 +369,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
throw error;
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
await storage.deleteUserById(user.id);
return errorResponse('Invite code is invalid or expired', 403);
}
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.register.invite',
@@ -891,7 +891,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
}
// PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" }
// enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -899,7 +899,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
let body: {
enabled?: boolean;
secret?: string;
token?: string;
masterPasswordHash?: string;
userVerificationToken?: string;
};
try {
body = await request.json();
} catch {
@@ -908,12 +914,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400);
}
if (!body.token) {
return errorResponse('TOTP token is required', 400);
}
let verifiedUser = false;
if (userVerificationToken) {
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
}
if (!verifiedUser && masterPasswordHash) {
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
}
if (!verifiedUser) {
return errorResponse('User verification failed.', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) {
return errorResponse('Invalid TOTP token', 400);
+2 -1
View File
@@ -4,6 +4,7 @@ import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import {
createAttachmentUploadToken,
createFileDownloadToken,
@@ -449,7 +450,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, {
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache',
+80 -12
View File
@@ -40,15 +40,51 @@ import {
uploadBackupArchive,
} from '../services/backup-uploader';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function requireBackupRepairVerification(
actorUser: User,
body: { masterPasswordHash?: string; userVerificationToken?: string },
env: Env
): Promise<Response | null> {
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
if (masterPasswordHash) {
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
}
const userVerificationToken = String(body.userVerificationToken || '').trim();
if (!userVerificationToken) {
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
}
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
if (!valid) {
return errorResponse('Invalid user verification token', 400);
}
return null;
}
async function writeAuditLog(
storage: StorageService,
actorUserId: string | null,
@@ -787,13 +823,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput;
let body: BackupSettingsInput & { masterPasswordHash?: string };
try {
body = await request.json<BackupSettingsInput>();
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
} catch {
return errorResponse('Backup settings payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
let previous;
try {
@@ -837,13 +876,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput;
let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
try {
body = await request.json<BackupSettingsInput>();
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
} catch {
return errorResponse('Backup settings repair payload is invalid', 400);
}
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
let previous;
try {
@@ -871,15 +913,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try {
let body: { destinationId?: string } | null = null;
let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>();
body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
}
} catch {
return errorResponse('Backup run payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const outcome = await runConfiguredBackupInDurableObject(env, {
actorUserId: actorUser.id,
auditMetadata: auditRequestMetadata(request),
@@ -928,12 +973,21 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup download payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
const remoteFile = await downloadRemoteBackupFile(destination, path);
return new Response(remoteFile.bytes, {
status: 200,
@@ -994,13 +1048,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
let body: {
destinationId?: string;
path?: string;
replaceExisting?: boolean;
allowChecksumMismatch?: boolean;
masterPasswordHash?: string;
};
try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch {
return errorResponse('Remote restore payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
try {
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
@@ -1028,14 +1091,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
const storage = new StorageService(env.DB);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null;
let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>();
body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
}
} catch {
return errorResponse('Backup export payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
let archive: BackupArchiveBundle;
try {
const progress = async (event: {
@@ -1140,6 +1205,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
return errorResponse('Backup file is required', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError;
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
let archiveBytes: Uint8Array;
+4
View File
@@ -21,6 +21,7 @@ import {
buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -583,6 +584,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
@@ -621,6 +623,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserVerificationToken: userVerificationToken,
userVerificationToken,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
+2 -1
View File
@@ -2,6 +2,7 @@ import { Env, SendType } from '../types';
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,
@@ -306,7 +307,7 @@ export async function handleDownloadSendFile(
return new Response(object.body, {
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache',
+1 -1
View File
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
return handleListAdminRemoteBackups(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
if (path === '/api/admin/backup/remote/download' && method === 'POST') {
return handleDownloadAdminRemoteBackup(request, env, actorUser);
}
+3 -1
View File
@@ -27,6 +27,7 @@ import {
handleNotificationsNegotiate,
} from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage';
import type { Env } from './types';
@@ -241,6 +242,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
headers: {
'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
},
});
}
@@ -272,7 +274,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue;
if (!isSafeWebsiteIconContentType(contentType)) continue;
const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
+11
View File
@@ -127,6 +127,17 @@ export async function markInviteUsed(db: D1Database, code: string, userId: strin
return (result.meta.changes ?? 0) > 0;
}
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by = ?"
)
.bind(now, code, userId)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
+5
View File
@@ -30,6 +30,7 @@ import {
markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revertInviteUsed as revertStoredInviteUsed,
revokeInvite as revokeStoredInvite,
} from './storage-admin-repo';
import {
@@ -313,6 +314,10 @@ export class StorageService {
return markStoredInviteUsed(this.db, code, userId);
}
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
return revertStoredInviteUsed(this.db, code, userId);
}
async revokeInvite(code: string): Promise<boolean> {
return revokeStoredInvite(this.db, code);
}
+2
View File
@@ -466,6 +466,8 @@ export interface TokenResponse {
ResetMasterPassword: boolean;
scope: string;
unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: {
minComplexity: number;
minLength: number;
+38
View File
@@ -0,0 +1,38 @@
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
'application/xhtml+xml',
'application/xml',
'image/svg+xml',
'text/html',
'text/xml',
]);
const SAFE_ICON_MEDIA_TYPES = new Set([
'image/avif',
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/vnd.microsoft.icon',
'image/webp',
'image/x-icon',
]);
function normalizeMediaType(contentType: string | null | undefined): string {
return String(contentType || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
}
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
}
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
const mediaType = normalizeMediaType(contentType);
if (!mediaType) return 'application/octet-stream';
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
return 'application/octet-stream';
}
return contentType || mediaType;
}
+3 -1
View File
@@ -100,7 +100,9 @@ export function applyCors(
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
if (!headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
+89
View File
@@ -0,0 +1,89 @@
import type { Env } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
export type UserVerificationPurpose = 'backup.settings.repair';
interface UserVerificationTokenPayload {
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
userId: string;
method: 'passkey';
purpose: UserVerificationPurpose;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function createPasskeyUserVerificationToken(
env: Env,
userId: string,
purpose: UserVerificationPurpose
): Promise<string> {
const now = Date.now();
const payload: UserVerificationTokenPayload = {
typ: USER_VERIFICATION_TOKEN_TYPE,
userId,
method: 'passkey',
purpose,
iat: now,
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyPasskeyUserVerificationToken(
env: Env,
token: string,
userId: string,
purpose: UserVerificationPurpose
): Promise<boolean> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return false;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return false;
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
return true;
} catch {
return false;
}
}