| /* |
| * Copyright (C) 2011 Adam Barth. All Rights Reserved. |
| * Copyright (C) 2011 Daniel Bates (dbates@intudata.com). |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions |
| * are met: |
| * 1. Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * 2. Redistributions in binary form must reproduce the above copyright |
| * notice, this list of conditions and the following disclaimer in the |
| * documentation and/or other materials provided with the distribution. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY |
| * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR |
| * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
| * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
| * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
| * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY |
| * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| #include "core/html/parser/XSSAuditor.h" |
| |
| #include "core/HTMLNames.h" |
| #include "core/SVGNames.h" |
| #include "core/XLinkNames.h" |
| #include "core/dom/Document.h" |
| #include "core/frame/LocalFrame.h" |
| #include "core/frame/Settings.h" |
| #include "core/frame/csp/ContentSecurityPolicy.h" |
| #include "core/html/HTMLParamElement.h" |
| #include "core/html/LinkRelAttribute.h" |
| #include "core/html/parser/HTMLDocumentParser.h" |
| #include "core/html/parser/HTMLParserIdioms.h" |
| #include "core/html/parser/TextResourceDecoder.h" |
| #include "core/html/parser/XSSAuditorDelegate.h" |
| #include "core/inspector/ConsoleMessage.h" |
| #include "core/loader/DocumentLoader.h" |
| #include "core/loader/MixedContentChecker.h" |
| #include "platform/network/EncodedFormData.h" |
| #include "platform/text/DecodeEscapeSequences.h" |
| #include "wtf/ASCIICType.h" |
| #include "wtf/PtrUtil.h" |
| #include <memory> |
| |
| namespace { |
| |
| // SecurityOrigin::urlWithUniqueSecurityOrigin() can't be used cross-thread, or |
| // we'd use it instead. |
| const char kURLWithUniqueOrigin[] = "data:,"; |
| |
| const char kSafeJavaScriptURL[] = "javascript:void(0)"; |
| |
| } // namespace |
| |
| namespace blink { |
| |
| using namespace HTMLNames; |
| |
| static bool isNonCanonicalCharacter(UChar c) { |
| // We remove all non-ASCII characters, including non-printable ASCII |
| // characters. |
| // |
| // Note, we don't remove backslashes like PHP stripslashes(), which among |
| // other things converts "\\0" to the \0 character. Instead, we remove |
| // backslashes and zeros (since the string "\\0" =(remove backslashes)=> "0"). |
| // However, this has the adverse effect that we remove any legitimate zeros |
| // from a string. |
| // |
| // We also remove forward-slash, because it is common for some servers to |
| // collapse successive path components, eg, a//b becomes a/b. |
| // |
| // We also remove the questionmark character, since some severs replace |
| // invalid high-bytes with a questionmark. We are already stripping the |
| // high-bytes so we also strip the questionmark to match. |
| // |
| // We also move the percent character, since some servers strip it when |
| // there's a malformed sequence. |
| // |
| // For instance: new String("http://localhost:8000?x") => new |
| // String("http:localhost:8x"). |
| return (c == '\\' || c == '0' || c == '\0' || c == '/' || c == '?' || |
| c == '%' || c >= 127); |
| } |
| |
| static bool isRequiredForInjection(UChar c) { |
| return (c == '\'' || c == '"' || c == '<' || c == '>'); |
| } |
| |
| static bool isTerminatingCharacter(UChar c) { |
| return (c == '&' || c == '/' || c == '"' || c == '\'' || c == '<' || |
| c == '>' || c == ','); |
| } |
| |
| static bool isHTMLQuote(UChar c) { |
| return (c == '"' || c == '\''); |
| } |
| |
| static bool isJSNewline(UChar c) { |
| // Per ecma-262 section 7.3 Line Terminators. |
| return (c == '\n' || c == '\r' || c == 0x2028 || c == 0x2029); |
| } |
| |
| static bool startsHTMLCommentAt(const String& string, size_t start) { |
| return (start + 3 < string.length() && string[start] == '<' && |
| string[start + 1] == '!' && string[start + 2] == '-' && |
| string[start + 3] == '-'); |
| } |
| |
| static bool startsSingleLineCommentAt(const String& string, size_t start) { |
| return (start + 1 < string.length() && string[start] == '/' && |
| string[start + 1] == '/'); |
| } |
| |
| static bool startsMultiLineCommentAt(const String& string, size_t start) { |
| return (start + 1 < string.length() && string[start] == '/' && |
| string[start + 1] == '*'); |
| } |
| |
| static bool startsOpeningScriptTagAt(const String& string, size_t start) { |
| if (start + 6 >= string.length()) |
| return false; |
| // TODO(esprehn): StringView should probably have startsWith. |
| StringView script("<script"); |
| return equalIgnoringASCIICase(StringView(string, start, script.length()), |
| script); |
| } |
| |
| // If other files need this, we should move this to |
| // core/html/parser/HTMLParserIdioms.h |
| template <size_t inlineCapacity> |
| bool threadSafeMatch(const Vector<UChar, inlineCapacity>& vector, |
| const QualifiedName& qname) { |
| return equalIgnoringNullity(vector, qname.localName().impl()); |
| } |
| |
| static bool hasName(const HTMLToken& token, const QualifiedName& name) { |
| return threadSafeMatch(token.name(), name); |
| } |
| |
| static bool findAttributeWithName(const HTMLToken& token, |
| const QualifiedName& name, |
| size_t& indexOfMatchingAttribute) { |
| // Notice that we're careful not to ref the StringImpl here because we might |
| // be on a background thread. |
| const String& attrName = name.namespaceURI() == XLinkNames::xlinkNamespaceURI |
| ? "xlink:" + name.localName().getString() |
| : name.localName().getString(); |
| |
| for (size_t i = 0; i < token.attributes().size(); ++i) { |
| if (equalIgnoringNullity(token.attributes().at(i).nameAsVector(), |
| attrName)) { |
| indexOfMatchingAttribute = i; |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| static bool isNameOfInlineEventHandler(const Vector<UChar, 32>& name) { |
| const size_t lengthOfShortestInlineEventHandlerName = 5; // To wit: oncut. |
| if (name.size() < lengthOfShortestInlineEventHandlerName) |
| return false; |
| return name[0] == 'o' && name[1] == 'n'; |
| } |
| |
| static bool isDangerousHTTPEquiv(const String& value) { |
| String equiv = value.stripWhiteSpace(); |
| return equalIgnoringCase(equiv, "refresh") || |
| equalIgnoringCase(equiv, "set-cookie"); |
| } |
| |
| static inline String decode16BitUnicodeEscapeSequences(const String& string) { |
| // Note, the encoding is ignored since each %u-escape sequence represents a |
| // UTF-16 code unit. |
| return decodeEscapeSequences<Unicode16BitEscapeSequence>(string, |
| UTF8Encoding()); |
| } |
| |
| static inline String decodeStandardURLEscapeSequences( |
| const String& string, |
| const WTF::TextEncoding& encoding) { |
| // We use decodeEscapeSequences() instead of decodeURLEscapeSequences() |
| // (declared in weborigin/KURL.h) to avoid platform-specific URL decoding |
| // differences (e.g. KURLGoogle). |
| return decodeEscapeSequences<URLEscapeSequence>(string, encoding); |
| } |
| |
| static String fullyDecodeString(const String& string, |
| const WTF::TextEncoding& encoding) { |
| size_t oldWorkingStringLength; |
| String workingString = string; |
| do { |
| oldWorkingStringLength = workingString.length(); |
| workingString = decode16BitUnicodeEscapeSequences( |
| decodeStandardURLEscapeSequences(workingString, encoding)); |
| } while (workingString.length() < oldWorkingStringLength); |
| workingString.replace('+', ' '); |
| return workingString; |
| } |
| |
| static void truncateForSrcLikeAttribute(String& decodedSnippet) { |
| // In HTTP URLs, characters following the first ?, #, or third slash may come |
| // from the page itself and can be merely ignored by an attacker's server when |
| // a remote script or script-like resource is requested. In DATA URLS, the |
| // payload starts at the first comma, and the the first /*, //, or <!-- may |
| // introduce a comment. |
| // |
| // Also, DATA URLs may use the same string literal tricks as with script |
| // content itself. In either case, content following this may come from the |
| // page and may be ignored when the script is executed. Also, any of these |
| // characters may now be represented by the (enlarged) set of html5 entities. |
| // |
| // For simplicity, we don't differentiate based on URL scheme, and stop at the |
| // first & (since it might be part of an entity for any of the subsequent |
| // punctuation), the first # or ?, the third slash, or the first slash, <, ', |
| // or " once a comma is seen. |
| int slashCount = 0; |
| bool commaSeen = false; |
| for (size_t currentLength = 0; currentLength < decodedSnippet.length(); |
| ++currentLength) { |
| UChar currentChar = decodedSnippet[currentLength]; |
| if (currentChar == '&' || currentChar == '?' || currentChar == '#' || |
| ((currentChar == '/' || currentChar == '\\') && |
| (commaSeen || ++slashCount > 2)) || |
| (currentChar == '<' && commaSeen) || |
| (currentChar == '\'' && commaSeen) || |
| (currentChar == '"' && commaSeen)) { |
| decodedSnippet.truncate(currentLength); |
| return; |
| } |
| if (currentChar == ',') |
| commaSeen = true; |
| } |
| } |
| |
| static void truncateForScriptLikeAttribute(String& decodedSnippet) { |
| // Beware of trailing characters which came from the page itself, not the |
| // injected vector. Excluding the terminating character covers common cases |
| // where the page immediately ends the attribute, but doesn't cover more |
| // complex cases where there is other page data following the injection. |
| // |
| // Generally, these won't parse as javascript, so the injected vector |
| // typically excludes them from consideration via a single-line comment or |
| // by enclosing them in a string literal terminated later by the page's own |
| // closing punctuation. Since the snippet has not been parsed, the vector |
| // may also try to introduce these via entities. As a result, we'd like to |
| // stop before the first "//", the first <!--, the first entity, or the first |
| // quote not immediately following the first equals sign (taking whitespace |
| // into consideration). |
| // |
| // To keep things simpler, we don't try to distinguish between |
| // entity-introducing amperands vs. other uses, nor do we bother to check for |
| // a second slash for a comment, nor do we bother to check for !-- following a |
| // less-than sign. We stop instead on any ampersand slash, or less-than sign. |
| size_t position = 0; |
| if ((position = decodedSnippet.find("=")) != kNotFound && |
| (position = decodedSnippet.find(isNotHTMLSpace<UChar>, position + 1)) != |
| kNotFound && |
| (position = decodedSnippet.find( |
| isTerminatingCharacter, |
| isHTMLQuote(decodedSnippet[position]) ? position + 1 : position)) != |
| kNotFound) { |
| decodedSnippet.truncate(position); |
| } |
| } |
| |
| static ReflectedXSSDisposition combineXSSProtectionHeaderAndCSP( |
| ReflectedXSSDisposition xssProtection, |
| ReflectedXSSDisposition reflectedXSS) { |
| ReflectedXSSDisposition result = std::max(xssProtection, reflectedXSS); |
| |
| if (result == ReflectedXSSInvalid || result == FilterReflectedXSS || |
| result == ReflectedXSSUnset) |
| return FilterReflectedXSS; |
| |
| return result; |
| } |
| |
| static bool isSemicolonSeparatedAttribute( |
| const HTMLToken::Attribute& attribute) { |
| return threadSafeMatch(attribute.nameAsVector(), SVGNames::valuesAttr); |
| } |
| |
| static String semicolonSeparatedValueContainingJavaScriptURL( |
| const String& value) { |
| Vector<String> valueList; |
| value.split(';', valueList); |
| for (size_t i = 0; i < valueList.size(); ++i) { |
| String stripped = stripLeadingAndTrailingHTMLSpaces(valueList[i]); |
| if (protocolIsJavaScript(stripped)) |
| return stripped; |
| } |
| return emptyString(); |
| } |
| |
| XSSAuditor::XSSAuditor() |
| : m_isEnabled(false), |
| m_xssProtection(FilterReflectedXSS), |
| m_didSendValidCSPHeader(false), |
| m_didSendValidXSSProtectionHeader(false), |
| m_state(Uninitialized), |
| m_scriptTagFoundInRequest(false), |
| m_scriptTagNestingLevel(0), |
| m_encoding(UTF8Encoding()) { |
| // Although tempting to call init() at this point, the various objects |
| // we want to reference might not all have been constructed yet. |
| } |
| |
| void XSSAuditor::initForFragment() { |
| ASSERT(isMainThread()); |
| ASSERT(m_state == Uninitialized); |
| m_state = FilteringTokens; |
| // When parsing a fragment, we don't enable the XSS auditor because it's |
| // too much overhead. |
| ASSERT(!m_isEnabled); |
| } |
| |
| void XSSAuditor::init(Document* document, XSSAuditorDelegate* auditorDelegate) { |
| ASSERT(isMainThread()); |
| if (m_state != Uninitialized) |
| return; |
| m_state = FilteringTokens; |
| |
| if (Settings* settings = document->settings()) |
| m_isEnabled = settings->xssAuditorEnabled(); |
| |
| if (!m_isEnabled) |
| return; |
| |
| m_documentURL = document->url().copy(); |
| |
| // In theory, the Document could have detached from the LocalFrame after the |
| // XSSAuditor was constructed. |
| if (!document->frame()) { |
| m_isEnabled = false; |
| return; |
| } |
| |
| if (m_documentURL.isEmpty()) { |
| // The URL can be empty when opening a new browser window or calling |
| // window.open(""). |
| m_isEnabled = false; |
| return; |
| } |
| |
| if (m_documentURL.protocolIsData()) { |
| m_isEnabled = false; |
| return; |
| } |
| |
| if (document->encoding().isValid()) |
| m_encoding = document->encoding(); |
| |
| if (DocumentLoader* documentLoader = |
| document->frame()->loader().documentLoader()) { |
| const AtomicString& headerValue = |
| documentLoader->response().httpHeaderField(HTTPNames::X_XSS_Protection); |
| String errorDetails; |
| unsigned errorPosition = 0; |
| String reportURL; |
| KURL xssProtectionReportURL; |
| |
| // Process the X-XSS-Protection header, then mix in the CSP header's value. |
| ReflectedXSSDisposition xssProtectionHeader = parseXSSProtectionHeader( |
| headerValue, errorDetails, errorPosition, reportURL); |
| |
| if (xssProtectionHeader == AllowReflectedXSS) |
| UseCounter::count(*document, UseCounter::XSSAuditorDisabled); |
| else if (xssProtectionHeader == FilterReflectedXSS) |
| UseCounter::count(*document, UseCounter::XSSAuditorEnabledFilter); |
| else if (xssProtectionHeader == BlockReflectedXSS) |
| UseCounter::count(*document, UseCounter::XSSAuditorEnabledBlock); |
| else if (xssProtectionHeader == ReflectedXSSInvalid) |
| UseCounter::count(*document, UseCounter::XSSAuditorInvalid); |
| |
| m_didSendValidXSSProtectionHeader = |
| xssProtectionHeader != ReflectedXSSUnset && |
| xssProtectionHeader != ReflectedXSSInvalid; |
| if ((xssProtectionHeader == FilterReflectedXSS || |
| xssProtectionHeader == BlockReflectedXSS) && |
| !reportURL.isEmpty()) { |
| xssProtectionReportURL = document->completeURL(reportURL); |
| if (MixedContentChecker::isMixedContent(document->getSecurityOrigin(), |
| xssProtectionReportURL)) { |
| errorDetails = "insecure reporting URL for secure page"; |
| xssProtectionHeader = ReflectedXSSInvalid; |
| xssProtectionReportURL = KURL(); |
| } |
| } |
| if (xssProtectionHeader == ReflectedXSSInvalid) |
| document->addConsoleMessage(ConsoleMessage::create( |
| SecurityMessageSource, ErrorMessageLevel, |
| "Error parsing header X-XSS-Protection: " + headerValue + ": " + |
| errorDetails + " at character position " + |
| String::format("%u", errorPosition) + |
| ". The default protections will be applied.")); |
| |
| ReflectedXSSDisposition cspHeader = |
| document->contentSecurityPolicy()->getReflectedXSSDisposition(); |
| m_didSendValidCSPHeader = |
| cspHeader != ReflectedXSSUnset && cspHeader != ReflectedXSSInvalid; |
| |
| m_xssProtection = |
| combineXSSProtectionHeaderAndCSP(xssProtectionHeader, cspHeader); |
| // FIXME: Combine the two report URLs in some reasonable way. |
| if (auditorDelegate) |
| auditorDelegate->setReportURL(xssProtectionReportURL.copy()); |
| |
| EncodedFormData* httpBody = documentLoader->request().httpBody(); |
| if (httpBody && !httpBody->isEmpty()) |
| m_httpBodyAsString = httpBody->flattenToString(); |
| } |
| |
| setEncoding(m_encoding); |
| } |
| |
| void XSSAuditor::setEncoding(const WTF::TextEncoding& encoding) { |
| const size_t miniumLengthForSuffixTree = 512; // FIXME: Tune this parameter. |
| const int suffixTreeDepth = 5; |
| |
| if (!encoding.isValid()) |
| return; |
| |
| m_encoding = encoding; |
| |
| m_decodedURL = canonicalize(m_documentURL.getString(), NoTruncation); |
| if (m_decodedURL.find(isRequiredForInjection) == kNotFound) |
| m_decodedURL = String(); |
| |
| if (!m_httpBodyAsString.isEmpty()) { |
| m_decodedHTTPBody = canonicalize(m_httpBodyAsString, NoTruncation); |
| m_httpBodyAsString = String(); |
| if (m_decodedHTTPBody.find(isRequiredForInjection) == kNotFound) |
| m_decodedHTTPBody = String(); |
| if (m_decodedHTTPBody.length() >= miniumLengthForSuffixTree) |
| m_decodedHTTPBodySuffixTree = wrapUnique( |
| new SuffixTree<ASCIICodebook>(m_decodedHTTPBody, suffixTreeDepth)); |
| } |
| |
| if (m_decodedURL.isEmpty() && m_decodedHTTPBody.isEmpty()) |
| m_isEnabled = false; |
| } |
| |
| std::unique_ptr<XSSInfo> XSSAuditor::filterToken( |
| const FilterTokenRequest& request) { |
| ASSERT(m_state != Uninitialized); |
| if (!m_isEnabled || m_xssProtection == AllowReflectedXSS) |
| return nullptr; |
| |
| bool didBlockScript = false; |
| if (request.token.type() == HTMLToken::StartTag) |
| didBlockScript = filterStartToken(request); |
| else if (m_scriptTagNestingLevel) { |
| if (request.token.type() == HTMLToken::Character) |
| didBlockScript = filterCharacterToken(request); |
| else if (request.token.type() == HTMLToken::EndTag) |
| filterEndToken(request); |
| } |
| |
| if (didBlockScript) { |
| bool didBlockEntirePage = (m_xssProtection == BlockReflectedXSS); |
| std::unique_ptr<XSSInfo> xssInfo = XSSInfo::create( |
| m_documentURL, didBlockEntirePage, m_didSendValidXSSProtectionHeader, |
| m_didSendValidCSPHeader); |
| return xssInfo; |
| } |
| return nullptr; |
| } |
| |
| bool XSSAuditor::filterStartToken(const FilterTokenRequest& request) { |
| m_state = FilteringTokens; |
| bool didBlockScript = eraseDangerousAttributesIfInjected(request); |
| |
| if (hasName(request.token, scriptTag)) { |
| didBlockScript |= filterScriptToken(request); |
| ASSERT(request.shouldAllowCDATA || !m_scriptTagNestingLevel); |
| m_scriptTagNestingLevel++; |
| } else if (hasName(request.token, objectTag)) |
| didBlockScript |= filterObjectToken(request); |
| else if (hasName(request.token, paramTag)) |
| didBlockScript |= filterParamToken(request); |
| else if (hasName(request.token, embedTag)) |
| didBlockScript |= filterEmbedToken(request); |
| else if (hasName(request.token, iframeTag) || |
| hasName(request.token, frameTag)) |
| didBlockScript |= filterFrameToken(request); |
| else if (hasName(request.token, metaTag)) |
| didBlockScript |= filterMetaToken(request); |
| else if (hasName(request.token, baseTag)) |
| didBlockScript |= filterBaseToken(request); |
| else if (hasName(request.token, formTag)) |
| didBlockScript |= filterFormToken(request); |
| else if (hasName(request.token, inputTag)) |
| didBlockScript |= filterInputToken(request); |
| else if (hasName(request.token, buttonTag)) |
| didBlockScript |= filterButtonToken(request); |
| else if (hasName(request.token, linkTag)) |
| didBlockScript |= filterLinkToken(request); |
| |
| return didBlockScript; |
| } |
| |
| void XSSAuditor::filterEndToken(const FilterTokenRequest& request) { |
| ASSERT(m_scriptTagNestingLevel); |
| m_state = FilteringTokens; |
| if (hasName(request.token, scriptTag)) { |
| m_scriptTagNestingLevel--; |
| ASSERT(request.shouldAllowCDATA || !m_scriptTagNestingLevel); |
| } |
| } |
| |
| bool XSSAuditor::filterCharacterToken(const FilterTokenRequest& request) { |
| ASSERT(m_scriptTagNestingLevel); |
| ASSERT(m_state != Uninitialized); |
| if (m_state == PermittingAdjacentCharacterTokens) |
| return false; |
| |
| if (m_state == FilteringTokens && m_scriptTagFoundInRequest) { |
| String snippet = canonicalizedSnippetForJavaScript(request); |
| if (isContainedInRequest(snippet)) |
| m_state = SuppressingAdjacentCharacterTokens; |
| else if (!snippet.isEmpty()) |
| m_state = PermittingAdjacentCharacterTokens; |
| } |
| if (m_state == SuppressingAdjacentCharacterTokens) { |
| request.token.eraseCharacters(); |
| // Technically, character tokens can't be empty. |
| request.token.appendToCharacter(' '); |
| return true; |
| } |
| return false; |
| } |
| |
| bool XSSAuditor::filterScriptToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, scriptTag)); |
| |
| bool didBlockScript = false; |
| m_scriptTagFoundInRequest = |
| isContainedInRequest(canonicalizedSnippetForTagName(request)); |
| if (m_scriptTagFoundInRequest) { |
| didBlockScript |= eraseAttributeIfInjected( |
| request, srcAttr, blankURL().getString(), SrcLikeAttributeTruncation); |
| didBlockScript |= eraseAttributeIfInjected(request, SVGNames::hrefAttr, |
| blankURL().getString(), |
| SrcLikeAttributeTruncation); |
| didBlockScript |= eraseAttributeIfInjected(request, XLinkNames::hrefAttr, |
| blankURL().getString(), |
| SrcLikeAttributeTruncation); |
| } |
| return didBlockScript; |
| } |
| |
| bool XSSAuditor::filterObjectToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, objectTag)); |
| |
| bool didBlockScript = false; |
| if (isContainedInRequest(canonicalizedSnippetForTagName(request))) { |
| didBlockScript |= eraseAttributeIfInjected( |
| request, dataAttr, blankURL().getString(), SrcLikeAttributeTruncation); |
| didBlockScript |= eraseAttributeIfInjected(request, typeAttr); |
| didBlockScript |= eraseAttributeIfInjected(request, classidAttr); |
| } |
| return didBlockScript; |
| } |
| |
| bool XSSAuditor::filterParamToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, paramTag)); |
| |
| size_t indexOfNameAttribute; |
| if (!findAttributeWithName(request.token, nameAttr, indexOfNameAttribute)) |
| return false; |
| |
| const HTMLToken::Attribute& nameAttribute = |
| request.token.attributes().at(indexOfNameAttribute); |
| if (!HTMLParamElement::isURLParameter(nameAttribute.value())) |
| return false; |
| |
| return eraseAttributeIfInjected(request, valueAttr, blankURL().getString(), |
| SrcLikeAttributeTruncation); |
| } |
| |
| bool XSSAuditor::filterEmbedToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, embedTag)); |
| |
| bool didBlockScript = false; |
| if (isContainedInRequest(canonicalizedSnippetForTagName(request))) { |
| didBlockScript |= eraseAttributeIfInjected(request, codeAttr, String(), |
| SrcLikeAttributeTruncation); |
| didBlockScript |= eraseAttributeIfInjected( |
| request, srcAttr, blankURL().getString(), SrcLikeAttributeTruncation); |
| didBlockScript |= eraseAttributeIfInjected(request, typeAttr); |
| } |
| return didBlockScript; |
| } |
| |
| bool XSSAuditor::filterFrameToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, iframeTag) || hasName(request.token, frameTag)); |
| |
| bool didBlockScript = eraseAttributeIfInjected(request, srcdocAttr, String(), |
| ScriptLikeAttributeTruncation); |
| if (isContainedInRequest(canonicalizedSnippetForTagName(request))) |
| didBlockScript |= eraseAttributeIfInjected(request, srcAttr, String(), |
| SrcLikeAttributeTruncation); |
| |
| return didBlockScript; |
| } |
| |
| bool XSSAuditor::filterMetaToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, metaTag)); |
| |
| return eraseAttributeIfInjected(request, http_equivAttr); |
| } |
| |
| bool XSSAuditor::filterBaseToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, baseTag)); |
| |
| return eraseAttributeIfInjected(request, hrefAttr, String(), |
| SrcLikeAttributeTruncation); |
| } |
| |
| bool XSSAuditor::filterFormToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, formTag)); |
| |
| return eraseAttributeIfInjected(request, actionAttr, kURLWithUniqueOrigin); |
| } |
| |
| bool XSSAuditor::filterInputToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, inputTag)); |
| |
| return eraseAttributeIfInjected(request, formactionAttr, kURLWithUniqueOrigin, |
| SrcLikeAttributeTruncation); |
| } |
| |
| bool XSSAuditor::filterButtonToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, buttonTag)); |
| |
| return eraseAttributeIfInjected(request, formactionAttr, kURLWithUniqueOrigin, |
| SrcLikeAttributeTruncation); |
| } |
| |
| bool XSSAuditor::filterLinkToken(const FilterTokenRequest& request) { |
| ASSERT(request.token.type() == HTMLToken::StartTag); |
| ASSERT(hasName(request.token, linkTag)); |
| |
| size_t indexOfAttribute = 0; |
| if (!findAttributeWithName(request.token, relAttr, indexOfAttribute)) |
| return false; |
| |
| const HTMLToken::Attribute& attribute = |
| request.token.attributes().at(indexOfAttribute); |
| LinkRelAttribute parsedAttribute(attribute.value()); |
| if (!parsedAttribute.isImport()) |
| return false; |
| |
| return eraseAttributeIfInjected(request, hrefAttr, kURLWithUniqueOrigin, |
| SrcLikeAttributeTruncation, |
| AllowSameOriginHref); |
| } |
| |
| bool XSSAuditor::eraseDangerousAttributesIfInjected( |
| const FilterTokenRequest& request) { |
| bool didBlockScript = false; |
| for (size_t i = 0; i < request.token.attributes().size(); ++i) { |
| bool eraseAttribute = false; |
| bool valueContainsJavaScriptURL = false; |
| const HTMLToken::Attribute& attribute = request.token.attributes().at(i); |
| // FIXME: Don't create a new String for every attribute.value in the |
| // document. |
| if (isNameOfInlineEventHandler(attribute.nameAsVector())) { |
| eraseAttribute = isContainedInRequest( |
| canonicalize(snippetFromAttribute(request, attribute), |
| ScriptLikeAttributeTruncation)); |
| } else if (isSemicolonSeparatedAttribute(attribute)) { |
| String subValue = |
| semicolonSeparatedValueContainingJavaScriptURL(attribute.value()); |
| if (!subValue.isEmpty()) { |
| valueContainsJavaScriptURL = true; |
| eraseAttribute = |
| isContainedInRequest(canonicalize( |
| nameFromAttribute(request, attribute), NoTruncation)) && |
| isContainedInRequest( |
| canonicalize(subValue, ScriptLikeAttributeTruncation)); |
| } |
| } else if (protocolIsJavaScript( |
| stripLeadingAndTrailingHTMLSpaces(attribute.value()))) { |
| valueContainsJavaScriptURL = true; |
| eraseAttribute = isContainedInRequest( |
| canonicalize(snippetFromAttribute(request, attribute), |
| ScriptLikeAttributeTruncation)); |
| } |
| if (!eraseAttribute) |
| continue; |
| request.token.eraseValueOfAttribute(i); |
| if (valueContainsJavaScriptURL) |
| request.token.appendToAttributeValue(i, kSafeJavaScriptURL); |
| didBlockScript = true; |
| } |
| return didBlockScript; |
| } |
| |
| bool XSSAuditor::eraseAttributeIfInjected(const FilterTokenRequest& request, |
| const QualifiedName& attributeName, |
| const String& replacementValue, |
| TruncationKind treatment, |
| HrefRestriction restriction) { |
| size_t indexOfAttribute = 0; |
| if (!findAttributeWithName(request.token, attributeName, indexOfAttribute)) |
| return false; |
| |
| const HTMLToken::Attribute& attribute = |
| request.token.attributes().at(indexOfAttribute); |
| if (!isContainedInRequest( |
| canonicalize(snippetFromAttribute(request, attribute), treatment))) |
| return false; |
| |
| if (threadSafeMatch(attributeName, srcAttr) || |
| (restriction == AllowSameOriginHref && |
| threadSafeMatch(attributeName, hrefAttr))) { |
| if (isLikelySafeResource(attribute.value())) |
| return false; |
| } else if (threadSafeMatch(attributeName, http_equivAttr)) { |
| if (!isDangerousHTTPEquiv(attribute.value())) |
| return false; |
| } |
| |
| request.token.eraseValueOfAttribute(indexOfAttribute); |
| if (!replacementValue.isEmpty()) |
| request.token.appendToAttributeValue(indexOfAttribute, replacementValue); |
| |
| return true; |
| } |
| |
| String XSSAuditor::canonicalizedSnippetForTagName( |
| const FilterTokenRequest& request) { |
| // Grab a fixed number of characters equal to the length of the token's name |
| // plus one (to account for the "<"). |
| return canonicalize(request.sourceTracker.sourceForToken(request.token) |
| .substring(0, request.token.name().size() + 1), |
| NoTruncation); |
| } |
| |
| String XSSAuditor::nameFromAttribute(const FilterTokenRequest& request, |
| const HTMLToken::Attribute& attribute) { |
| // The range inlcudes the character which terminates the name. So, |
| // for an input of |name="value"|, the snippet is |name=|. |
| int start = attribute.nameRange().start - request.token.startIndex(); |
| int end = attribute.valueRange().start - request.token.startIndex(); |
| return request.sourceTracker.sourceForToken(request.token) |
| .substring(start, end - start); |
| } |
| |
| String XSSAuditor::snippetFromAttribute(const FilterTokenRequest& request, |
| const HTMLToken::Attribute& attribute) { |
| // The range doesn't include the character which terminates the value. So, |
| // for an input of |name="value"|, the snippet is |name="value|. For an |
| // unquoted input of |name=value |, the snippet is |name=value|. |
| // FIXME: We should grab one character before the name also. |
| int start = attribute.nameRange().start - request.token.startIndex(); |
| int end = attribute.valueRange().end - request.token.startIndex(); |
| return request.sourceTracker.sourceForToken(request.token) |
| .substring(start, end - start); |
| } |
| |
| String XSSAuditor::canonicalize(String snippet, TruncationKind treatment) { |
| String decodedSnippet = fullyDecodeString(snippet, m_encoding); |
| |
| if (treatment != NoTruncation) { |
| if (decodedSnippet.length() > kMaximumFragmentLengthTarget) { |
| // Let the page influence the stopping point to avoid disclosing leading |
| // fragments. Stop when we hit whitespace, since that is unlikely to be |
| // part a leading fragment. |
| size_t position = kMaximumFragmentLengthTarget; |
| while (position < decodedSnippet.length() && |
| !isHTMLSpace(decodedSnippet[position])) |
| ++position; |
| decodedSnippet.truncate(position); |
| } |
| if (treatment == SrcLikeAttributeTruncation) |
| truncateForSrcLikeAttribute(decodedSnippet); |
| else if (treatment == ScriptLikeAttributeTruncation) |
| truncateForScriptLikeAttribute(decodedSnippet); |
| } |
| |
| return decodedSnippet.removeCharacters(&isNonCanonicalCharacter); |
| } |
| |
| String XSSAuditor::canonicalizedSnippetForJavaScript( |
| const FilterTokenRequest& request) { |
| String string = request.sourceTracker.sourceForToken(request.token); |
| size_t startPosition = 0; |
| size_t endPosition = string.length(); |
| size_t foundPosition = kNotFound; |
| size_t lastNonSpacePosition = kNotFound; |
| |
| // Skip over initial comments to find start of code. |
| while (startPosition < endPosition) { |
| while (startPosition < endPosition && |
| isHTMLSpace<UChar>(string[startPosition])) |
| startPosition++; |
| |
| // Under SVG/XML rules, only HTML comment syntax matters and the parser |
| // returns these as a separate comment tokens. Having consumed whitespace, |
| // we need not look further for these. |
| if (request.shouldAllowCDATA) |
| break; |
| |
| // Under HTML rules, both the HTML and JS comment synatx matters, and the |
| // HTML comment ends at the end of the line, not with -->. |
| if (startsHTMLCommentAt(string, startPosition) || |
| startsSingleLineCommentAt(string, startPosition)) { |
| while (startPosition < endPosition && !isJSNewline(string[startPosition])) |
| startPosition++; |
| } else if (startsMultiLineCommentAt(string, startPosition)) { |
| if (startPosition + 2 < endPosition && |
| (foundPosition = string.find("*/", startPosition + 2)) != kNotFound) |
| startPosition = foundPosition + 2; |
| else |
| startPosition = endPosition; |
| } else |
| break; |
| } |
| |
| String result; |
| while (startPosition < endPosition && !result.length()) { |
| // Stop at next comment (using the same rules as above for SVG/XML vs HTML), |
| // when we encounter a comma, when we encoutner a backtick, when we hit an |
| // opening <script> tag, or when we exceed the maximum length target. The |
| // comma rule covers a common parameter concatenation case performed by some |
| // web servers. The backtick rule covers the ECMA6 multi-line template |
| // string feature. |
| lastNonSpacePosition = kNotFound; |
| for (foundPosition = startPosition; foundPosition < endPosition; |
| foundPosition++) { |
| if (!request.shouldAllowCDATA) { |
| if (startsSingleLineCommentAt(string, foundPosition) || |
| startsMultiLineCommentAt(string, foundPosition) || |
| startsHTMLCommentAt(string, foundPosition)) { |
| break; |
| } |
| } |
| if (string[foundPosition] == ',' || string[foundPosition] == '`') |
| break; |
| |
| if (lastNonSpacePosition != kNotFound && |
| startsOpeningScriptTagAt(string, foundPosition)) { |
| foundPosition = lastNonSpacePosition + 1; |
| break; |
| } |
| if (foundPosition > startPosition + kMaximumFragmentLengthTarget) { |
| // After hitting the length target, we can only stop at a point where we |
| // know we are not in the middle of a %-escape sequence. For the sake of |
| // simplicity, approximate not stopping inside a (possibly multiply |
| // encoded) %-escape sequence by breaking on whitespace only. We should |
| // have enough text in these cases to avoid false positives. |
| if (isHTMLSpace<UChar>(string[foundPosition])) |
| break; |
| } |
| if (!isHTMLSpace<UChar>(string[foundPosition])) |
| lastNonSpacePosition = foundPosition; |
| } |
| result = canonicalize( |
| string.substring(startPosition, foundPosition - startPosition), |
| NoTruncation); |
| startPosition = foundPosition + 1; |
| } |
| |
| return result; |
| } |
| |
| bool XSSAuditor::isContainedInRequest(const String& decodedSnippet) { |
| if (decodedSnippet.isEmpty()) |
| return false; |
| if (m_decodedURL.find(decodedSnippet, 0, TextCaseInsensitive) != kNotFound) |
| return true; |
| if (m_decodedHTTPBodySuffixTree && |
| !m_decodedHTTPBodySuffixTree->mightContain(decodedSnippet)) |
| return false; |
| return m_decodedHTTPBody.find(decodedSnippet, 0, TextCaseInsensitive) != |
| kNotFound; |
| } |
| |
| bool XSSAuditor::isLikelySafeResource(const String& url) { |
| // Give empty URLs and about:blank a pass. Making a resourceURL from an |
| // empty string below will likely later fail the "no query args test" as |
| // it inherits the document's query args. |
| if (url.isEmpty() || url == blankURL().getString()) |
| return true; |
| |
| // If the resource is loaded from the same host as the enclosing page, it's |
| // probably not an XSS attack, so we reduce false positives by allowing the |
| // request, ignoring scheme and port considerations. If the resource has a |
| // query string, we're more suspicious, however, because that's pretty rare |
| // and the attacker might be able to trick a server-side script into doing |
| // something dangerous with the query string. |
| if (m_documentURL.host().isEmpty()) |
| return false; |
| |
| KURL resourceURL(m_documentURL, url); |
| return (m_documentURL.host() == resourceURL.host() && |
| resourceURL.query().isEmpty()); |
| } |
| |
| bool XSSAuditor::isSafeToSendToAnotherThread() const { |
| return m_documentURL.isSafeToSendToAnotherThread() && |
| m_decodedURL.isSafeToSendToAnotherThread() && |
| m_decodedHTTPBody.isSafeToSendToAnotherThread() && |
| m_httpBodyAsString.isSafeToSendToAnotherThread(); |
| } |
| |
| } // namespace blink |