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(
+ "- [innocentgnihtemos 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