-
-
Notifications
You must be signed in to change notification settings - Fork 628
Expand file tree
/
Copy pathembedHost.ts
More file actions
142 lines (136 loc) · 6.58 KB
/
Copy pathembedHost.ts
File metadata and controls
142 lines (136 loc) · 6.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
// Shared embed-host plumbing for the postMessage bridges that connect the app to
// an embedding host (the GeoLibre Python widget / `to_html()` export). Both the
// project-state bridge (useEmbedBridge) and the scripting command bridge
// (useCommandBridge) talk to the SAME host window and must apply the SAME trust
// rules, so the detection and origin handshake live here once.
import { EMBED_ORIGIN_WILDCARD, isEmbedOriginAllowed, readEmbedOrigins } from "../lib/embed-api";
/**
* Detects whether the app is running inside the GeoLibre Jupyter/embed host.
*
* The app is considered embedded when it is framed (a different `window.parent`)
* or when it is opened with an explicit `?embed=1` query parameter, which lets
* the host force the bridge on for a standalone `to_html()` export.
*
* Trust model: auto-detection trusts only a SAME-ORIGIN framing parent — it
* confirms same-origin by reading `window.parent.location.href`, which throws
* for a cross-origin parent (merely comparing `window.parent !== window` does
* not throw cross-origin, so it can't be used for this). A random cross-origin
* page that iframes a deployed app therefore never auto-activates the bridge.
* The explicit `?embed=1` opt-in, however, trusts whatever the framing parent
* is — the bridge broadcasts project state to it, redacted unless that host
* asks for full fidelity with `trustedWidget` (see `useEmbedBridge.ts`, where
* that flag is documented as self-declared and therefore not a boundary of its
* own). Because the legitimate hosts (the Jupyter
* widget, Colab's proxy) have arbitrary, unknowable origins, an origin allowlist
* is not viable here; instead the deployment constraint is: an `?embed=1`
* export must only be served from a trusted context, never a public URL. A
* deployment that *can* name its hosts (a web build framed by a portal) should
* set `GEOLIBRE_EMBED_ORIGINS`, which both enables the embed API
* (`hooks/useEmbedApi.ts`) and narrows these bridges to those origins.
*
* @returns True when the postMessage bridges should be active.
*/
export function isEmbedded(): boolean {
if (typeof window === "undefined") return false;
// An explicit opt-in always activates the bridge — this is how the Jupyter
// widget and `to_html()` exports run (they load the app with `?embed=1`).
const embed = new URLSearchParams(window.location.search).get("embed");
if (embed === "1" || embed === "true") return true;
if (!window.parent || window.parent === window) return false;
try {
// `window.parent !== window` does NOT throw cross-origin, so it can't tell a
// same-origin host from a third-party framer on its own. Probe a restricted
// property (location.href): readable only for a SAME-ORIGIN parent, throws
// for cross-origin. So a same-origin framing host auto-activates the bridge,
// while a cross-origin page that iframes a deployed app does not (it would
// need the explicit `?embed=1` opt-in, which must only be served from a
// trusted context — see below).
void window.parent.location.href;
return true;
} catch {
return false;
}
}
/**
* Per-bridge handshake state with the embedding host.
*
* The host is the embedding parent window. Outbound messages are scoped to the
* host's origin once it is learned from the host's first message; until then
* (only a version-only `ready` ping precedes the handshake) `targetOrigin`
* falls back to `"*"`. `handshakeComplete` gates proactive broadcasts so a
* third-party page that frames a `?embed=1` export but never speaks never
* receives data.
*/
export interface EmbedHost {
/** The embedding parent window (or `window` itself for a top-level export). */
readonly window: Window;
/** Whether the host has sent at least one message. */
readonly handshakeComplete: boolean;
/** Origin to scope outbound posts to (`"*"` until the host is identified). */
targetOrigin(): string;
/**
* Origins a pre-handshake broadcast (the `ready` ping) may go to: the known
* host origin, else every allowlisted origin, else `["*"]`.
*/
broadcastTargets(): string[];
/**
* Record an inbound message from the host: marks the handshake complete and
* learns the host's origin. Returns true when the message actually came from
* the host window (callers should ignore messages where this is false).
*/
note(event: MessageEvent): boolean;
}
/**
* Create the shared host channel for a bridge. In a browser `window.parent` is
* always defined; when the app is the top-level document (the `?embed=1`
* self-test) it is `window` itself, so the bridge naturally posts to and
* receives from itself.
*
* When the deployment configured an embed-API origin allowlist
* (`GEOLIBRE_EMBED_ORIGINS`), it applies here too: a host whose origin is not
* listed never completes the handshake, so an operator who names their trusted
* hosts also narrows the `?embed=1` project/scripting bridges to them. With no
* allowlist configured nothing changes (the Jupyter widget's host origin is
* arbitrary and unknowable, so it cannot be listed in advance).
*/
export function createEmbedHost(): EmbedHost {
const host = window.parent;
const allowedOrigins = readEmbedOrigins();
let hostOrigin: string | null = null;
let handshakeComplete = false;
return {
window: host,
get handshakeComplete() {
return handshakeComplete;
},
targetOrigin: () => hostOrigin ?? "*",
broadcastTargets: () => {
if (hostOrigin) return [hostOrigin];
if (allowedOrigins.length === 0 || allowedOrigins.includes(EMBED_ORIGIN_WILDCARD)) {
return [EMBED_ORIGIN_WILDCARD];
}
return allowedOrigins;
},
note(event: MessageEvent): boolean {
if (event.source !== host) return false;
if (allowedOrigins.length > 0 && !isEmbedOriginAllowed(event.origin, allowedOrigins)) {
return false;
}
handshakeComplete = true;
// "null" (opaque/file origins) stays "*".
if (event.origin && event.origin !== "null") hostOrigin = event.origin;
return true;
},
};
}
// Both bridges talk to the SAME parent window, so they share one host channel:
// whichever bridge sees the host's first message learns the origin and marks the
// handshake for both. This lets the command bridge emit events with the correct
// scoped origin once the project bridge has completed its (reliable) handshake,
// without each bridge needing its own round-trip first.
let shared: EmbedHost | null = null;
/** The process-wide shared {@link EmbedHost}, created lazily on first use. */
export function getEmbedHost(): EmbedHost {
if (!shared) shared = createEmbedHost();
return shared;
}