diff --git a/components/providers/embedded-bridge-provider.tsx b/components/providers/embedded-bridge-provider.tsx index 3cb6edc4..6a1d4060 100644 --- a/components/providers/embedded-bridge-provider.tsx +++ b/components/providers/embedded-bridge-provider.tsx @@ -12,6 +12,14 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode useEffect(() => { if (!embeddedMode || !isEmbedded()) return; + // Refuse to attach the listener without a pinned parent origin — + // otherwise any cross-origin frame could forge sso:trigger-logout. + if (!parentOrigin) { + console.error( + "[embedded-bridge] embeddedMode is enabled but parentOrigin is not configured; refusing to attach message listener", + ); + return; + } const unsubscribe = listenFromParent((msg) => { switch (msg.type) { @@ -26,7 +34,7 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode logout(); break; } - }, parentOrigin || undefined); + }, parentOrigin); return unsubscribe; }, [embeddedMode, parentOrigin, logout]); diff --git a/lib/iframe-bridge.ts b/lib/iframe-bridge.ts index fbe28070..431bf3dc 100644 --- a/lib/iframe-bridge.ts +++ b/lib/iframe-bridge.ts @@ -1,6 +1,17 @@ +function normalizeOrigin(value: string): string | null { + if (!value) return null; + try { + const u = new URL(value); + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + return u.origin; + } catch { + return null; + } +} + const PARENT_ORIGIN = typeof window !== 'undefined' - ? (document.querySelector('meta[name="parent-origin"]')?.getAttribute('content') || '') - : ''; + ? normalizeOrigin(document.querySelector('meta[name="parent-origin"]')?.getAttribute('content') || '') + : null; export function isEmbedded(): boolean { try { @@ -12,10 +23,13 @@ export function isEmbedded(): boolean { export function notifyParent(type: string, payload: Record = {}) { if (!isEmbedded()) return; + // Refuse to broadcast when the parent origin is unknown. A wildcard + // targetOrigin would leak the payload (e.g. username) to any frame + // the parent has open. + if (!PARENT_ORIGIN) return; - const targetOrigin = PARENT_ORIGIN || '*'; try { - window.parent.postMessage({ source: 'bulwark', type, ...payload }, targetOrigin); + window.parent.postMessage({ source: 'bulwark', type, ...payload }, PARENT_ORIGIN); } catch { // Cross-origin postMessage may fail in restricted contexts } @@ -23,13 +37,22 @@ export function notifyParent(type: string, payload: Record = {} export function listenFromParent( handler: (msg: { type: string; [k: string]: unknown }) => void, - allowedOrigin?: string, + allowedOrigin: string, ): () => void { - const listener = (event: MessageEvent) => { - // Validate origin if configured - if (allowedOrigin && event.origin !== allowedOrigin) return; + // Reject installation entirely when the caller cannot pin an origin. + // Without this gate any cross-origin frame could forge + // { source: 'portal', type: 'sso:trigger-logout' } and ride the session. + const normalized = normalizeOrigin(allowedOrigin); + if (!normalized) { + if (typeof console !== 'undefined') { + console.error('[iframe-bridge] listenFromParent requires a valid http(s) allowedOrigin; listener not installed'); + } + return () => {}; + } - // Only accept messages from the portal + const listener = (event: MessageEvent) => { + if (event.origin !== normalized) return; + if (event.source !== window.parent) return; if (!event.data || event.data.source !== 'portal') return; handler(event.data);