/* 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. */ // HTML construction with escaping enforced by the type system. // // The problem this solves: Crafter.Graphics has no setAttribute-shaped API for // most of what a page needs, so markup is built as strings and handed to // SetInnerHTML. Any product name, post title or user-supplied field // interpolated into one of those strings is an XSS sink, and "remember to // escape" is not a strategy that survives a codebase. // // So: SafeHtml is an opaque wrapper whose constructors from string types are // DELETED. The only ways to obtain one are Escape() (escapes), Num()/Money() // (can't contain markup), Url() (scheme-allowlisted), and Raw() (the single // audited escape hatch). Format() then accepts only SafeHtml arguments, so // // Html::Format("

{}

", post.title) // std::string -> COMPILE ERROR // Html::Format("

{}

", Escape(title)) // ok // // The failure mode for forgetting to escape is a build failure, not a stored // cross-site-scripting bug. export module Catcrafts.Shared:Html; import std; namespace Catcrafts::Html { export class SafeHtml { public: SafeHtml() = default; // Deleted so no string type can become SafeHtml implicitly. Without // these, `SafeHtml h = userInput;` would silently compile and the whole // guarantee would be decorative. SafeHtml(const char*) = delete; SafeHtml(std::string) = delete; SafeHtml(std::string_view) = delete; // Str() returns a reference (not a copy) because Format() feeds these // to std::make_format_args, which in C++ binds Args&... and therefore // needs lvalues. const std::string& Str() const noexcept { return v_; } std::string_view View() const noexcept { return v_; } bool Empty() const noexcept { return v_.empty(); } std::size_t Size() const noexcept { return v_.size(); } SafeHtml& operator+=(const SafeHtml& r) { v_ += r.v_; return *this; } friend SafeHtml operator+(SafeHtml l, const SafeHtml& r) { l += r; return l; } private: // Private tagged ctor: the ONLY path from a raw string into the type. // Every friend below is a function that has established the string is // safe to emit, either by escaping it or by generating it itself. struct TrustedTag {}; SafeHtml(TrustedTag, std::string v) : v_(std::move(v)) {} std::string v_; friend SafeHtml Escape(std::string_view); friend SafeHtml Raw(std::string_view); friend SafeHtml Num(std::int64_t); friend SafeHtml Attr(std::string_view, std::string_view); friend SafeHtml Url(std::string_view, std::string_view); friend SafeHtml Join(std::span, const SafeHtml&); template friend SafeHtml FormatImpl(std::string_view, const Ts&...); }; // Escape for both text and attribute contexts in a single pass. // // Quotes are escaped even though they are harmless in text content, so that // ONE function is correct in every context. The alternative — a text escaper // and an attribute escaper — means every call site is a chance to pick wrong, // which is the bug this module exists to prevent. export SafeHtml Escape(std::string_view text) { std::string out; out.reserve(text.size() + text.size() / 8); for (const char c : text) { switch (c) { case '&': out += "&"; break; case '<': out += "<"; break; case '>': out += ">"; break; case '"': out += """; break; case '\'': out += "'"; break; default: out += c; break; } } return SafeHtml(SafeHtml::TrustedTag{}, std::move(out)); } // Integers can't carry markup, so they pass through unescaped. export SafeHtml Num(std::int64_t n) { return SafeHtml(SafeHtml::TrustedTag{}, std::to_string(n)); } // The single escape hatch. Every call is a claim that the argument is markup // this codebase generated. Kept greppable and lint-gated to a small allowlist // of files — if it starts appearing in view code, the discipline has failed. export SafeHtml Raw(std::string_view trustedMarkup) { return SafeHtml(SafeHtml::TrustedTag{}, std::string(trustedMarkup)); } // `name="escaped-value"`, including the leading space, or empty when the // value is empty — so optional attributes compose without leaving stray // whitespace or a bare `alt=""` where none was wanted. // // The name is validated rather than escaped: an attribute name is never // user data in this codebase, and silently emitting a mangled one would // hide a bug. An invalid name yields nothing. export SafeHtml Attr(std::string_view name, std::string_view value) { for (const char c : name) { const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':'; if (!ok) return SafeHtml{}; } if (name.empty() || value.empty()) return SafeHtml{}; std::string out = " "; out += name; out += "=\""; out += Escape(value).Str(); out += '"'; return SafeHtml(SafeHtml::TrustedTag{}, std::move(out)); } // href/src emission with a scheme allowlist. // // Escaping alone does not make a URL safe: `javascript:alert(1)` contains no // character that needs escaping, so an escaped-but-unvalidated href is still // script execution. Anything not clearly http/https/mailto or site-relative // is replaced with "#" rather than dropped, so a bad link is visibly inert // instead of silently vanishing from the markup. export SafeHtml Url(std::string_view attrName, std::string_view href) { auto startsWithNoCase = [](std::string_view s, std::string_view prefix) { if (s.size() < prefix.size()) return false; for (std::size_t i = 0; i < prefix.size(); ++i) { char a = s[i]; if (a >= 'A' && a <= 'Z') a = static_cast(a - 'A' + 'a'); if (a != prefix[i]) return false; } return true; }; // Leading control characters and whitespace are stripped by browsers // before scheme detection, so "java\tscript:" would slip past a naive // prefix test. Strip them here first and validate what remains. std::string cleaned; cleaned.reserve(href.size()); for (const char c : href) { if (static_cast(c) > 0x20) cleaned += c; } const bool safe = startsWithNoCase(cleaned, "https://") || startsWithNoCase(cleaned, "http://") || startsWithNoCase(cleaned, "mailto:") // Site-relative, but NOT protocol-relative ("//evil.example" would // leave the origin while looking like a path). || (cleaned.size() >= 1 && cleaned[0] == '/' && !(cleaned.size() >= 2 && cleaned[1] == '/')) || (!cleaned.empty() && cleaned[0] == '#'); return Attr(attrName, safe ? std::string_view(cleaned) : std::string_view("#")); } export SafeHtml Join(std::span parts, const SafeHtml& sep = {}) { std::string out; std::size_t total = 0; for (const SafeHtml& p : parts) total += p.Size() + sep.Size(); out.reserve(total); bool first = true; for (const SafeHtml& p : parts) { if (!first) out += sep.Str(); out += p.Str(); first = false; } return SafeHtml(SafeHtml::TrustedTag{}, std::move(out)); } // Only SafeHtml may be interpolated. export template concept Safe = std::same_as, SafeHtml>; template SafeHtml FormatImpl(std::string_view fmt, const Ts&... args) { return SafeHtml(SafeHtml::TrustedTag{}, std::vformat(fmt, std::make_format_args(args.Str()...))); } // Maps each SafeHtml parameter to std::string for the format-string check, // so std::format_string validates placeholder count and syntax at compile // time against the real argument list. template using AsString = std::string; // The gate. Two properties, both enforced by the signature: // * std::format_string means the template must be a compile-time constant, // so a runtime-assembled template can't be smuggled in; // * `Safe... Ts` means every argument is already SafeHtml, so a bare // std::string, const char*, int or string_view fails to compile. export template SafeHtml Format(std::format_string...> fmt, const Ts&... args) { return FormatImpl(fmt.get(), args...); } } // namespace Catcrafts::Html