From 65e7dc2a4e5239b0131808f2b5f8dcdec3727221 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 11:31:20 -0700 Subject: [PATCH] fix(board): a link row could rewrite the dialog that authorises its deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- booth/templates/booth.html | 20 ++++++++++++++++++-- tests/mutations/u7_navigation.toml | 9 +++++++++ tests/test_booth.py | 25 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 76a6549..bcb4c99 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -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) { btn.addEventListener('click', function (ev) { - var d = btn.getAttribute('data-desc') || ''; - var u = btn.getAttribute('data-url') || ''; + var d = shown(btn.getAttribute('data-desc') || ''); + 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.')) { ev.preventDefault(); } diff --git a/tests/mutations/u7_navigation.toml b/tests/mutations/u7_navigation.toml index 3b1bf02..1e1c6c7 100644 --- a/tests/mutations/u7_navigation.toml +++ b/tests/mutations/u7_navigation.toml @@ -203,3 +203,12 @@ old = ''' return parts.scheme.lower() in ("http", "https")''' new = ''' 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') || '';''' diff --git a/tests/test_booth.py b/tests/test_booth.py index cf7ff60..c481e9a 100644 --- a/tests/test_booth.py +++ b/tests/test_booth.py @@ -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" # refused, not hidden: the operator sees that it was posted 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) · rogue · 2026-09-23 10:00\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