6. Slot ids are not authenticated, and the content boundary is the defence
Date: 2026-08-29
Status
Accepted. Decided on #109 and shipped in PR #122, which put the defence at the
content boundary — unescapedHtml's reserved-name strip — rather than at the
read path. No code change is attached to this file: it records a decision
already implemented. Binding on the wire format and on unescapedHtml's
contract. Raised by #137.
Context
The security sweep on #25 measured a hand-typed <fw-island> in a rich-text
field being mounted like any other marker (#109): the runtime read the four
data-fw-* attributes, checked they were all present, and scheduled the
mount, so a CMS author chose which registered component ran on the client and
with what props. The general question underneath it is the one this ADR
answers, and it is about the wire format rather than about that one payload:
should the client be able to tell control markup the build wrote from control
markup content wrote? #128 asked it again about slot ids specifically, after
#114 had made ownership structurally correct for every forgery except the one
whose nearest marker genuinely is the target container.
A slot id is a position, and a position is public. ISLAND_ID_PATTERN is
digits joined by dots and nothing else; the id a <fw-slot> carries in
ISLAND_SLOT_ATTRIBUTE is the child's place in the entry tree, which is what
makes it unique page-wide. Content does not have to derive one or guess one,
only to write one down. There is no secret in it to check.
The one identifier here that is derived has the same property. A marker's
useId prefix (ISLAND_PREFIX_ATTRIBUTE) is render.tsx's islandPrefix:
i — an id must not start with a digit — and twelve hex digits of
sha256(locale \0 path \0 position). All three inputs are public to anyone who
can read the page, so the digest is reproducible by anyone who can read the
page.
Both fixes #109's brief proposed were measured and neither survived.
Validating against the i + 12-hex shape passes that issue's
own payload, ideadbeef0000, which is i followed by exactly twelve hex
characters. Recomputing the prefix at the read path is not available either:
the runtime has document order from querySelectorAll, which diverges from
entry-tree position as soon as islands nest — and it would not help if it were,
because the inputs are public.
So the only real proposal was a per-build random value, and it collides with
a live invariant. A nonce in the markup makes two builds of one unchanged
site differ, which is what render.test.tsx's "rendering the same page twice
returns byte-identical HTML" pins for a render and what #20 pins for a whole
output tree — landed in PR #179 as determinism.ts, compared over two real
pipeline runs by build.test.ts, with the manifest's build stamp as the single
excused field. That test alone is narrower than the invariant it stands for, and
PR #179 said so itself: both of its runs are one process, with Rolldown, React
and React DOM externalized and keeping their module-scope values across the
pair, so what it certifies is that two builds agree given one evaluation of the
toolchain. #220 closed that gap and it needed no new comparison:
pnpm check:build-twice spawns two fw build processes over one unchanged
site and hands the two trees to the same diffOutputTrees, and CI runs it on
every push. So the invariant is now certified as it reads, over a site that
islands both ways — a registry override and a module's own "use client" — with
the toolchain evaluated twice and the build stamp still the one excused field.
None of this changes the collision here: a per-build nonce differs between two
builds however they are isolated, so it fails both pins.
Threading the nonce in as a renderPage input rather than
minting it per build avoids the invariant and buys a different fault: roughly
47 renderPage and 36 hydrateIslands call sites to plumb, and a site that
supplies none is silently unprotected, which is #116's shape.
Decision
Nothing on the read path authenticates a slot id or a marker, and that is the
call rather than the gap. adoptSlots checks that an id has the shape a
build's id has (ISLAND_ID_PATTERN) and that the element is structurally this
marker's own (owns, issue #114). Both are questions about the page as it
stands. Neither is a question about who wrote the bytes, and no test at the
read path can be, because the wire format deliberately carries nothing to
answer it with.
The defence is the door. unescapedHtml is the single sanctioned entry for
content-authored HTML, and since PR #122 it strips the framework's reserved
vocabulary — <fw-island> and <fw-slot> in either tag form, and a named
list of attributes with each one's value in all three forms HTML allows. The
list is the six island attributes (, -component, -mode,
-props, -slot, -template), since #394 , and since #566
the attribute @fw/search reads to leave an element out of its index. (That
one is described rather than named: this ADR is published on the docs site
through unescapedHtml, which would strip the name from the sentence.) It is an
enumeration and not a data-fw- prefix match, so a data-fw-* name that is
not on it, data-fw-consent among them, passes through unstripped. Content
coming through it cannot mint a marker or a slot id at all, so the runtime
needs no credential to refuse one. The sink splits in two and the split is
the boundary: rawHtml takes the build's own composed markup unstripped,
unescapedHtml strips and then calls it.
The framework defends its own namespace, not the site's HTML. Sanitising
content is the CMS's job and stays there. What unescapedHtml's contract
changed to on #109 is "sanitises exactly the framework's own reserved names and
nothing else", which is the smallest claim that makes the trust boundary a
mechanism instead of an argument.
The residual is stated rather than closed. unescapedHtml is a convention.
A site that calls dangerouslySetInnerHTML itself, or hands the exported
SlotContent a string of its own, is past the boundary — and SlotContent
cannot be made to strip, because what it legitimately carries is the build's
own nested markers. That is the boundary the framework claims and no more.
Reopening this needs a way to make a build-written id unforgeable that does not cost the rebuild. New evidence, not a new reading of the same facts. The two directions already weighed are below.
Owner: the slot mechanism in @fw/islands. Whoever changes slot.ts owns
the read path this decision leaves unauthenticated, and whoever changes
stripReservedNames owns the door that stands in for it. It was #109's finding
and #109 is closed, so a reader looking for who holds the residual finds this
file.
Consequences
A forgery whose nearest marker genuinely is the target container is indistinguishable from that container's own panel, because by the only test available it really does belong there. That is #128, and it is this decision's residual seen from the read path rather than a second defect. It is loud rather than silent: measured across five arrangements of a forgery and a real panel, the container is handed one child more than the build's pass rendered from, React's child-by-child walk runs off the end of the server DOM, and the uncaught channel gets one hydration mismatch and regenerates the tree. The recoverable channel is empty in all five, which is where #128's body's "silently" came from, and the correction is on that issue.
#129's undercount errs safe on the scan's own arithmetic: a miss can only
move the count down, never up. The door is a second fact, not the reason.
slotCopies matches literal bytes, so it is case-sensitive and sees a
double-quoted value only, and it cannot see <FW-SLOT"0.1">, an
unquoted value, a single-quoted one, or a tag whose earlier attribute holds a
>. Down means one of two things: to 0, which stashes a child that was
placed, or off a duplicate, which declines a refusal rather than making a wrong
one. Neither drops a child. Separately, and because of this decision, none of
those spellings reaches a page through the sanctioned door at all, by two routes
rather than one. RESERVED_NAMES (tree.tsx) carries the gi that
stripReservedNames runs, where the scan is bytes, and its value alternation
takes unquoted and single-quoted values where the scan takes double-quoted ones
only. The title="a>b" tag is not the value alternation's doing: that regex's
tag alternative stops at the same inner > the scan stops at, and the
"0.1" left standing falls to the attribute alternative later in
the same pass. The undercount is therefore reachable only from past the
content boundary, which is the residual above arriving from the build side.
Whether to widen the scan is a decision neither #129 nor this ADR makes.
Determinism is untouched, and that is the point of taking the door instead of the credential. No marker carries a random value, no build reads a random source, and #20's build-twice check holds over the emitted tree without an exemption for hydration markup.
There is no secret to distribute, no config concept, no opt-in, and therefore no site that is silently unprotected because it never passed one (#116). The protection is a property of the sanctioned door rather than of a caller remembering to use a parameter.
PR #122 corrected runtime.ts and marker.ts to say that completeness is what
they check, not authorship. A reader who arrives asking whether signing was
ever considered finds this file, which is why #137 filed it.
Alternatives considered
Validate against the i + 12-hex shape. Rejected on
measurement, and it was #109's cheap proposal. The payload that issue measured
is ideadbeef0000 — i followed by exactly twelve hex characters. The check
passes the attack it was proposed to stop.
Recompute the prefix at the read path, or sign the ids deterministically.
Rejected twice over. The runtime does not have the inputs: islandPrefix
hashes the entry-tree position, and querySelectorAll gives document order,
which diverges from it once islands nest. And it would authenticate nothing if
it did — locale, path and position are all public, so any value derived
deterministically from them is reproducible by whoever is authoring the
forgery. A slot id is worse still: it is the position, with no digest in
front of it.
A per-build random nonce stamped on every marker. Rejected on the
determinism invariant, and it is the strongest of the three. It genuinely buys
the distinction — an unguessable value is exactly what a deterministic one
cannot be — and the price is the property the framework will not trade: two
builds of one unchanged site emitting the same bytes, which #20 pins over the
output tree and render.test.tsx pins per render. Cache stability and
incremental correctness both rest on it. If this trade is ever reopened, it is
reopened on a mechanism that survives a byte-identical rebuild, not by ranking
the nonce above the invariant.
Thread the nonce in as a renderPage input instead of minting it per
build. Rejected. It keeps the rebuild identical for a site that passes the
same value twice, and it costs roughly 47 renderPage and 36 hydrateIslands
call sites of plumbing, for a defence that is absent by default: a site that
supplies no nonce is unprotected and is told nothing, which is #116's failure
mode rather than a fix for this one.
Leave the decision in the issues, the PR and the docblock. Rejected, and it
is why this file exists. The docblock serves a reader already looking at
adoptSlots; three adversarial passes rediscovered the same residual as a
defect, and #128's fix addressed only the direction of approach that ends at
the code. docs/agents/domain.md sends a reader asking "did we consider
signing these ids?" to docs/adr/, and until now that search found nothing.
ADR-0002 is the precedent: an option weighed and rejected, with no code change
to show for it, is still a decision the project has to be able to find.