This commit is contained in:
parent
ae30ac69f7
commit
2f5ccd272e
3 changed files with 107 additions and 16 deletions
|
|
@ -125,6 +125,18 @@ export SafeHtml Attr(std::string_view name, std::string_view value) {
|
|||
return SafeHtml(SafeHtml::TrustedTag{}, std::move(out));
|
||||
}
|
||||
|
||||
// Case-insensitive prefix test, for scheme detection. Not exported: schemes
|
||||
// are the only thing in this module that needs it.
|
||||
bool 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;
|
||||
}
|
||||
|
||||
// href/src emission with a scheme allowlist.
|
||||
//
|
||||
// Escaping alone does not make a URL safe: `javascript:alert(1)` contains no
|
||||
|
|
@ -133,16 +145,6 @@ export SafeHtml Attr(std::string_view name, std::string_view value) {
|
|||
// 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.
|
||||
|
|
@ -153,9 +155,9 @@ export SafeHtml Url(std::string_view attrName, std::string_view href) {
|
|||
}
|
||||
|
||||
const bool safe =
|
||||
startsWithNoCase(cleaned, "https://")
|
||||
|| startsWithNoCase(cleaned, "http://")
|
||||
|| startsWithNoCase(cleaned, "mailto:")
|
||||
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] == '/'
|
||||
|
|
@ -204,4 +206,87 @@ SafeHtml Format(std::format_string<AsString<Ts>...> fmt, const Ts&... args) {
|
|||
return FormatImpl(fmt.get(), args...);
|
||||
}
|
||||
|
||||
// Escape(), plus bare URLs in the text become anchors.
|
||||
//
|
||||
// The legal and about pages are stored as plain sentences rather than
|
||||
// markdown (see LegalSection), so a URL written into one rendered as inert
|
||||
// text the reader had to select and retype. Everything is still escaped; the
|
||||
// only addition is that http/https runs are wrapped in <a>, with the href
|
||||
// going through Url() so an autolinked address passes exactly the same scheme
|
||||
// check as a hand-written one. Built from Escape/Url/Format/Join alone — no
|
||||
// Raw(), so this adds no new trusted path into SafeHtml.
|
||||
//
|
||||
// An explicit scheme is required. "example.com" mid-sentence is a guess that
|
||||
// would eventually link something that is not a URL; "https://example.com" is
|
||||
// the author saying outright that this is a link.
|
||||
export SafeHtml Autolink(std::string_view text) {
|
||||
// What ends a URL in prose. Everything else is taken greedily and then
|
||||
// trimmed below, which is the only way to get "(see https://x/y)" right.
|
||||
auto terminates = [](char c) {
|
||||
return static_cast<unsigned char>(c) <= 0x20
|
||||
|| c == '<' || c == '>' || c == '"' || c == '\'' || c == '`';
|
||||
};
|
||||
|
||||
std::vector<SafeHtml> parts;
|
||||
std::size_t pos = 0;
|
||||
while (pos < text.size()) {
|
||||
std::size_t at = text.size();
|
||||
for (std::size_t i = pos; i < text.size(); ++i) {
|
||||
if (!StartsWithNoCase(text.substr(i), "https://")
|
||||
&& !StartsWithNoCase(text.substr(i), "http://")) continue;
|
||||
// A scheme has to start a word: "shttps://" is not a link, and
|
||||
// neither is the tail of some longer token. Tested against the
|
||||
// real previous character, not `pos` — two URLs run together
|
||||
// ("...a.comhttps://b") must not link the second.
|
||||
if (i > 0) {
|
||||
const char prev = text[i - 1];
|
||||
const bool word = (prev >= 'a' && prev <= 'z')
|
||||
|| (prev >= 'A' && prev <= 'Z')
|
||||
|| (prev >= '0' && prev <= '9');
|
||||
if (word) continue;
|
||||
}
|
||||
at = i;
|
||||
break;
|
||||
}
|
||||
if (at == text.size()) break;
|
||||
|
||||
std::size_t end = at;
|
||||
while (end < text.size() && !terminates(text[end])) ++end;
|
||||
|
||||
// Trailing punctuation belongs to the sentence, not to the URL:
|
||||
// "...at https://catcrafts.net/analytics." must not link the period.
|
||||
// A closing bracket is kept only when the URL itself opened one.
|
||||
while (end > at) {
|
||||
const char last = text[end - 1];
|
||||
if (last == '.' || last == ',' || last == ';' || last == ':'
|
||||
|| last == '!' || last == '?') { --end; continue; }
|
||||
if (last == ')' || last == ']' || last == '}') {
|
||||
const char open = last == ')' ? '(' : last == ']' ? '[' : '{';
|
||||
const std::string_view sofar = text.substr(at, end - at);
|
||||
if (std::ranges::count(sofar, open)
|
||||
>= std::ranges::count(sofar, last)) break;
|
||||
--end;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// A scheme with nothing after it is not a link. Emit it as text and
|
||||
// resume past it, so a degenerate "https://" can't stall the loop.
|
||||
const std::string_view url = text.substr(at, end - at);
|
||||
const std::size_t host = url.find("//");
|
||||
if (host == std::string_view::npos || host + 2 >= url.size()) {
|
||||
parts.push_back(Escape(text.substr(pos, end - pos)));
|
||||
pos = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.push_back(Escape(text.substr(pos, at - pos)));
|
||||
parts.push_back(Format(R"(<a{}>{}</a>)", Url("href", url), Escape(url)));
|
||||
pos = end;
|
||||
}
|
||||
parts.push_back(Escape(text.substr(pos)));
|
||||
return Join(parts);
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Html
|
||||
|
|
|
|||
Loading…
Reference in a new issue