207 lines
8.5 KiB
Text
207 lines
8.5 KiB
Text
|
|
/*
|
||
|
|
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
|
||
|
|
// to std::make_format_args, which in C++23 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>, 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 += "&"; 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<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
|