export interface BridgeConfig { trustedOrigins: string[]; serverBases: string[]; provider: string; version: string; } declare const PMA_BUILD_CONFIG: BridgeConfig | undefined; export const BRIDGE_CONFIG: BridgeConfig = typeof PMA_BUILD_CONFIG === 'undefined' ? { trustedOrigins: [ 'http://localhost:4200', 'http://127.0.0.1:4200', 'https://pma-rs.pages.dev', ], serverBases: [ 'https://vault.bitwarden.com', 'https://vault.bitwarden.eu', 'https://api.bitwarden.com', 'https://api.bitwarden.eu', 'https://identity.bitwarden.com', 'https://identity.bitwarden.eu', ], provider: 'PMA standalone demo', version: '0.0.4', } : PMA_BUILD_CONFIG; export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } export function isTrustedOrigin(origin: string): boolean { return BRIDGE_CONFIG.trustedOrigins.includes(origin); } export function isTrustedSender(sender: chrome.runtime.MessageSender): boolean { if ( sender.id !== chrome.runtime.id || sender.frameId !== 0 || sender.tab?.id === undefined || !sender.url ) return false; try { return isTrustedOrigin(new URL(sender.url).origin); } catch { return false; } } export function serverRoute(url: URL): string { if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw new Error('Target URL rejected'); for (const base of BRIDGE_CONFIG.serverBases) { const root = new URL(base); const prefix = root.pathname.replace(/\/$/, ''); if (url.origin === root.origin && url.pathname.startsWith(`${prefix}/`)) return url.pathname.slice(prefix.length); } throw new Error('Target server is not configured'); } export interface FetchRequest { url: string; options: RequestInit; } export function validateFetch(message: Record): FetchRequest { if (typeof message.url !== 'string' || message.url.length > 8192) throw new Error('Invalid request URL'); const url = new URL(message.url); const route = serverRoute(url); const options = message.options === undefined ? {} : message.options; if (!isRecord(options)) throw new Error('Invalid request options'); if (Object.keys(options).some((key) => !['method', 'body', 'headers'].includes(key))) throw new Error('Request option rejected'); const method = options.method ?? 'GET'; const getRoutes = [ '/api/sync', '/api/accounts/profile', '/api/folders', '/api/collections', '/sync', '/accounts/profile', '/folders', '/collections', ]; const postRoutes = [ '/identity/accounts/prelogin', '/identity/connect/token', '/accounts/prelogin', '/connect/token', ]; if ( !( method === 'GET' && (getRoutes.includes(route) || /^\/(?:api\/)?ciphers\/[\w-]+\/details$/.test(route)) ) && !(method === 'POST' && postRoutes.includes(route)) ) throw new Error('Request endpoint or method rejected'); if (url.search && !(route.endsWith('/sync') && url.search === '?excludeDomains=true')) throw new Error('Request query rejected'); if (method === 'GET' && options.body !== undefined) throw new Error('GET request body rejected'); if ( options.body !== undefined && (typeof options.body !== 'string' || options.body.length > 65536) ) throw new Error('Request body rejected'); const headers: Record = {}; if (options.headers !== undefined) { if (!isRecord(options.headers)) throw new Error('Request headers rejected'); for (const [name, value] of Object.entries(options.headers)) { const key = name.toLowerCase(); if ( ![ 'authorization', 'content-type', 'accept', 'device-type', 'bitwarden-client-name', 'bitwarden-client-version', 'auth-email', ].includes(key) || typeof value !== 'string' || value.length > 16384 || /[\r\n]/.test(value) ) throw new Error('Request header rejected'); if ( key === 'authorization' && (method !== 'GET' || !/^Bearer [A-Za-z0-9._~+\/-]+=*$/.test(value)) ) throw new Error('Authorization header rejected'); if ( key === 'content-type' && !['application/json', 'application/x-www-form-urlencoded'].includes(value) ) throw new Error('Content type rejected'); headers[key] = value; } } return { url: url.href, options: { method, headers, ...(options.body === undefined ? {} : { body: options.body as string }), credentials: 'omit', redirect: 'error', cache: 'no-store', referrerPolicy: 'no-referrer', }, }; } export interface SsoRequest { url: string; redirectPrefix: string; state: string; } export function validateSso(message: Record): SsoRequest { if ( typeof message.flowId !== 'string' || !/^[\w-]{1,128}$/.test(message.flowId) || typeof message.url !== 'string' || message.url.length > 8192 || typeof message.redirectPrefix !== 'string' || message.redirectPrefix.length > 256 ) throw new Error('Invalid SSO request'); const url = new URL(message.url); const hash = url.hash; url.hash = ''; const route = serverRoute(url); let params: URLSearchParams; if (route === '/' && hash.startsWith('#/sso?') && !url.search) params = new URLSearchParams(hash.slice(6)); else if (['/identity/connect/authorize', '/connect/authorize'].includes(route) && !hash) params = url.searchParams; else throw new Error('SSO entry point rejected'); // Reject duplicate parameters and ambiguous camel/snake-case aliases. const single = (...keys: string[]): string | undefined => { const values = keys.flatMap((key) => params.getAll(key)); if (values.length !== 1) throw new Error('SSO parameter rejected'); return values[0]; }; const redirect = new URL(message.redirectPrefix); if ( redirect.protocol !== 'http:' || !['localhost', '127.0.0.1'].includes(redirect.hostname) || redirect.port !== '8065' || redirect.pathname !== '/' || redirect.search || redirect.hash || redirect.username || redirect.password ) throw new Error('SSO redirect rejected'); const targetRedirect = single('redirectUri', 'redirect_uri'); if (!targetRedirect || new URL(targetRedirect).href !== redirect.href) throw new Error('SSO redirect mismatch'); const state = single('state'); const challenge = single('codeChallenge', 'code_challenge'); if ( !state || !/^[\w-]{16,256}$/.test(state) || !challenge || !/^[\w-]{43}$/.test(challenge) || single('clientId', 'client_id') !== 'cli' ) throw new Error('SSO state or PKCE rejected'); return { url: message.url, redirectPrefix: redirect.href, state }; } export function isMatchingRedirect(rawUrl: string, redirectPrefix: string, state: string): boolean { try { if (rawUrl.length > 8192) return false; const url = new URL(rawUrl); const expected = new URL(redirectPrefix); const returnedState = url.searchParams.get('state'); const stateParts = returnedState?.split('_identifier='); const matchingState = returnedState === state || (stateParts?.length === 2 && stateParts[0] === state && stateParts[1].length > 0); return ( url.origin === expected.origin && url.pathname === expected.pathname && !url.hash && !url.username && !url.password && url.searchParams.getAll('state').length === 1 && matchingState && ((url.searchParams.getAll('code').length === 1 && !!url.searchParams.get('code') && !url.searchParams.has('error')) || (url.searchParams.getAll('error').length === 1 && !!url.searchParams.get('error') && !url.searchParams.has('code'))) ); } catch { return false; } }