diff --git a/CHANGELOG.md b/CHANGELOG.md index d513500c..bf019e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ developer-facing record. ## [Unreleased] +### Reflected HTML context handling +- Keep HTML template contents inert, including nested templates and apparent + closing tags inside raw text, comments or attributes. +- Track SVG and MathML namespaces, HTML integration points, foreign-content + breakouts, CDATA and self-closing foreign elements. +- Respect escaped script states and complete tag/attribute boundaries before + changing context. Duplicate annotation encoding attributes use the first value. +- Expand the pinned Chromium corpus to 1,255 context comparisons and add 13 + public API checks. Keep the probe partial: frameset handling, complete tree + construction and proof of script execution remain outside these fixes. + ### Repeater response bytes - Preserve wire responses and inspection body bytes separately in snapshots and per-tab history. Current responses survive project switches and app restarts; diff --git a/Src/Core/Networking/xss_logic.cpp b/Src/Core/Networking/xss_logic.cpp index 0ff528d1..04b2b770 100644 --- a/Src/Core/Networking/xss_logic.cpp +++ b/Src/Core/Networking/xss_logic.cpp @@ -10,6 +10,8 @@ #include "xss_reflected.hpp" #include "response_header_values.hpp" +#include +#include #include #include @@ -73,121 +75,228 @@ bool canExecuteHtml(const QList> &headers) { return isHtmlMediaType(type); } -// Would "" at offset `at` be parsed as a start tag -- i.e. it's in -// normal element content, NOT inside a comment, a raw-text element, or an open -// tag's attributes? Only that position is exploitable; the rest reflect the -// brackets inertly. A reduced HTML5 tokenizer over body[0..at): -// - tracks attribute QUOTING so only an UNQUOTED '>' ends a tag (a '>' inside -// a quoted attribute value must NOT re-enter element content); -// - treats script/style/textarea/title/xmp/noscript/noframes/noembed/iframe as -// raw-text (a marker inside them is inert), and plaintext as irreversible -// raw-text that never closes; -// - closes a raw-text element on ""), and closes a comment on "-->" or the bogus "--!>". +namespace { +bool htmlSpace(QChar c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\f' || c == '\r'; +} +bool tagDelimiter(QChar c) { return htmlSpace(c) || c == '/' || c == '>'; } +bool asciiLetter(QChar c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } +QString asciiLower(QString text) { + for (QChar &c : text) + if (c >= 'A' && c <= 'Z') c = QChar(c.unicode() + ('a' - 'A')); + return text; +} + +struct ContextTag { + QString name; + QHash attributes; + bool closing = false; + bool selfClosing = false; + int end = -1; +}; + +// Only a complete token before the reflection changes context. Quotes open a +// quoted attribute value after '=', not in an unquoted value or an attribute name. +ContextTag contextTag(const QString &body, int start, int limit) { + ContextTag tag; + int i = start + 1; + if (i < limit && body[i] == '/') { tag.closing = true; ++i; } + if (i >= limit || !asciiLetter(body[i])) return tag; + const int nameStart = i; + while (i < limit && !tagDelimiter(body[i])) ++i; + tag.name = asciiLower(body.mid(nameStart, i - nameStart)); + while (i < limit) { + while (i < limit && htmlSpace(body[i])) ++i; + if (i >= limit) break; + if (body[i] == '>') { tag.end = i; return tag; } + if (body[i] == '/') { + if (++i < limit && body[i] == '>') { + tag.selfClosing = true; tag.end = i; return tag; + } + continue; + } + const int attrStart = i++; + while (i < limit && !tagDelimiter(body[i]) && body[i] != '=') ++i; + const QString name = asciiLower(body.mid(attrStart, i - attrStart)); + while (i < limit && htmlSpace(body[i])) ++i; + QString value; + if (i < limit && body[i] == '=') { + ++i; + while (i < limit && htmlSpace(body[i])) ++i; + if (i >= limit) break; + const QChar quote = body[i]; + const bool quoted = quote == '"' || quote == '\''; + if (quoted) ++i; + const int valueStart = i; + while (i < limit && (quoted ? body[i] != quote : !htmlSpace(body[i]) && body[i] != '>')) ++i; + value = body.mid(valueStart, i - valueStart); + if (quoted) { + if (i >= limit) break; + ++i; + } + } + if (!tag.attributes.contains(name)) tag.attributes.insert(name, value); + } + return tag; +} + +bool htmlAnnotationEncoding(const QString &value) { + // The named character references in the accepted ASCII MIME types are + // / and +. Numeric references can encode any of their characters. Decode once. + static const QRegularExpression references("&(?:#([0-9]+);?|#[xX]([0-9a-fA-F]+);?|sol;|plus;)"); + QString decoded; + qsizetype copied = 0; + auto matches = references.globalMatch(value); + while (matches.hasNext()) { + const auto match = matches.next(); + decoded += value.mid(copied, match.capturedStart() - copied); + bool ok = false; + const uint code = match.captured(0) == "+" ? uint('+') + : match.captured(0) == "/" ? uint('/') + : !match.captured(1).isEmpty() ? match.captured(1).toUInt(&ok, 10) + : match.captured(2).toUInt(&ok, 16); + decoded += QChar((ok || code == '/' || code == '+') && code > 0 && code < 128 ? ushort(code) : ushort(0xfffd)); + copied = match.capturedEnd(); + } + decoded += value.mid(copied); + decoded = asciiLower(decoded); + return decoded == "text/html" || decoded == "application/xhtml+xml"; +} + +enum class HtmlNamespace { Html, Svg, Math }; +struct ContextElement { + QString name; + HtmlNamespace space = HtmlNamespace::Html; + bool integration = false; +}; +} // namespace + +// A reduced context parser for the probe's reflected tag. Namespace transitions +// follow HTML's foreign-content rules; HTML templates keep their contents inert. +// This does not implement a full tree builder or prove arbitrary script execution. +// https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inforeign bool inExecutingHtmlContext(const QString &body, int at) { - static const QStringList rawText = { - "script", "style", "textarea", "title", "xmp", "noscript", - "noframes", "noembed", "iframe", "plaintext" }; - auto isDelim = [](QChar c) { - return c == '>' || c == '/' || c == ' ' || c == '\t' - || c == '\n' || c == '\f' || c == '\r'; + static const QStringList rawText = {"script", "style", "textarea", "title", "xmp", + "noscript", "noframes", "noembed", "iframe", "plaintext"}; + static const QStringList voidElements = {"area", "base", "basefont", "bgsound", "br", "col", + "embed", "frame", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr"}; + static const QStringList foreignBreakouts = {"b", "big", "blockquote", "body", "br", "center", + "code", "dd", "div", "dl", "dt", "em", "embed", "h1", "h2", "h3", "h4", "h5", "h6", + "head", "hr", "i", "img", "li", "listing", "menu", "meta", "nobr", "ol", "p", "pre", + "ruby", "s", "small", "span", "strong", "strike", "sub", "sup", "table", "tt", "u", "ul", "var"}; + static const QStringList mathText = {"mi", "mo", "mn", "ms", "mtext"}; + QList elements; + QString openRaw; + int scriptEscape = 0; // 0 = data, 1 = escaped, 2 = double escaped + int templates = 0; + const int limit = qMin(at, body.size()); + if (at < 0) return false; + auto popTo = [&](int index) { + while (elements.size() > index) { + const auto element = elements.takeLast(); + if (element.space == HtmlNamespace::Html && element.name == "template") --templates; + } }; - // HTML5 tag-open / end-tag-open: a tag name starts with an ASCII letter ONLY. - // QChar::isLetter() is Unicode-aware (Cyrillic/Greek/CJK/accented letters all - // return true), so a '<' + a NON-ASCII letter would wrongly open a phantom tag - // and swallow a following reflected -- the same false negative already - // fixed for '<'+space/digit/'=', just missed for non-ASCII letters. - auto isAsciiAlpha = [](QChar c) { - return (c >= QLatin1Char('a') && c <= QLatin1Char('z')) - || (c >= QLatin1Char('A') && c <= QLatin1Char('Z')); + auto starts = [&](int i, const QString &text) { + return asciiLower(body.mid(i, text.size())) == text; }; - QString openRaw; // current raw-text element name, or empty - bool inComment = false; - bool inTag = false; // between '<' and its (unquoted) '>' - QChar quote = QChar(0); // active attribute-value quote inside a tag, or 0 - const int n = qMin(at, body.size()); - for (int i = 0; i < n; ++i) { - if (inComment) { - if (body.mid(i, 3) == QLatin1String("-->")) { inComment = false; i += 2; } - else if (body.mid(i, 4) == QLatin1String("--!>")) { inComment = false; i += 3; } - continue; - } + for (int i = 0; i < limit; ++i) { if (!openRaw.isEmpty()) { - // plaintext is irreversible: nothing after it re-enters markup. - if (openRaw == QLatin1String("plaintext")) continue; - const QString close = "'); - if (isDelim(d)) { - i += close.size() - 1; // resume at the delimiter - openRaw.clear(); - inTag = false; + if (openRaw == "plaintext") return false; + if (openRaw == "script") { + if (scriptEscape == 0 && body.mid(i, 4) == "") { scriptEscape = 0; i += 2; continue; } + if (scriptEscape == 1 && starts(i, "= body.size() + || !tagDelimiter(body[i + close.size()])) continue; + const auto tag = contextTag(body, i, limit); + if (tag.end < 0) return false; + i = tag.end; + if (!elements.isEmpty()) popTo(elements.size() - 1); + openRaw.clear(); scriptEscape = 0; continue; } - const QChar ch = body[i]; - if (inTag) { - if (quote != QChar(0)) { - if (ch == quote) quote = QChar(0); - } else if (ch == '"' || ch == '\'') { - quote = ch; - } else if (ch == '>') { - inTag = false; - } + if (body[i] != '<') continue; + if (body.mid(i, 4) == "") { i += 4; continue; } + if (body.mid(i, 6) == "") { i += 5; continue; } + int end = body.indexOf("-->", i + 4), width = 3; + const int abrupt = body.indexOf("--!>", i + 4); + if (abrupt >= 0 && (end < 0 || abrupt < end)) { end = abrupt; width = 4; } + if (end < 0 || end + width > limit) return false; + i = end + width - 1; continue; } - // element content - if (ch == '<') { - if (body.mid(i, 4) == QLatin1String("")) { i += 4; continue; } - if (body.mid(i, 6) == QLatin1String("")) { i += 5; continue; } - inComment = true; i += 3; continue; - } - // Markup declaration (", , a bogus - // "") or a PI-like "' with NO attribute - // quote-tracking. Routing them through the tag branch lets a '"' - // inside open a fake quoted value that swallows the terminating '>' - // and hides a following ', '', + '', '', '', '', + '', '', '