donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -69,8 +69,25 @@ int main() {
// ── Url ───────────────────────────────────────────────────────────
CheckEq(Url("href", "/shop/thing"), " href=\"/shop/thing\"", "url: site-relative");
CheckEq(Url("href", "https://a.example/x"), " href=\"https://a.example/x\"", "url: https");
// Plain http is on the allowlist too. Not merely tolerated: a link the
// author wrote as http must survive as http rather than turn into an
// inert "#", because a silently dead link is worse than an insecure one.
CheckEq(Url("href", "http://x.example/a"), " href=\"http://x.example/a\"", "url: http");
CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto");
CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment");
// The EIP-681 pay link the crypto rail hands the buyer. Eurc builds it as
// ethereum:{contract}@{chainId}/transfer?address={to}&uint256={units}
// and the order page emits it through Url(). Two things must hold at once:
// ethereum: stays on the allowlist (drop it and every crypto pay button
// becomes href="#", i.e. nobody on that rail can pay), and the query
// separator is still escaped to &amp; like any other attribute value.
CheckEq(Url("href", "ethereum:0xAbC@8453/transfer?address=0xDeF&uint256=1000000"),
" href=\"ethereum:0xAbC@8453/transfer?address=0xDeF&amp;uint256=1000000\"",
"url: ethereum: EIP-681 kept verbatim, ampersand escaped");
// Empty href fails every allowlist branch — including the site-relative
// one, which needs at least one character — so it lands on "#" rather
// than emitting a link that resolves to the current page.
CheckEq(Url("href", ""), " href=\"#\"", "url: empty falls back to #");
// Escaping alone would NOT make these safe: they contain no character
// that needs escaping, so only a scheme allowlist stops them.
CheckEq(Url("href", "javascript:alert(1)"), " href=\"#\"", "url: javascript: neutralised");
@ -99,6 +116,65 @@ int main() {
CheckEq(Join(std::span<const Html::SafeHtml>{}), "", "join: empty");
CheckEq(Escape("a") + Escape("<"), "a&lt;", "operator+: escapes preserved");
// ── Autolink: escaping ────────────────────────────────────────────
// Autolink, not Escape, is what every prose paragraph on /legal/*, /about
// and /financials goes through (Views), and what Markdown hands its
// paragraph bodies. So it is the real escaper on those pages: if it ever
// stops escaping, that is stored XSS on the policy text.
CheckEq(Autolink("<b>a & b</b>"), "&lt;b&gt;a &amp; b&lt;/b&gt;",
"autolink: escapes text with no URL in it");
// With a URL present the non-URL runs still go through Escape. The '<'
// also doubles as a URL terminator here, which is why the anchor stops
// before "</b>" instead of swallowing it into the href.
CheckEq(Autolink("<b>https://x.example</b>"),
"&lt;b&gt;<a href=\"https://x.example\">https://x.example</a>&lt;/b&gt;",
"autolink: markup around a URL stays escaped");
// The URL text is escaped on BOTH sides of the anchor — attribute and
// text node — because href goes through Url() (which calls Attr, which
// escapes) and the label goes through Escape(). A '&' in a query string
// is the everyday case that proves it.
CheckEq(Autolink("https://x.example/?a=1&b=2"),
"<a href=\"https://x.example/?a=1&amp;b=2\">https://x.example/?a=1&amp;b=2</a>",
"autolink: ampersand escaped in href and in anchor text");
// An explicit http/https scheme is required, so nothing else becomes a
// link — least of all a scheme Url() would have had to neutralise.
CheckEq(Autolink("ftp://x.example/a"), "ftp://x.example/a",
"autolink: non-http scheme is not linked");
CheckEq(Autolink("javascript:alert(1)"), "javascript:alert(1)",
"autolink: javascript: is text, never an anchor");
// ── Autolink: URL boundaries ──────────────────────────────────────
// The privacy notice ends a sentence with a bare address. A period pulled
// into the href is a 404 for every reader who clicks it, so the trailing
// sentence punctuation is trimmed back out of the URL and re-emitted as
// escaped text after the </a>.
CheckEq(Autolink("See https://catcrafts.net/analytics."),
"See <a href=\"https://catcrafts.net/analytics\">"
"https://catcrafts.net/analytics</a>.",
"autolink: trailing period stays outside the anchor");
// Same rule for a closing bracket the URL did not open: "(see .../y)" has
// zero '(' inside the matched run and one ')', so the ')' is given back.
CheckEq(Autolink("(see https://x.example/y)"),
"(see <a href=\"https://x.example/y\">https://x.example/y</a>)",
"autolink: unmatched closing paren stays outside the anchor");
// But a bracket the URL DID open is part of it — one '(' and one ')' in
// the run, so the count test holds and the paren is kept in both href and
// text. Wikipedia disambiguation links are the reason this rule exists.
CheckEq(Autolink("https://en.wikipedia.org/wiki/Foo_(bar) end"),
"<a href=\"https://en.wikipedia.org/wiki/Foo_(bar)\">"
"https://en.wikipedia.org/wiki/Foo_(bar)</a> end",
"autolink: balanced paren kept inside the anchor");
// A scheme has to start a word. The "https://" here begins at index 1
// with a letter before it, so it is the tail of a longer token, not a
// link — the guard that stops two run-together URLs linking the second.
CheckEq(Autolink("shttps://x.example"), "shttps://x.example",
"autolink: scheme mid-word is not a link");
// A scheme with no host after it: find("//") lands at 6 and 6+2 is not
// less than the 8-char run, so the degenerate case emits escaped text and
// advances pos past it. That advance is the loop-stall guard.
CheckEq(Autolink("https://"), "https://",
"autolink: bare scheme emits text and cannot stall the loop");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;