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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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') || '';'''
|
||||
|
||||
@@ -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) <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
|
||||
|
||||
Reference in New Issue
Block a user