From ace00e8e74a28cd2e8a0ecebffb3e8607220fbff Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 6 Jul 2026 17:43:57 +0800 Subject: [PATCH] Harden WebAuthn extension origins --- src/index.ts | 6 +- src/router-public.ts | 3 + src/router.ts | 2 +- src/utils/account-passkeys.ts | 17 +---- src/utils/origins.ts | 42 ++++++++++++ src/utils/response.ts | 36 +++++----- .../public/webauthn-fallback-connector.html | 66 ++++++++++++++----- webapp/src/lib/types.ts | 1 + 8 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 src/utils/origins.ts diff --git a/src/index.ts b/src/index.ts index 58bbd5c..cca7a00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,7 +89,7 @@ export default { const normalizedRequest = normalizeRequestUrl(request); const assetResponse = await maybeServeAsset(normalizedRequest, env); if (assetResponse) { - return applyCors(normalizedRequest, assetResponse); + return applyCors(normalizedRequest, assetResponse, env); } await ensureDatabaseInitialized(env); @@ -107,11 +107,11 @@ export default { }, 500 ); - return applyCors(normalizedRequest, resp); + return applyCors(normalizedRequest, resp, env); } const resp = await handleRequest(normalizedRequest, env); - return applyCors(normalizedRequest, resp); + return applyCors(normalizedRequest, resp, env); }, async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { diff --git a/src/router-public.ts b/src/router-public.ts index de13846..8b3cd9d 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -35,6 +35,7 @@ import { isSafeWebsiteIconContentType } from './utils/content-type'; import { jsonResponse, unsupportedResponse } from './utils/response'; import { StorageService } from './services/storage'; import type { Env } from './types'; +import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins'; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise; type JwtUnsafeReason = 'missing' | 'too_short' | null; @@ -44,6 +45,7 @@ export interface WebBootstrapResponse { jwtUnsafeReason: JwtUnsafeReason; jwtSecretMinLength: number; registrationInviteRequired: boolean; + webAuthnAllowedOrigins: string[]; } function isSameOriginWriteRequest(request: Request): boolean { @@ -322,6 +324,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise 0, + webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env), }; } diff --git a/src/router.ts b/src/router.ts index 93f9c75..a171427 100644 --- a/src/router.ts +++ b/src/router.ts @@ -148,7 +148,7 @@ export async function handleRequest(request: Request, env: Env): Promise origin.trim()) - .filter(Boolean); + const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env); const origins = new Set([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) }; } diff --git a/src/utils/origins.ts b/src/utils/origins.ts new file mode 100644 index 0000000..216b30e --- /dev/null +++ b/src/utils/origins.ts @@ -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 +): string[] { + const seen = new Set(); + 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, + origin: unknown +): boolean { + const normalized = normalizeOrigin(origin); + return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized); +} diff --git a/src/utils/response.ts b/src/utils/response.ts index 2deab3f..749a811 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -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 { +function buildCorsHeaders(request: Request, env: Env): Record { const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '') .split(',') .map((value) => value.trim()) @@ -72,7 +71,7 @@ function buildCorsHeaders(request: Request): Record { '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 { 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), }); } diff --git a/webapp/public/webauthn-fallback-connector.html b/webapp/public/webauthn-fallback-connector.html index 40e45e1..dc89cd2 100644 --- a/webapp/public/webauthn-fallback-connector.html +++ b/webapp/public/webauthn-fallback-connector.html @@ -170,6 +170,7 @@ (function () { var params = new URLSearchParams(window.location.search); var sentSuccess = false; + var allowedParentOriginsPromise = null; var text = pickText(params.get("locale") || navigator.language || "en"); document.documentElement.lang = params.get("locale") || navigator.language || "en"; @@ -227,24 +228,55 @@ return out; } - function trustedParentOrigin() { - var parent = decodeRepeated(params.get("parent")); - if (!parent) return ""; + function normalizeOrigin(value) { + if (!value) return ""; try { - var parentUrl = new URL(parent); - if ( - parentUrl.protocol === "chrome-extension:" || - parentUrl.protocol === "moz-extension:" || - parentUrl.protocol === "safari-web-extension:" - ) { - return parentUrl.protocol + "//" + parentUrl.host; - } - if (parentUrl.origin === window.location.origin) { - return parentUrl.origin; - } + var url = new URL(value); + if (!url.protocol || !url.host) return ""; + return url.protocol + "//" + url.host; } catch (_error) { return ""; } + } + + function isExtensionOrigin(origin) { + return ( + origin.indexOf("chrome-extension://") === 0 || + origin.indexOf("moz-extension://") === 0 || + origin.indexOf("safari-web-extension://") === 0 + ); + } + + function allowedParentOrigins() { + if (allowedParentOriginsPromise) return allowedParentOriginsPromise; + allowedParentOriginsPromise = fetch("/api/web-bootstrap", { + headers: { Accept: "application/json" }, + credentials: "omit", + }).then(function (response) { + if (!response.ok) return []; + return response.json(); + }).then(function (body) { + var origins = Array.isArray(body && body.webAuthnAllowedOrigins) + ? body.webAuthnAllowedOrigins + : []; + return origins.map(normalizeOrigin).filter(Boolean); + }).catch(function () { + return []; + }); + return allowedParentOriginsPromise; + } + + function trustedParentOrigin(allowedOrigins) { + var parent = decodeRepeated(params.get("parent")); + if (!parent) return ""; + var parentOrigin = normalizeOrigin(parent); + if (!parentOrigin) return ""; + if (parentOrigin === window.location.origin) { + return parentOrigin; + } + if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) { + return parentOrigin; + } return ""; } @@ -258,8 +290,8 @@ return copy; } - function postResult(message) { - var parentOrigin = trustedParentOrigin(); + async function postResult(message) { + var parentOrigin = trustedParentOrigin(await allowedParentOrigins()); if (parentOrigin) { if (window.opener && !window.opener.closed) { window.opener.postMessage(message, parentOrigin); @@ -371,7 +403,7 @@ if (!(credential instanceof PublicKeyCredential)) { throw new Error("No security key was selected."); } - postResult({ + await postResult({ command: "webAuthnResult", data: credentialToDataString(credential), remember: rememberEl.checked, diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 063f5e9..bdb26b7 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -411,6 +411,7 @@ export interface WebBootstrapResponse { jwtUnsafeReason?: 'missing' | 'too_short' | null; jwtSecretMinLength?: number; registrationInviteRequired?: boolean; + webAuthnAllowedOrigins?: string[]; } export interface YubiKeyOtpSettings {