fix: pin parent origin in iframe-bridge to block cross-frame postMessage
This commit is contained in:
@@ -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]);
|
||||
|
||||
+32
-9
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user