This commit is contained in:
parent
fb2f6079cc
commit
934c94cb5c
50 changed files with 10464 additions and 758 deletions
207
shared/interfaces/Catcrafts.Shared-Html.cppm
Normal file
207
shared/interfaces/Catcrafts.Shared-Html.cppm
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/*
|
||||
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
|
||||
Loading…
Reference in a new issue