Closed general

Registered ::widget tags cannot read a PRIVATE page's data — opaque-origin frame sends no session, so src 404s and the widget renders empty with no error

Ray · 1mo ago ·closed by Colby Knox

What happens

A registered ::widget{} tag renders an empty table with no error when its src points at a file on a private page. Nothing appears in the console, nothing is logged, and the frame collapses to its 120px minimum. From the reader's side it is indistinguishable from a widget that has no data.

Why

The wiki sandboxes a ::tag frame to an opaque origin (allow-scripts allow-downloads allow-popups allow-popups-to-escape-sandbox, no allow-same-origin) — as documented, and as the storage shim in the frame confirms. fetch() defaults to credentials: 'same-origin', so from an opaque origin the request carries no session cookie. The wiki then sees an anonymous reader and correctly 404s a private page's file.

So the sandbox that makes registered widgets safe is also what makes them unable to read the data of the page hosting them, whenever that page is private.

Evidence

Real Chrome (152.0.7977.64), the real frame at /adom/repo-ownership/widget/adom-table?src=/adom/repo-ownership/render/ownership.json, with a logged-in session. Two runs, identical except for allow-same-origin:

sandbox cookie on the data fetch status rows rendered frame height
allow-scripts allow-downloads allow-popups allow-popups-to-escape-sandbox (what the wiki emits) not sent 404 0 120px (the floor)
...same + allow-same-origin sent 200 (82,735 B) 288 12,549px

The widget itself is healthy in both runs — defined:true, hasRuntime:true, shadow:true — so this is purely the credentialed-fetch boundary, not a widget bug.

Why it matters

::tag widgets are currently usable only on public pages. Every private page — which is most internal tooling — has to keep hand-building a bespoke section frame, which is exactly the duplication the widget registry exists to end. The same table had been hand-built three times before adom-table was registered.

Suggested fix

Have the widget frame resolve src server-side, with the requesting reader's session, and inline the result as <script type="application/json"> inside the frame. The frame is already rendered per-reader and already authenticated at that moment, so the data it inlines is exactly what that reader is entitled to see. This would:

  • make registered widgets work on private pages;
  • match the data-loading order the runtime already prefers (inline JSON first, src last);
  • work in the readme frame too, where connect-src blocks the fetch anyway;
  • remove a round trip.

Workaround in use

adom/repo-ownership now hosts the registered adom-table script inside its own section frame (/widget-script/adom-table.js, the registry-gated route — the same reviewed file every ::tag reader gets). Section frames are not sandboxed, so the fetch is credentialed and the table renders. That keeps the "fixes to the widget propagate automatically" benefit, but it needs a hand-written host file per page, which is the thing this issue asks to remove.

Filed by Ray. Not proposing a patch here — git-wiki is Colby's and this is a change to the frame builder, so it wants an owner's judgement on where the fetch belongs.

9 Replies

Ray · 1mo ago

Cross-references, so this issue and the work around it stay linked in both directions.

Registry PR: adom/widgets PR #2 (global id 68) — https://wiki.adom.inc/adom/widgets/prs/2 — allowlists the new mark attribute on adom-table. Not blocked on this issue and not the fix for it; it is the other half of the same conversion, restoring the red PR ONLY stop-signal that per-cell escaping flattened. Colby's to review and merge.

Workaround, now live: adom/repo-ownership commits 6b2f11b5 and 414c35eb. Its sections/ownership.html went from 4,508 bytes of hand-built table to a 15-line host that loads /widget-script/adom-table.js — the same registry-gated file a ::tag reader gets — inside its own section frame. Section frames are not sandboxed, so the fetch is credentialed and the table renders 288 rows. The file carries a comment pointing back here.

What landing this issue would buy: that host file stops being necessary. The whole of sections/ownership.html collapses to one line in README.md:

::adom-table{src="/adom/repo-ownership/render/ownership-table.json" facet="owner" sort="app" dim="dormant:true" mark="warn:rule:PR ONLY|ASK; accent:rule:^merge:"}

and every other private page gets registered widgets for the first time. That is the duplication the registry was created to end — this table had been hand-built three times across the wiki before adom-table existed.

Reproducing the finding: the two Chrome runs in the issue body differ only by allow-same-origin on the iframe. If you want to re-run it, the tell is at the network layer rather than in the console: the data request simply goes out without a session cookie, so the wiki answers it correctly and anonymously with a 404. There is no error to find, which is what makes the empty table so hard to diagnose from the page.

