fix(board): a link row could rewrite the dialog that authorises its deletion

Found by design-dev, the same class as the wipe dialog he had just fixed on the
Desk, and reported across the fence rather than kept.

A board row's description and URL are written by any of seventeen agent handles
and were pasted RAW into the `confirm()` the operator reads before approving a
delete. A bidi override (U+202E) or a newline in either re-orders or hides what
he is consenting to, so the row shown is not the row removed.

Escaping does nothing here and that is the trap: autoescape protects the PAGE,
but `confirm` renders a plain string, so the markup defence everyone reaches for
first is irrelevant to the surface that actually carries the decision.

Control and bidi formatting characters now render as U+FFFD — visibly mangled,
never silently re-ordered — through the same helper shape design-dev used, so
the two dialogs cannot drift apart.

Both arguments go through it, and the mutation row defeats exactly that: taking
the raw description back for one of the two turns the test red. 763 green.
This commit is contained in:
vh
2026-09-23 11:31:20 -07:00
parent 995e7b9686
commit 65e7dc2a4e
3 changed files with 52 additions and 2 deletions
+18 -2
View File
@@ -651,10 +651,26 @@
}); });
} }
/* ⚠ THE DIALOG'S TEXT IS WHAT THE OPERATOR APPROVES, and a board row's
description and URL are written by any of seventeen agent handles. A bidi
override (U+202E) or a newline in either REWRITES what he reads before
consenting to a delete — the row shown is not the row removed. Escaping
protects the PAGE; `confirm` renders a plain string and escaping does
nothing for it.
Controls and bidi formatting render as U+FFFD: visibly mangled, never
silently re-ordered. Same treatment and same helper shape as the wipe
dialog on the Desk (design-dev, round Slate, who found this one too). */
function shown(n) {
return String(n).replace(
/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g,
'\ufffd');
}
form.querySelectorAll('.board-rm-btn').forEach(function (btn) { form.querySelectorAll('.board-rm-btn').forEach(function (btn) {
btn.addEventListener('click', function (ev) { btn.addEventListener('click', function (ev) {
var d = btn.getAttribute('data-desc') || ''; var d = shown(btn.getAttribute('data-desc') || '');
var u = btn.getAttribute('data-url') || ''; var u = shown(btn.getAttribute('data-url') || '');
if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) { if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) {
ev.preventDefault(); ev.preventDefault();
} }
+9
View File
@@ -203,3 +203,12 @@ old = '''
return parts.scheme.lower() in ("http", "https")''' return parts.scheme.lower() in ("http", "https")'''
new = ''' new = '''
return True''' return True'''
[[mutation]]
label = "the board delete dialog takes the raw agent-written description"
file = "booth/templates/booth.html"
test = "tests/test_booth.py::test_the_board_delete_dialog_cannot_be_rewritten_by_a_link_row"
old = '''
var d = shown(btn.getAttribute('data-desc') || '');'''
new = '''
var d = btn.getAttribute('data-desc') || '';'''
+25
View File
@@ -1625,3 +1625,28 @@ def test_the_link_board_refuses_to_render_a_script_href(tmp_path):
assert f'href="{bad}' not in html, f"{bad} rendered as an href" assert f'href="{bad}' not in html, f"{bad} rendered as an href"
# refused, not hidden: the operator sees that it was posted # refused, not hidden: the operator sees that it was posted
assert "evil.test" in html, "the refused row vanished instead of being shown inert" assert "evil.test" in html, "the refused row vanished instead of being shown inert"
def test_the_board_delete_dialog_cannot_be_rewritten_by_a_link_row(tmp_path):
"""A board row's description and URL come from any of seventeen agent
handles, and they are pasted into a `confirm()` dialog — which is the text
the operator reads before approving a delete.
Escaping protects the PAGE and does nothing here: `confirm` renders a plain
string, so a bidi override (U+202E) or a newline re-orders or hides what he
is consenting to, and the row shown is not the row removed.
Found by design-dev, the same class as the wipe dialog he had just fixed on
the Desk. Defeating change: dropping `shown()` from either argument."""
b = tmp_path / "links"
b.mkdir()
b.joinpath("links.md").write_text(
"- [innocent‮gnihtemos esle](https://ok.test/a) <sub>· rogue · 2026-09-23 10:00</sub>\n"
)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/links/").text
assert "function shown(" in html, "the dialog sanitiser is gone"
# both arguments must go through it, not just one
assert "shown(btn.getAttribute('data-desc')" in html
assert "shown(btn.getAttribute('data-url')" in html