Skip to content
4 changes: 3 additions & 1 deletion app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1003,11 +1003,13 @@ export default function CommentsShell(props: Props) {

<div ref={stageRef} className="jh-stage" data-rail={railOpen ? "open" : "closed"} style={stageStyle}>
<div style={{ flex: 1, minWidth: 0, position: "relative" }}>
{/* allow-popups (+ escape-sandbox): the overlay opens the doc's links in a new
tab rather than navigating this frame, which X-Frame-Options sites refuse. */}
<iframe
ref={iframeRef}
title={title}
src={rawSrc}
sandbox="allow-scripts allow-downloads"
sandbox="allow-scripts allow-downloads allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
style={{ border: "none", width: "100%", height: "100%", display: "block", background: "var(--jh-stage-bg, #fff)" }}
/>
Expand Down
14 changes: 9 additions & 5 deletions app/d/[slug]/raw/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ type Ctx = { params: Promise<{ slug: string }> };
// can never execute same-origin with our auth/session surface (birthday.md "The
// one security decision that matters").
//
// Content-Security-Policy: sandbox allow-scripts allow-downloads → the response
// is treated as a unique opaque origin; scripts run and user-initiated file
// downloads work, but the document cannot read our cookies / tokens or reach
// our origin's storage.
// Content-Security-Policy: sandbox allow-scripts allow-downloads allow-popups
// allow-popups-to-escape-sandbox → the response is treated as a unique opaque
// origin; scripts run, user-initiated file downloads work, and the doc's links
// can open in a new tab (the overlay routes them there — navigating the frame
// itself breaks on every X-Frame-Options site), but the document cannot read
// our cookies / tokens or reach our origin's storage. The popup escapes the
// sandbox so the linked site loads as a normal page, never as our origin.
// X-Content-Type-Options: nosniff → no MIME sniffing.
//
// Directly linkable for zero-chrome viewing; same token rules as /d/:slug.
Expand Down Expand Up @@ -98,7 +101,8 @@ export async function GET(req: Request, ctx: Ctx): Promise<Response> {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "sandbox allow-scripts allow-downloads",
"Content-Security-Policy":
"sandbox allow-scripts allow-downloads allow-popups allow-popups-to-escape-sandbox",
"X-Content-Type-Options": "nosniff",
// Never cache private content at shared caches; tokens are capability URLs.
"Cache-Control": "private, no-store",
Expand Down
58 changes: 58 additions & 0 deletions lib/docs/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,8 @@ export const OVERLAY_SCRIPT = String.raw`
span.addEventListener("mouseleave", function(){ setHover(null); send({type:"jh:hlHoverOut"}); });
span.addEventListener("click", function(ev){
ev.stopPropagation();
// A click the link interceptor claimed is navigation, not an annotation click.
if (ev === linkClick) return;
onSegClick(seg, ev);
});
}
Expand Down Expand Up @@ -709,6 +711,62 @@ export const OVERLAY_SCRIPT = String.raw`
send({type:"jh:focus", key:null, id:null, keys:[]});
}

// ---- links leave the frame, never navigate it ----
// The doc renders in an opaque-origin sandbox inside the shell's iframe, so a
// plain <a href="https://…"> click navigates the FRAME, and any site sending
// X-Frame-Options / frame-ancestors (github.com, most of the web) then paints
// "refused to connect" where the doc was. The sandbox also can't reach the top
// window, and target="_blank" alone is a blocked popup — so we open the tab
// ourselves from inside the user gesture (sandbox carries allow-popups +
// allow-popups-to-escape-sandbox so the target loads as a normal page).
// Same-document fragments (#section, the gutter section links) stay in-frame.
// The raw attribute, not a.href: on an SVG <a> that property is an
// SVGAnimatedString and stringifies to garbage.
function externalHref(a){
var raw = a.getAttribute("href");
if (raw == null) raw = a.getAttribute("xlink:href");
if (raw == null) return null;
var u;
try { u = new URL(raw, document.baseURI); } catch(e){ return null; }
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
if (u.href.split("#")[0] === location.href.split("#")[0]) return null;
return u.href;
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}
// Capture phase: a link inside a highlighted span would otherwise be swallowed
// by the segment's own click handler (it stopPropagation()s) and navigate. We
// never stop propagation ourselves — the doc may have its own click handlers on
// the link — so the claimed event is recorded instead, and the segment handler
// skips focusing for it.
var linkClick = null;
function openLink(ev){
if (ev.type === "auxclick" && ev.button !== 1) return; // middle-click only
var t = ev.target;
if (!t || !t.closest) return;
var a = t.closest("a");
if (!a) return;
// In edit mode a link is text to retype. The edit-mode handler already eats
// the click; this also covers middle-click, which would open a tab.
if (editing){ ev.preventDefault(); return; }
// Our own chrome can sit INSIDE an author link: a reaction chip is appended
// after the segment that ends its span, so a span ending mid-link puts the
// chip in the anchor, and the section anchor is a link of ours. Those clicks
// are ours — swallow the anchor's navigation (the chip handler stops
// propagation but not the default action), and open no tab.
if (fromOverlayChrome(ev) || t.closest("[data-jh-sec-anchor]")){ ev.preventDefault(); return; }
var href = externalHref(a);
if (!href) return;
ev.preventDefault();
linkClick = ev;
// A selection that survives the click is a comment/react gesture (a drag that
// ended in linked text), not navigation: keep the selection, open nothing.
// Same emptiness test as reportSelection.
var sel = window.getSelection();
if (sel && sel.rangeCount && !sel.isCollapsed && sel.toString().trim()) return;
window.open(href, "_blank", "noopener");
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale focus after in-highlight link

Medium Severity

When an external link sits inside a highlight segment, openLink opens the tab and sets linkClick, but the segment handler still stopPropagation()s before bailing. That blocks the document click-elsewhere listener, so pinned focus/dimming and any open pick popover remain after the navigation gesture—neither onSegClick nor clearFocus/hidePop runs.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 99c12f4. Configure here.

document.addEventListener("click", openLink, true);
document.addEventListener("auxclick", openLink, true);
Comment thread
cursor[bot] marked this conversation as resolved.

// click-elsewhere (on non-highlight) clears focus + selection popovers
document.addEventListener("click", function(ev){
var t = ev.target;
Expand Down
Loading