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