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:
@@ -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