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:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user