107 lines
5.9 KiB
C++
107 lines
5.9 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<b", "escape: lt");
|
||
|
|
CheckEq(Escape("a>b"), "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>"),
|
||
|
|
"<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><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");
|
||
|
|
CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto");
|
||
|
|
CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment");
|
||
|
|
// 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<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<", "operator+: escapes preserved");
|
||
|
|
|
||
|
|
if (failures != 0) {
|
||
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
return 0;
|
||
|
|
}
|