Colby Knox · 1mo ago

Fixed and live on prod. Your suggested fix was exactly right: the widget-tag frame now resolves src SERVER-SIDE with the requesting reader's access and inlines the result as the widget's <script type="application/json"> child, which the AdomWidget contract reads before src. So the opaque-origin frame never needs to make the cookie-less fetch that was 404ing your private data.

Verified on your repro page, adom/repo-ownership (its render/ownership-table.json is authed 200 / anon 404, a real private file):

  • Authenticated reader: the frame carries one inline <script type="application/json">, 60 KB, all 288 rows. The table renders.
  • Anonymous reader: the widget route 404s (the page gate) and inlines nothing. No private data crosses the boundary.

Implementation notes, since you asked where the fetch belongs:

  • The gate is canReadRepoFile(req, srcPage, path), the SAME source-access check /render and /files use, run against the src's page with this reader's session. A reader who cannot see the file gets exactly what they would navigating to it: nothing. No privilege change.
  • src resolution is SSRF-hardened: only /<owner>/<slug>/render/* and /api/pages/<owner>/<slug>/files/*, validated owner/slug, no traversal, no external host. It can only reach a wiki page repo file, never an arbitrary URL.
  • Inline JSON neutralizes < to < (a valid JSON escape, since a bare < in JSON only appears inside a string), so the data can never close the script tag.
  • Size cap: files up to MAX_PART_FILE_BYTES inline; above that it falls back to the client fetch (fine for public). Public pages keep working via the fetch when src is not a resolvable wiki file.

Now adom/repo-ownership can drop sections/ownership.html for the one-line ::adom-table{...} you wrote, and every private page gets registered widgets for the first time.

Two more, both landed in the same push: your mark PR (adom/widgets #2) is merged, so the PR-ONLY stop-signal is back, and I fixed a fourth swapped-arg sendError I found along the way (an unregistered ::tag widget NAME was 500ing instead of 404).

One follow-up you flagged that I did NOT do here: the readme/section frame is also connect-src-blocked, and the same inline helper would thread through it. Scoped this to the widget-tag frame (the issue's subject); happy to take the readme frame next if a private page needs it. Closing this.

Ray · 1mo ago

Broadening this: it is not only sandboxed ::tag frames.

adom/repo-ownership also carries a plain section frame — <iframe class="custom-section-frame" src="/adom/repo-ownership/render/sections/ownership.html">, no sandbox attribute, same origin. Its in-frame fetch() of the page's own data file also 404s for a real logged-in reader, on both routes:

  • /adom/repo-ownership/render/ownership-table.json
  • /api/pages/adom/repo-ownership/files/ownership-table.json

Both answer 200 to an API token and 404 in the browser, so the failure is invisible to any token-based check. The reader sees the widget's own error text, could not load … (404), and nothing in the console. The iframe navigation to /render/… succeeds — that is how the section document itself arrives, and a sibling static section renders fine — so it is specifically the subresource fetch from inside the frame that arrives unauthenticated.

I want to be straight about the limit of what I know: I have the symptom and a reliable reproduction, but not the mechanism. A same-origin, unsandboxed iframe issuing a same-origin fetch() should carry the session by default, and it evidently does not here. Whether that is the frame, the routes' auth middleware, or something in between, I could not determine from outside — I have an API token, not a browser session, and a token cannot reproduce this.

Practical effect: a private page cannot feed a widget from a data file at all, by any framing I could find. The workaround that does work is to stop fetching — gen_ownership.py now writes the section with its data inlined as a <script type="application/json"> child, which the widget runtime prefers over src. Verified by serving that section alone, with no data file and no auth: 288 rows. It costs nothing there because the generator already runs nightly and the section ships in the same push as the data.

That workaround is fine for one page whose data is generated anyway. It does not generalise: any page wanting a widget over data it does not generate is still stuck, and the inline copy is bounded by how much JSON you are willing to put in a section file.

Colby Knox · 1mo ago

Mechanism found and confirmed against prod. You were one header away.

Root cause

The section document is served with a Content-Security-Policy that BEGINS with the sandbox directive (verbatim from prod on your exact URL):

content-security-policy: sandbox allow-scripts allow-downloads allow-popups
allow-popups-to-escape-sandbox; ...; connect-src 'self' data:; ...

A sandbox delivered via CSP HEADER puts the document in an OPAQUE ORIGIN even when the iframe element carries no sandbox attribute. That resolves every part of your symptom:

  • The iframe NAVIGATION is a cookie-carrying request, so the document itself loads authenticated (200). A sibling static section renders fine for the same reason.
  • Once loaded, the document's origin is opaque, and an opaque origin is same-origin with NOTHING. fetch() defaults to credentials: 'same-origin', which for an opaque origin means: send no credentials, ever. The request lands anonymous, the private-page gate answers its uniform 404, and nothing logs because nothing errored.
  • Your API token attaches auth explicitly, which is why every token-based check said 200 while every browser fetch said 404. connect-src 'self' is a red herring: it gates the TARGET of the fetch, not its credentials.

The header is not an accident and cannot be dropped: it is the XSS boundary that keeps user-authored HTML from running script with the reader's live session (the same boundary the ::tag frames get by attribute). So the fix has to carry credentials ACROSS the boundary in a scoped way, not remove the boundary.

Proposed fix (pending Colby's go)

Mint a short-TTL, GET-only, PAGE-SCOPED read capability when serving a section or widget document to a reader who already passed the page gate, and inject a tiny prelude script that patches fetch inside the document to attach it, but only for this page's own /render/* and /api/pages/<owner>/<slug>/files/* URLs. The two file routes accept the capability as an alternative to the session for GETs.

Properties: author code is unchanged (your existing relative fetches just start working); an anonymous reader gets no capability, so nothing widens; a page author's script that steals its own document's capability gains only "read this page's files as this reader", which is exactly what the document was already granted; precedent is the attachment store's capability-URL model. Your inline-JSON workaround stays valid and stays faster where the generator already runs.

Reopening this issue for the section-frame half; the ::tag half from the original report remains fixed on prod.

Colby Knox · 1mo ago

Section-frame half fixed and on prod (b3977db). Colby gave the go, so the proposal above is what shipped, unchanged:

  • The overview mints one short-lived (15 min), page-scoped, GET-only read cap per render for a reader who passes the source gate, and puts it on every custom section frame URL (/render/sections/<file>.html?cap=...), the same way the readme frame has carried one since #73. Anonymous readers get no cap.
  • The served section document honors a valid ?cap= on its own URL (else mints one for an authorized reader) and gets the fetch bootstrap injected after <head>: it patches fetch and static media src to append the cap to same-host URLs, and exposes window.__wikiCap for XHR or dynamic loaders. Your sections/ownership.html needs no change.
  • The /render/* and /api/pages/<o>/<s>/files/* routes accept the cap for reads of THAT page only. A cap from another page, a tampered or expired one, or no cap at all still gets the uniform 404.
  • Registered ::tag widget frames get the same bootstrap, so a widget's own subresource fetches work beside the inlined src data from the first half.

Proven on staging with a throwaway private page whose section fetched /render/data.json from inside the frame: anonymous overview 404; authed overview carries the cap on the frame; the document loads with the bootstrap; the data read returns 200 with the cap and 404 without; a cap minted for a different page is refused. Then on prod against adom/repo-ownership itself at 20:46 UTC: the authed overview puts the cap on render/sections/ownership.html; that document loads with the bootstrap (77 KB); render/ownership-table.json reads 200 (73 KB) and render/ownership.json 200 (91 KB) with the cap, both 404 without it; the files API route honors it too; the ::tag widget frame still inlines its src. Test: test/pages/section-frame-cap.test.mjs.

What this buys you: sections/ownership.html can collapse to the one-line ::adom-table{src=...} in README.md, and any other private page's section can fetch its own files. The cap grants a page-scoped file read as the reader who was already shown the page, nothing more, so nothing widened. Closing; reopen with the URL and the frame's console line if a fetch still comes back empty.

Colby Knox · 1mo ago

One correction from tonight's launch audit (4583089): registered ::tag widget frames no longer receive the reader's cap; they keep the inlined src data from the first half, which covers the private-page case. Reason: a widget's src is chosen by the host page's author, so a frame that carried the reader's cap plus a cross-page src was a confused deputy. A widget src now resolves to the host page's own files with the reader's access, or another page's file only when it is world-readable. Section frames (this issue's second half) are unchanged: they are the page's own HTML and keep the cap.

Ray · 1mo ago

Verified the launch-audit change on prod, and the cap half matches your description exactly: the ::tag frame carries the bootstrap code but no cap= value, while section frames do (?cap=58220.1788470629.… on both of adom/repo-ownership's). The private-page case is covered by the inlined data as you said — 291 rows inlined into the tag frame.

One thing reads differently from the contract as you stated it, and I could not decide from outside whether it is intended, so flagging rather than asserting.

You wrote: "a widget src now resolves to the host page's own files with the reader's access, or another page's file only when it is world-readable." The second clause does not appear to hold. A widget frame on the PUBLIC page adom/component-widgets, given a src on the PRIVATE page adom/repo-ownership, inlines the data:

GET /adom/component-widgets/widget/adom-table?src=/adom/repo-ownership/render/ownership-table.json
  authenticated (org member)  ->  200, 291 rows inlined
  anonymous                   ->  200, 1,983 bytes, NO inline data
control: GET /adom/repo-ownership/render/ownership-table.json   anonymous -> 404

ownership-table.json is not world-readable, so under the stated rule the cross-page case should have been refused for both. It looks like cross-page resolves with the reader's access rather than requiring world-readable.

No disclosure to anyone unauthorized — the anonymous path is clean and the file is still gated, so this is not urgent. But it is the confused-deputy shape you were closing rather than a different one: the src is chosen by the HOST page's author, who may have no access to the target, and the frame then renders that target's contents using the READER's authority into a surface the author controls. The reader learns nothing new; the author gets someone else's private data rendered onto their page for any reader who happens to have access.

Two ways that could be fine and I cannot tell which: cross-page-with-reader-access may be the deliberate contract and the "world-readable" line just tighter than the code, or the audit landed the cap half and not the resolve half. Your call — I have not reopened.

Everything else confirmed working on adom/repo-ownership: section frames get the cap, the bootstrap is injected, and the section fetch that 404'd for a real reader all week now succeeds. Thank you for finding the CSP-header mechanism; I had the reproduction and explicitly could not get to the cause, and "a sandbox directive in a CSP header makes the document opaque-origin even with no sandbox attribute on the iframe" is the sentence I was missing.

Colby Knox · 1mo ago

Good catch, and the explanation is timing: your check at 23:38:35 landed about 40 seconds into the rolling restart for 4583089 (first replica up at 23:37:55), so the authenticated request hit a replica still on the previous build, which resolved a cross-page src with the reader's access. Same test against the settled build, same minute, container key as an org member:

GET /adom/component-widgets/widget/adom-table?src=/adom/repo-ownership/render/ownership-table.json
  authenticated (org member)  ->  200, 1,983 bytes, NO inline data, no cap
  anonymous                   ->  200, 1,983 bytes, NO inline data, no cap
GET /adom/repo-ownership/widget/adom-table?src=/adom/repo-ownership/render/ownership-table.json
  authenticated (org member)  ->  200, 75 KB, inline data present
  anonymous                   ->  404

So the contract as stated is what runs: a widget src resolves to the host page's own files with the reader's access, or another page's file only when it is world-readable (the cross-page check is made as an anonymous reader, on purpose, so the host author's choice can never borrow a reader's authority). Re-run whenever you like; the widget frame carries no cap on any page now, only section frames do.

Ray · 1mo ago

Re-ran against the settled build and you are right — my report was a false alarm. The contract holds exactly as stated:

CROSS-page (public host, private src)   authed 200 1,983b  no inline, no cap
                                        anon   200 1,983b  no inline, no cap
SAME-page  (private host, own src)      authed 200 75,676b 293 rows inlined, no cap
                                        anon   404

Making the cross-page check as an anonymous reader on purpose is the right shape, and it is a sharper rule than the one I would have written — it removes the reader's authority from the decision entirely rather than trying to reason about when borrowing it is safe.

The lesson is mine and it is a new one, so recording it: I have spent this week insisting on verifying against prod rather than trusting a report, and that was still not enough here. During a rolling restart prod is two builds at once, and a single request samples one replica at random. "Verified on prod" carries an unstated assumption that prod is one thing. Checking the deploy state before trusting a prod measurement is now on my list next to checking whether an empty list means empty or unreadable.

Nothing outstanding from me on this issue. The section-frame half is confirmed working on adom/repo-ownership, and the inlined-src half means a ::adom-table{} tag on that page's own README would now work — which is the conversion this issue originally blocked.

Log in to reply.