/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // The Html layer — escaping, attribute building and the URL scheme allowlist. // Catcrafts.Shared is the security boundary for every piece of markup the // site emits, and this suite is the direct test of that boundary. import std; import Catcrafts.Shared; using namespace Catcrafts; namespace { int failures = 0; void Check(bool ok, std::string_view what, std::string_view got = {}) { if (ok) return; ++failures; std::println(std::cerr, "FAIL: {}{}{}", what, got.empty() ? "" : " got: ", got); } void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) { Check(actual.View() == expected, what, actual.View()); } } // namespace int main() { using namespace Catcrafts::Html; // ── Escape ──────────────────────────────────────────────────────── CheckEq(Escape("plain"), "plain", "escape: passthrough"); CheckEq(Escape("ab"), "a>b", "escape: gt"); CheckEq(Escape("a&b"), "a&b", "escape: amp"); CheckEq(Escape("say \"hi\""), "say "hi"", "escape: dquote"); CheckEq(Escape("it's"), "it's", "escape: squote"); // Ampersand must be escaped first or the other replacements get // double-encoded; a single pass makes that ordering bug impossible. CheckEq(Escape("<"), "&lt;", "escape: no double-encode"); CheckEq(Escape(""), "<script>alert(1)</script>", "escape: script tag"); // Non-ASCII passes through untouched — the output is UTF-8, and // entity-encoding it would just bloat the page. CheckEq(Escape("café ✓ 日本"), "café ✓ 日本", "escape: utf-8 passthrough"); CheckEq(Escape(""), "", "escape: empty"); // ── Num ─────────────────────────────────────────────────────────── CheckEq(Num(0), "0", "num: zero"); CheckEq(Num(-42), "-42", "num: negative"); CheckEq(Num(9007199254740993LL), "9007199254740993", "num: beyond double precision"); // ── Attr ────────────────────────────────────────────────────────── CheckEq(Attr("class", "card"), " class=\"card\"", "attr: basic"); CheckEq(Attr("data-x", "a\"b"), " data-x=\"a"b\"", "attr: value escaped"); CheckEq(Attr("class", ""), "", "attr: empty value omits attribute"); // An invalid name is a programming error, not user data. Emitting // nothing is safer than emitting mangled markup. CheckEq(Attr("on error", "x"), "", "attr: invalid name rejected"); CheckEq(Attr("x>"), " href=\"#\"", "url: data: neutralised"); // Browsers strip control characters before resolving the scheme, so a // naive prefix check would pass this straight through. CheckEq(Url("href", "java\tscript:alert(1)"), " href=\"#\"", "url: embedded tab"); CheckEq(Url("href", " javascript:alert(1)"), " href=\"#\"", "url: leading space"); CheckEq(Url("href", "//evil.example/x"), " href=\"#\"", "url: protocol-relative blocked"); CheckEq(Url("href", "vbscript:x"), " href=\"#\"", "url: vbscript neutralised"); // ── Format ──────────────────────────────────────────────────────── // The compile-time half of this guarantee (raw std::string rejected) is // verified by the build itself — see the negative test in the notes. CheckEq(Format("

{}

", Escape("aa<b", "format: escapes flow through"); CheckEq(Format("{}", Url("href", "/x"), Escape("go")), "go", "format: attr + text"); CheckEq(Format("{}{}", Num(1), Num(2)), "12", "format: multiple args"); CheckEq(Format("literal"), "literal", "format: no args"); CheckEq(Format("{{literal braces}}"), "{literal braces}", "format: brace escaping"); // ── Join / concat ───────────────────────────────────────────────── const std::array parts{ Escape("a"), Escape("b"), Escape("c") }; CheckEq(Join(parts, Raw(", ")), "a, b, c", "join: separator"); CheckEq(Join(std::span{}), "", "join: empty"); CheckEq(Escape("a") + Escape("<"), "a<", "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("a & b"), "<b>a & b</b>", "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 "" instead of swallowing it into the href. CheckEq(Autolink("https://x.example"), "<b>https://x.example</b>", "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"), "https://x.example/?a=1&b=2", "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 . CheckEq(Autolink("See https://catcrafts.net/analytics."), "See " "https://catcrafts.net/analytics.", "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 https://x.example/y)", "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"), "" "https://en.wikipedia.org/wiki/Foo_(bar) 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; } return 0; }