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
+3 -3
View File
@@ -89,7 +89,7 @@ export default {
const normalizedRequest = normalizeRequestUrl(request); const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env); const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) { if (assetResponse) {
return applyCors(normalizedRequest, assetResponse); return applyCors(normalizedRequest, assetResponse, env);
} }
await ensureDatabaseInitialized(env); await ensureDatabaseInitialized(env);
@@ -107,11 +107,11 @@ export default {
}, },
500 500
); );
return applyCors(normalizedRequest, resp); return applyCors(normalizedRequest, resp, env);
} }
const resp = await handleRequest(normalizedRequest, 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<void> { async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
+3
View File
@@ -35,6 +35,7 @@ import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse, unsupportedResponse } from './utils/response'; import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
@@ -44,6 +45,7 @@ export interface WebBootstrapResponse {
jwtUnsafeReason: JwtUnsafeReason; jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number; jwtSecretMinLength: number;
registrationInviteRequired: boolean; registrationInviteRequired: boolean;
webAuthnAllowedOrigins: string[];
} }
function isSameOriginWriteRequest(request: Request): boolean { function isSameOriginWriteRequest(request: Request): boolean {
@@ -322,6 +324,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
jwtUnsafeReason, jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength, jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0, registrationInviteRequired: userCount > 0,
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
}; };
} }
+1 -1
View File
@@ -148,7 +148,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
if (method === 'OPTIONS') { if (method === 'OPTIONS') {
return handleCors(request); return handleCors(request, env);
} }
try { try {
+2 -15
View File
@@ -12,6 +12,7 @@ import type {
WebAuthnPrfDecryptionOption, WebAuthnPrfDecryptionOption,
} from '../types'; } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey'; import { base64UrlToBytes, bytesToBase64Url } from './passkey';
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1'; const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000; 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 configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname; const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME; const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '') const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const origins = new Set<string>([url.origin, ...configuredOrigins]); 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) }; 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 { 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 CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const DEFAULT_CORS_HEADERS = [ const DEFAULT_CORS_HEADERS = [
@@ -18,14 +24,6 @@ const DEFAULT_CORS_HEADERS = [
'X-NodeWarden-Web-Session', '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 { function isWildcardCorsPath(path: string): boolean {
return ( return (
path.startsWith('/icons/') 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 url = new URL(request.url);
const origin = request.headers.get('Origin'); const originHeader = request.headers.get('Origin');
if (!origin) { if (!originHeader) {
return isWildcardCorsPath(url.pathname) return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false } ? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false }; : { allowOrigin: null, allowCredentials: false };
} }
const origin = normalizeOrigin(originHeader);
if (origin === url.origin) { if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isExtensionOrigin(origin)) { if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isWildcardCorsPath(url.pathname)) { if (isWildcardCorsPath(url.pathname)) {
@@ -58,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
return { allowOrigin: null, allowCredentials: false }; 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') || '') const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',') .split(',')
.map((value) => value.trim()) .map((value) => value.trim())
@@ -72,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds), 'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
}; };
const corsPolicy = getCorsPolicy(request); const corsPolicy = getCorsPolicy(request, env);
if (corsPolicy.allowOrigin) { if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin; headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) { if (corsPolicy.allowCredentials) {
@@ -86,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
export function applyCors( export function applyCors(
request: Request, request: Request,
response: Response response: Response,
env: Env
): Response { ): Response {
// WebSocket upgrade responses must be returned untouched. // WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket; const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
@@ -95,7 +95,7 @@ export function applyCors(
} }
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request); const corsHeaders = buildCorsHeaders(request, env);
for (const [k, v] of Object.entries(corsHeaders)) { for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v); headers.set(k, v);
} }
@@ -159,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
} }
// Handle CORS preflight // Handle CORS preflight
export function handleCors(request: Request): Response { export function handleCors(request: Request, env: Env): Response {
return new Response(null, { return new Response(null, {
status: 204, status: 204,
headers: buildCorsHeaders(request), headers: buildCorsHeaders(request, env),
}); });
} }
+49 -17
View File
@@ -170,6 +170,7 @@
(function () { (function () {
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
var sentSuccess = false; var sentSuccess = false;
var allowedParentOriginsPromise = null;
var text = pickText(params.get("locale") || navigator.language || "en"); var text = pickText(params.get("locale") || navigator.language || "en");
document.documentElement.lang = params.get("locale") || navigator.language || "en"; document.documentElement.lang = params.get("locale") || navigator.language || "en";
@@ -227,24 +228,55 @@
return out; return out;
} }
function trustedParentOrigin() { function normalizeOrigin(value) {
var parent = decodeRepeated(params.get("parent")); if (!value) return "";
if (!parent) return "";
try { try {
var parentUrl = new URL(parent); var url = new URL(value);
if ( if (!url.protocol || !url.host) return "";
parentUrl.protocol === "chrome-extension:" || return url.protocol + "//" + url.host;
parentUrl.protocol === "moz-extension:" ||
parentUrl.protocol === "safari-web-extension:"
) {
return parentUrl.protocol + "//" + parentUrl.host;
}
if (parentUrl.origin === window.location.origin) {
return parentUrl.origin;
}
} catch (_error) { } catch (_error) {
return ""; 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 ""; return "";
} }
@@ -258,8 +290,8 @@
return copy; return copy;
} }
function postResult(message) { async function postResult(message) {
var parentOrigin = trustedParentOrigin(); var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
if (parentOrigin) { if (parentOrigin) {
if (window.opener && !window.opener.closed) { if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, parentOrigin); window.opener.postMessage(message, parentOrigin);
@@ -371,7 +403,7 @@
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected."); throw new Error("No security key was selected.");
} }
postResult({ await postResult({
command: "webAuthnResult", command: "webAuthnResult",
data: credentialToDataString(credential), data: credentialToDataString(credential),
remember: rememberEl.checked, remember: rememberEl.checked,
+1
View File
@@ -411,6 +411,7 @@ export interface WebBootstrapResponse {
jwtUnsafeReason?: 'missing' | 'too_short' | null; jwtUnsafeReason?: 'missing' | 'too_short' | null;
jwtSecretMinLength?: number; jwtSecretMinLength?: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
webAuthnAllowedOrigins?: string[];
} }
export interface YubiKeyOtpSettings { export interface YubiKeyOtpSettings {