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