Harden WebAuthn extension origins

This commit is contained in:
shuaiplus
2026-07-06 17:43:57 +08:00
parent 51428461a8
commit ace00e8e74
8 changed files with 119 additions and 54 deletions
+2 -15
View File
@@ -12,6 +12,7 @@ import type {
WebAuthnPrfDecryptionOption,
} from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
@@ -159,22 +160,8 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
const origins = new Set<string>([url.origin, ...configuredOrigins]);
const requestOrigin = request.headers.get('Origin');
if (
requestOrigin
&& (
requestOrigin.startsWith('chrome-extension://')
|| requestOrigin.startsWith('moz-extension://')
|| requestOrigin.startsWith('safari-web-extension://')
)
) {
origins.add(requestOrigin);
}
return { rpId, rpName, origins: Array.from(origins) };
}
+42
View File
@@ -0,0 +1,42 @@
import type { Env } from '../types';
export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (!url.protocol || !url.host) return null;
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
export function isBrowserExtensionOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && (
normalized.startsWith('chrome-extension://')
|| normalized.startsWith('moz-extension://')
|| normalized.startsWith('safari-web-extension://')
);
}
export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] {
const seen = new Set<string>();
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item);
if (origin) seen.add(origin);
}
return Array.from(seen);
}
export function isConfiguredWebAuthnAllowedOrigin(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
origin: unknown
): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
}
+18 -18
View File
@@ -1,4 +1,10 @@
import { LIMITS } from '../config/limits';
import type { Env } from '../types';
import {
isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin,
normalizeOrigin,
} from './origins';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const DEFAULT_CORS_HEADERS = [
@@ -18,14 +24,6 @@ const DEFAULT_CORS_HEADERS = [
'X-NodeWarden-Web-Session',
];
function isExtensionOrigin(origin: string): boolean {
return (
origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://')
);
}
function isWildcardCorsPath(path: string): boolean {
return (
path.startsWith('/icons/')
@@ -38,18 +36,19 @@ function isWildcardCorsPath(path: string): boolean {
);
}
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url);
const origin = request.headers.get('Origin');
if (!origin) {
const originHeader = request.headers.get('Origin');
if (!originHeader) {
return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false };
}
const origin = normalizeOrigin(originHeader);
if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isExtensionOrigin(origin)) {
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isWildcardCorsPath(url.pathname)) {
@@ -58,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
return { allowOrigin: null, allowCredentials: false };
}
function buildCorsHeaders(request: Request): Record<string, string> {
function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',')
.map((value) => value.trim())
@@ -72,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
};
const corsPolicy = getCorsPolicy(request);
const corsPolicy = getCorsPolicy(request, env);
if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) {
@@ -86,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
export function applyCors(
request: Request,
response: Response
response: Response,
env: Env
): Response {
// WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
@@ -95,7 +95,7 @@ export function applyCors(
}
const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request);
const corsHeaders = buildCorsHeaders(request, env);
for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v);
}
@@ -159,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
}
// Handle CORS preflight
export function handleCors(request: Request): Response {
export function handleCors(request: Request, env: Env): Response {
return new Response(null, {
status: 204,
headers: buildCorsHeaders(request),
headers: buildCorsHeaders(request, env),
});
}