catcrafts.net/tests/ShouldEscapeHtml/main.cpp
Jorijn van der Graaf abbd616b40
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
donation item, shop soft open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:04:03 +02:00

183 lines
11 KiB
C++

/*
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("a<b"), "a&lt;b", "escape: lt");
CheckEq(Escape("a>b"), "a&gt;b", "escape: gt");
CheckEq(Escape("a&b"), "a&amp;b", "escape: amp");
CheckEq(Escape("say \"hi\""), "say &quot;hi&quot;", "escape: dquote");
CheckEq(Escape("it's"), "it&#39;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;"), "&amp;lt;", "escape: no double-encode");
CheckEq(Escape("<script>alert(1)</script>"),
"&lt;script&gt;alert(1)&lt;/script&gt;", "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&quot;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><script", "y"), "", "attr: name cannot break out");
// ── 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");
CheckEq(Url("href", "JaVaScRiPt:alert(1)"), " href=\"#\"", "url: case-insensitive");
CheckEq(Url("href", "data:text/html,<script>"), " 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("<h2>{}</h2>", Escape("a<b")), "<h2>a&lt;b</h2>", "format: escapes flow through");
CheckEq(Format("<a{}>{}</a>", Url("href", "/x"), Escape("go")),
"<a href=\"/x\">go</a>", "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<Html::SafeHtml, 3> parts{ Escape("a"), Escape("b"), Escape("c") };
CheckEq(Join(parts, Raw(", ")), "a, b, c", "join: separator");
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;
}
return 0;
}