Flag lookalike link domains for the moderator, never for the door (#41)#117
Conversation
…for the door #4 decided the renderer links `https://аpple.example/` with a Cyrillic а, because it is a genuine https URL and a lookalike domain is a judgement about intent rather than an injection. This is the other half of that decision: the judgement goes to the human gate, and layer 5 now names it. Detection is UTS #39 mixed-script detection (§5.1), restricted to the three scripts the standard itself carves out — a "Moderately Restrictive" identifier may mix Latin with any one other Recommended script *except* Cyrillic and Greek, because only those two have Latin twins. So Latin + Han + Kana stays clean and `аpple` does not. Script data comes from the engine's own tables via `\p{Script_Extensions=…}`, so there is no table here to go stale and no dependency to ship. `review`, never `reject`, and the reason is argued in the file: an internationalised domain is a real domain for real people, nothing in the string separates one from a disguise, and rejecting would fall silently on exactly the readers who did nothing wrong. Judged per label rather than per host — `пример.com` mixes scripts as a host and nothing as a label, and judging the host would flag the whole IDN namespace. The renderer is untouched, and a test asserts the markup for such a link is byte-for-byte what it was. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Coordinator verificationThe discrimination risk is genuinely handled — I probed it directlyThis was the concern in the brief: a heuristic that flags non-Latin script would silently penalise readers whose domains are legitimately not in Latin. I wrote my own probe against nine real internationalised domains rather than reading the tests: And both attacks still caught: Judging per label rather than per host is the decision that makes this work. Nearly every IDN sits under an ASCII TLD, so Restricting to Latin/Cyrillic/Greek is the standard's carve-out, not a shortcutI had assumed this would be an approximation. It is not: UTS #39 §5.2 "Moderately Restrictive" permits Latin beside any one other Recommended script except Cyrillic and Greek, and "Highly Restrictive" explicitly permits Latin + Han + Hiragana + Katakana. So The two findings I would not have anticipated
A real false positive found before the tests existed: Two guards deleted rather than keptA port test that passed with its guard disabled, and a header claim naming four scripts as test-enforced when only Han actually was. Deleting a test that cannot fail, and correcting a comment that overstated its coverage, is the right response — both were the exact defect CLAUDE.md describes, where the artefact sits where a reader goes to verify the property. Correctly titled "Advances", and the limits are asserted rather than describedWhole-script confusables ( The signal reaches the Worker log today and no further, because |
Advances #41. It does not close it — see "What this does not catch" and "Where the signal goes today".
Deferred out of #4, which decided the renderer links
https://аpple.example/with a Cyrillic а because it is a genuine https URL with nothing executable in it. That decision is untouched here. A lookalike domain is a judgement about intent, so it goes to the human gate instead, and layer 5 now names it.The detection, and its sources
Mixed-script within a single label, not "contains non-Latin script".
Script_Extensionsrather thanScript, as the standard specifies.\p{Script_Extensions=…}with theuflag, ES2018, baseline since 2020. No new dependency and no table in this repo: the script data is the engine's own, so there is nothing here to go stale. workerd is V8 and the tests run in workerd, so support is asserted rather than assumed.new URL(): the parser runs the host through IDNA ToASCII, soаpple.examplecomes backxn--pple-43d.exampleand the Unicode form, which is the entire subject of the check, is gone. Checked 2026-07-25:new URL('https://аpple.example/').hostname→xn--pple-43d.example.Percent-decoding is load-bearing and verified rather than assumed:
new URL('https://%D0%B0pple.example/').hostname→xn--pple-43d.example, checked 2026-07-25. A browser resolves that to the lookalike host; a reader cannot decode it; undecoded it is pure ASCII and would have sailed past.Why
reviewand neverrejectArgued in the file, not just here.
пример.рфis a Russian domain the wayexample.ukis a British one, and no property of the string separates "IDN belonging to someone who does not write in Latin" from "IDN chosen to impersonate a Latin brand" — only intent does, and intent is what a moderator has and a regular expression does not. A reject would therefore fall entirely on non-Latin-script readers, silently, in the name of protecting them. That is a discrimination bug wearing a spam control's clothes.The same reasoning produces the per-label rule. Nearly every IDN sits under an ASCII TLD, so
пример.commixes scripts as a host while no label of it mixes anything. Judging the host would flag the entire IDN namespace.What this does not catch
Stated in the file as well, so it is visible to whoever picks it up:
аррӏе.exampleis Cyrillic end to end and reads as "apple". Nothing is mixed, so no mixed-script measure can see it. UTS Optional page-key canonicalisation: path case folding, index and extension folding #39 answers this with a separate mechanism (§4, Confusable Detection) needing the confusables mapping — thousands of entries — plus a list of names worth impersonating, because the alternative rule, "flag every wholly non-Latin label", is the discrimination bug with extra steps. There is a test asserting this gap rather than a comment implying it is covered.xn--label is ASCII, so it is never mixed here. Deciding it needs a real decoder, not a prefix match:xn--is exactly how a legitimate Cyrillic domain looks when copied out of the address bar. Named in Flag lookalike link domains in the moderation queue #41's body; still open there.Both of the first two remain on #41, which is why this is "Advances".
Where the signal goes today — please read before assuming this is finished
The verdict is
content: lookalike-domain, and today it reaches the Worker log only.src/submit/pipeline.tsreads the verdict'sactionand drops itsreason, which is #70 (owned by another session). So a moderator opening the queue sees the held comment but not why.Rather than imply otherwise, there is an end-to-end test that drives the real Worker and asserts the log line — the whole of the feature's output today:
Once #70 carries the reason through, the signal lands in the queue with no change needed here.
Kill-shots (card rule 6)
Every guard disabled, test run, restored. Real output,
pnpm vitest run test/worker/spam/lookalike.test.ts.isMixedScriptreturnsfalseTests 9 failed | 14 passed— × Cyrillic а, × Greek ο, × userinfo host, × percent-encoded @, × percent-encoding, × holds for review, × is never a reject, × www-prefixed, × layer holdsTests 1 failed | 22 passed— × judges each label on its own, so a Cyrillic name under a Latin TLD is fineTests 1 failed | 22 passed— × does not flag Latin mixed with a script that is not confusable with itTests 2 failed | 21 passed— × percent-encoded @, × sees through percent-encodingTests 2 failed | 21 passed— × reads the host after any userinfo, × percent-encoded @Tests 1 failed | 22 passed— × stops the host at punctuation, so a link written mid-sentence is not flaggedlookalikeOutcomereturnsrejectTests 3 failed | 19 passed— × holds for review and names why, × is never a reject, × layer holds a comment whose only fault is a lookalikecontent.ts)Tests 1 failed | 37 passed(withcontent.test.ts) — × still rejects a link flood that happens to contain a lookalikemarkdown.ts, reverted)Tests 1 failed | 21 passed— × renders a lookalike link exactly as it did before this signal existedTwo guards were removed rather than kept because they could not be shot:
рф:8443resolves to Cyrillic either way. It was coverage theatre; it is gone, and the file says why there is no port case.Both of those came out of the review pass, along with a real false positive found before the tests existed:
https://пример.рф,and— the link pattern stops at whitespace, so a comma written after a URL came along with it, andрф,andis Cyrillic beside Latin. A comma has done nothing wrong. Fixed by cutting the host at the first character that cannot be in a domain name, and kill-shot 6 is that guard.pnpm checkSkills used:
security-and-hardening,source-driven-development(every Unicode and URL claim above is a fetched citation, none from memory),test-driven-development(failing test first — the first run failed on the missing module),improveover the branch,code-review-and-qualityincluding a fresh-context adversarial reviewer, whose three findings are all addressed above,superpowers:verification-before-completion.Scope:
src/spam/lookalike.ts(new),src/spam/content.ts,test/worker/spam/lookalike.test.ts. No dependency added;package.jsonandpnpm-lock.yamluntouched. Nothing inmigrations/,src/db/,src/render/,src/embed/or the dashboard.🤖 Generated with Claude Code