catcrafts.net/shared/interfaces/Catcrafts.Shared-Html.cppm

207 lines
8.5 KiB
Text
Raw Normal View History

2026-08-05 04:18:37 +02:00
/*
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("<h2>{}</h2>", post.title) // std::string -> COMPILE ERROR
// Html::Format("<h2>{}</h2>", 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
2026-08-05 22:32:03 +02:00
// to std::make_format_args, which in C++ binds Args&... and therefore
2026-08-05 04:18:37 +02:00
// 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>, const SafeHtml&);
template <class... Ts> 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 += "&amp;"; break;
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&#39;"; 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<char>(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<unsigned char>(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<const SafeHtml> 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 <class T>
concept Safe = std::same_as<std::remove_cvref_t<T>, SafeHtml>;
template <class... Ts>
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 <class T> 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 <Safe... Ts>
SafeHtml Format(std::format_string<AsString<Ts>...> fmt, const Ts&... args) {
return FormatImpl(fmt.get(), args...);
}
} // namespace Catcrafts::Html