the embed answers one page, sizes to a bound, and says whose dialog it is

Every message the loader and the framed document exchange is now bound to the
frame entitled to send it, and the popup declares who is serving it:

- the popup draws a bar naming its origin. data-* is what survives an HTML
  sanitizer, so a page that accepts user HTML can be made to conjure a
  full-viewport overlay with no script at all; the bar is drawn in the loader's
  own DOM under all:initial !important, which a scriptless injection cannot
  remove, so the overlay cannot pass for the host page. The attribute keeps
  working — routing the popup through HanzoCal.open instead would have broken
  every host page already using it.
- a reported height is clamped to 5000px. It was applied verbatim, so one
  message could paper the host page over.
- close is bound to the popup's own frame, the way height was already bound to
  the frame that reported it. Two bookers on one page and one Escape closed the
  wrong dialog.
- the framed document posts to the origin the loader names in ?parent=, never
  "*". Measured from a third-party page: a frame addressed elsewhere delivers
  nothing, one addressed here delivers.
- a key segment of nothing but dots is refused. `.` and `..` are steps through a
  path rather than names, and both segments are spliced into the public-config
  URL the Booker fetches. Dots inside a name stay legal.
This commit is contained in:
zooqueen
2026-08-13 12:07:01 -07:00
parent b317b71724
commit 5f0019f061
3 changed files with 114 additions and 30 deletions
+83 -13
View File
@@ -15,6 +15,10 @@ const KEY = "data-hanzo-cal";
const POPUP = "data-hanzo-cal-popup";
const MOUNTED = "data-hanzo-cal-mounted";
// A frame reports the height of its own content. Past this it is not reporting a
// booker, and a page that accepts any number has agreed to be papered over.
const MAX_HEIGHT = 5000;
type Style = Record<string, string>;
const FRAME: Style = {
@@ -24,13 +28,6 @@ const FRAME: Style = {
background: "transparent",
};
const INLINE: Style = { ...FRAME, height: "640px" };
const MODAL: Style = {
...FRAME,
"max-width": "900px",
height: "90vh",
"max-height": "760px",
"border-radius": "12px",
};
const SCRIM: Style = {
position: "fixed",
inset: "0",
@@ -41,6 +38,44 @@ const SCRIM: Style = {
padding: "16px",
background: "rgba(0,0,0,.6)",
};
const PANEL: Style = {
display: "flex",
"flex-direction": "column",
width: "100%",
"max-width": "900px",
height: "90vh",
"max-height": "760px",
"border-radius": "12px",
overflow: "hidden",
"background-color": "#0b0b0d",
};
const BAR: Style = {
display: "flex",
"align-items": "center",
"justify-content": "space-between",
gap: "12px",
padding: "10px 14px",
"font-family": "system-ui, -apple-system, sans-serif",
"font-size": "13px",
"line-height": "1.2",
color: "#f3f4f6",
"background-color": "#0b0b0d",
};
const LABEL: Style = {
"font-family": "system-ui, -apple-system, sans-serif",
"font-size": "13px",
color: "#9ca3af",
};
const DISMISS: Style = {
"font-family": "system-ui, -apple-system, sans-serif",
"font-size": "13px",
color: "#f3f4f6",
background: "transparent",
border: "0",
padding: "2px 6px",
cursor: "pointer",
};
const MODAL: Style = { ...FRAME, flex: "1 1 auto", "min-height": "0" };
// `all:initial` drops everything the element was inheriting, and !important on
// an inline declaration is the one thing a host's `iframe { border: … }` cannot
@@ -58,10 +93,16 @@ const origin = new URL(
const inline: HTMLIFrameElement[] = [];
let scrim: HTMLElement | null = null;
let popup: HTMLIFrameElement | null = null;
// `parent` is the address the booker answers to. Without it the booker posts to
// whatever surrounds it, and a page that never asked for one could read what it
// says.
function frame(pk: string, surface: "inline" | "popup", style: Style): HTMLIFrameElement {
const el = document.createElement("iframe");
el.src = `${origin}/embed?pk=${encodeURIComponent(pk)}&surface=${surface}`;
el.src =
`${origin}/embed?pk=${encodeURIComponent(pk)}&surface=${surface}` +
`&parent=${encodeURIComponent(location.origin)}`;
el.title = "Book a time";
apply(el, style);
return el;
@@ -83,12 +124,36 @@ export function mount(el: Element): void {
inline.push(f);
}
/** open shows the booker over the page. */
/** open shows the booker over the page, under a bar naming who is serving it.
*
* The popup covers the whole viewport, and `data-*` is what survives an HTML
* sanitizer — so a page that takes user HTML can be made to open one without
* ever running a script. The bar is why that is not worth doing: it is drawn in
* this script's own DOM with `all:initial !important`, so the host's CSS cannot
* hide it and an injection with no script cannot remove it, and it says the
* overlay is cal.hanzo.ai's, not the page's. */
export function open(pk: string): void {
close();
const root = document.createElement("div");
apply(root, SCRIM);
root.appendChild(frame(pk, "popup", MODAL));
const panel = document.createElement("div");
apply(panel, PANEL);
const bar = document.createElement("div");
apply(bar, BAR);
const who = document.createElement("span");
apply(who, LABEL);
who.textContent = new URL(origin).host;
const dismiss = document.createElement("button");
apply(dismiss, DISMISS);
dismiss.textContent = "Close";
dismiss.addEventListener("click", () => close());
bar.append(who, dismiss);
popup = frame(pk, "popup", MODAL);
panel.append(bar, popup);
root.appendChild(panel);
root.addEventListener("click", (e) => {
if (e.target === root) close();
});
@@ -102,6 +167,7 @@ export function close(): void {
if (!scrim) return;
scrim.remove();
scrim = null;
popup = null;
document.removeEventListener("keydown", onKey);
}
@@ -110,19 +176,23 @@ function onKey(e: KeyboardEvent): void {
}
// The embedded document is the only thing we listen to, and only for the two
// things it is allowed to ask for.
// things it is allowed to ask for. Both are bound to the frame entitled to ask:
// close to the open popup, height to the inline frame that reported it. Right
// origin, wrong frame — two bookers on one page, one Escape — is how a dialog
// closes on somebody else's visitor.
window.addEventListener("message", (e: MessageEvent) => {
if (e.origin !== origin) return;
const msg = e.data as { source?: string; type?: string; height?: number } | null;
if (!msg || msg.source !== "hanzo-cal") return;
if (msg.type === "close") {
close();
if (popup && e.source === popup.contentWindow) close();
return;
}
if (msg.type === "height" && typeof msg.height === "number") {
const f = inline.find((x) => x.contentWindow === e.source);
if (f) f.style.setProperty("height", `${msg.height}px`, "important");
const h = Math.min(Math.max(msg.height, 0), MAX_HEIGHT);
if (f) f.style.setProperty("height", `${h}px`, "important");
}
});
+26 -15
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { calendarApi } from "../api";
import { booked, keyRejected, useShown, type Surface } from "../analytics";
import { booked, useShown, type Surface } from "../analytics";
import { resolve } from "../pk";
// The document a host page frames: the same <Booker> against the same
@@ -14,16 +14,13 @@ export function Embed() {
const pk = params.get("pk") ?? "";
const surface: Surface = params.get("surface") === "popup" ? "popup" : "inline";
const target = resolve(pk);
const parent = loader(params.get("parent"));
useTransparentPage();
usePostedHeight();
useEscapeToClose();
usePostedHeight(parent);
useEscapeToClose(parent);
useShown(surface, target);
useEffect(() => {
if (!target) keyRejected(pk);
}, [target, pk]);
if (!target) {
return (
<main className="embed">
@@ -53,37 +50,51 @@ function useTransparentPage() {
}, []);
}
// The address of every message back to the page around us: embed.js names its
// own origin in ?parent=, and the browser delivers there or nowhere. A "*"
// targetOrigin is delivered to whatever the parent happens to be, and a frame
// chain can be rearranged after load. A bare origin only — a value carrying a
// path is not one — and a frame nobody addressed says nothing.
function loader(raw: string | null): string | null {
if (!raw) return null;
try {
return new URL(raw).origin === raw ? raw : null;
} catch {
return null;
}
}
// Escape pressed inside a frame never reaches the page around it, so a visitor
// typing in the form would have no way out of the popup. Pass it up; the loader
// ignores it unless a popup is open.
function useEscapeToClose() {
function useEscapeToClose(parent: string | null) {
useEffect(() => {
if (window.parent === window) return;
if (!parent || window.parent === window) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
window.parent.postMessage({ source: "hanzo-cal", type: "close" }, "*");
window.parent.postMessage({ source: "hanzo-cal", type: "close" }, parent);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
}, [parent]);
}
// An iframe cannot size itself to its content, so the content reports its
// height and the loader on the host page applies it.
function usePostedHeight() {
function usePostedHeight(parent: string | null) {
const last = useRef(0);
useEffect(() => {
if (window.parent === window) return;
if (!parent || window.parent === window) return;
const post = () => {
const height = Math.ceil(document.documentElement.scrollHeight);
if (height === last.current) return;
last.current = height;
window.parent.postMessage({ source: "hanzo-cal", type: "height", height }, "*");
window.parent.postMessage({ source: "hanzo-cal", type: "height", height }, parent);
};
const observer = new ResizeObserver(post);
observer.observe(document.documentElement);
post();
return () => observer.disconnect();
}, []);
}, [parent]);
}
+5 -2
View File
@@ -22,8 +22,11 @@ export interface Target {
const PREFIX = "pk_";
// Handles and slugs are URL segments. Anything else in a decoded key is junk
// from a mistyped host page, not a calendar we could ever resolve.
const SEGMENT = /^[\w.-]{1,64}$/;
// from a mistyped host page, not a calendar we could ever resolve. Dots are
// legal in a name but a segment of nothing else is `.` or `..` — a step through
// a path, not a name — and these two go on to be spliced into the public-config
// URL the Booker fetches.
const SEGMENT = /^(?!\.+$)[\w.-]{1,64}$/;
/** mint returns the key that names a target. */
export function mint(t: Target): string {