8e1bcc8d40292babf9a8849d077db9860a3379bc
[WebKit-https.git] / Source / WebCore / html / parser / XSSAuditor.cpp
1 /*
2  * Copyright (C) 2011 Adam Barth. All Rights Reserved.
3  * Copyright (C) 2011 Daniel Bates (dbates@intudata.com).
4  *
5  * Redistribution and use in source and binary forms, with or without
6  * modification, are permitted provided that the following conditions
7  * are met:
8  * 1. Redistributions of source code must retain the above copyright
9  *    notice, this list of conditions and the following disclaimer.
10  * 2. Redistributions in binary form must reproduce the above copyright
11  *    notice, this list of conditions and the following disclaimer in the
12  *    documentation and/or other materials provided with the distribution.
13  *
14  * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
15  * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
17  * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL APPLE INC. OR
18  * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
19  * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
20  * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
21  * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
22  * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25  */
26
27 #include "config.h"
28 #include "XSSAuditor.h"
29
30 #include "DecodeEscapeSequences.h"
31 #include "Document.h"
32 #include "DocumentLoader.h"
33 #include "FormData.h"
34 #include "Frame.h"
35 #include "HTMLDocumentParser.h"
36 #include "HTMLNames.h"
37 #include "HTMLParamElement.h"
38 #include "HTMLParserIdioms.h"
39 #include "SVGNames.h"
40 #include "Settings.h"
41 #include "TextResourceDecoder.h"
42 #include "XLinkNames.h"
43 #include <wtf/ASCIICType.h>
44 #include <wtf/MainThread.h>
45 #include <wtf/NeverDestroyed.h>
46 #include <wtf/text/StringView.h>
47
48 namespace WebCore {
49
50 using namespace HTMLNames;
51
52 static bool isNonCanonicalCharacter(UChar c)
53 {
54     // We remove all non-ASCII characters, including non-printable ASCII characters.
55     //
56     // Note, we don't remove backslashes like PHP stripslashes(), which among other things converts "\\0" to the \0 character.
57     // Instead, we remove backslashes and zeros (since the string "\\0" =(remove backslashes)=> "0"). However, this has the
58     // adverse effect that we remove any legitimate zeros from a string.
59     // We also remove forward-slash, because it is common for some servers to collapse successive path components, eg,
60     // a//b becomes a/b.
61     //
62     // For instance: new String("http://localhost:8000") => new String("http:localhost:8").
63     return (c == '\\' || c == '0' || c == '\0' || c == '/' || c >= 127);
64 }
65
66 static bool isRequiredForInjection(UChar c)
67 {
68     return (c == '\'' || c == '"' || c == '<' || c == '>');
69 }
70
71 static bool isTerminatingCharacter(UChar c)
72 {
73     return (c == '&' || c == '/' || c == '"' || c == '\'' || c == '<' || c == '>' || c == ',');
74 }
75
76 static bool isHTMLQuote(UChar c)
77 {
78     return (c == '"' || c == '\'');
79 }
80
81 static bool isJSNewline(UChar c)
82 {
83     // Per ecma-262 section 7.3 Line Terminators.
84     return (c == '\n' || c == '\r' || c == 0x2028 || c == 0x2029);
85 }
86
87 static bool startsHTMLCommentAt(const String& string, size_t start)
88 {
89     return (start + 3 < string.length() && string[start] == '<' && string[start + 1] == '!' && string[start + 2] == '-' && string[start + 3] == '-');
90 }
91
92 static bool startsSingleLineCommentAt(const String& string, size_t start)
93 {
94     return (start + 1 < string.length() && string[start] == '/' && string[start + 1] == '/');
95 }
96
97 static bool startsMultiLineCommentAt(const String& string, size_t start)
98 {
99     return (start + 1 < string.length() && string[start] == '/' && string[start + 1] == '*');
100 }
101
102 static bool startsOpeningScriptTagAt(const String& string, size_t start)
103 {
104     return start + 6 < string.length() && string[start] == '<'
105         && WTF::toASCIILowerUnchecked(string[start + 1]) == 's'
106         && WTF::toASCIILowerUnchecked(string[start + 2]) == 'c'
107         && WTF::toASCIILowerUnchecked(string[start + 3]) == 'r'
108         && WTF::toASCIILowerUnchecked(string[start + 4]) == 'i'
109         && WTF::toASCIILowerUnchecked(string[start + 5]) == 'p'
110         && WTF::toASCIILowerUnchecked(string[start + 6]) == 't';
111 }
112
113 // If other files need this, we should move this to HTMLParserIdioms.h
114 template<size_t inlineCapacity>
115 bool threadSafeMatch(const Vector<UChar, inlineCapacity>& vector, const QualifiedName& qname)
116 {
117     return equalIgnoringNullity(vector, qname.localName().impl());
118 }
119
120 static bool hasName(const HTMLToken& token, const QualifiedName& name)
121 {
122     return threadSafeMatch(token.name(), name);
123 }
124
125 static bool findAttributeWithName(const HTMLToken& token, const QualifiedName& name, size_t& indexOfMatchingAttribute)
126 {
127     // Notice that we're careful not to ref the StringImpl here because we might be on a background thread.
128     const String& attrName = name.namespaceURI() == XLinkNames::xlinkNamespaceURI ? "xlink:" + name.localName().string() : name.localName().string();
129
130     for (size_t i = 0; i < token.attributes().size(); ++i) {
131         if (equalIgnoringNullity(token.attributes().at(i).name, attrName)) {
132             indexOfMatchingAttribute = i;
133             return true;
134         }
135     }
136     return false;
137 }
138
139 static bool isNameOfInlineEventHandler(const Vector<UChar, 32>& name)
140 {
141     const size_t lengthOfShortestInlineEventHandlerName = 5; // To wit: oncut.
142     if (name.size() < lengthOfShortestInlineEventHandlerName)
143         return false;
144     return name[0] == 'o' && name[1] == 'n';
145 }
146
147 static bool isDangerousHTTPEquiv(const String& value)
148 {
149     String equiv = value.stripWhiteSpace();
150     return equalLettersIgnoringASCIICase(equiv, "refresh") || equalLettersIgnoringASCIICase(equiv, "set-cookie");
151 }
152
153 static inline String decode16BitUnicodeEscapeSequences(const String& string)
154 {
155     // Note, the encoding is ignored since each %u-escape sequence represents a UTF-16 code unit.
156     return decodeEscapeSequences<Unicode16BitEscapeSequence>(string, UTF8Encoding());
157 }
158
159 static inline String decodeStandardURLEscapeSequences(const String& string, const TextEncoding& encoding)
160 {
161     // We use decodeEscapeSequences() instead of decodeURLEscapeSequences() (declared in URL.h) to
162     // avoid platform-specific URL decoding differences (e.g. URLGoogle).
163     return decodeEscapeSequences<URLEscapeSequence>(string, encoding);
164 }
165
166 static String fullyDecodeString(const String& string, const TextEncoding& encoding)
167 {
168     size_t oldWorkingStringLength;
169     String workingString = string;
170     do {
171         oldWorkingStringLength = workingString.length();
172         workingString = decode16BitUnicodeEscapeSequences(decodeStandardURLEscapeSequences(workingString, encoding));
173     } while (workingString.length() < oldWorkingStringLength);
174     workingString.replace('+', ' ');
175     return workingString;
176 }
177
178 static void truncateForSrcLikeAttribute(String& decodedSnippet)
179 {
180     // In HTTP URLs, characters following the first ?, #, or third slash may come from
181     // the page itself and can be merely ignored by an attacker's server when a remote
182     // script or script-like resource is requested. In data URLs, the payload starts at
183     // the first comma, and the first /*, //, or <!-- may introduce a comment. Also
184     // data URLs may use the same string literal tricks as with script content itself.
185     // In either case, content following this may come from the page and may be ignored
186     // when the script is executed. Also, any of these characters may now be represented
187     // by the (enlarged) set of HTML5 entities.
188     // For simplicity, we don't differentiate based on URL scheme, and stop at the first
189     // & (since it might be part of an entity for any of the subsequent punctuation)
190     // the first # or ?, the third slash, or the first slash, <, ', or " once a comma
191     // is seen.
192     int slashCount = 0;
193     bool commaSeen = false;
194     for (size_t currentLength = 0; currentLength < decodedSnippet.length(); ++currentLength) {
195         UChar currentChar = decodedSnippet[currentLength];
196         if (currentChar == '&'
197             || currentChar == '?'
198             || currentChar == '#'
199             || ((currentChar == '/' || currentChar == '\\') && (commaSeen || ++slashCount > 2))
200             || (currentChar == '<' && commaSeen)
201             || (currentChar == '\'' && commaSeen)
202             || (currentChar == '"' && commaSeen)) {
203             decodedSnippet.truncate(currentLength);
204             return;
205         }
206         if (currentChar == ',')
207             commaSeen = true;
208     }
209 }
210
211 static void truncateForScriptLikeAttribute(String& decodedSnippet)
212 {
213     // Beware of trailing characters which came from the page itself, not the
214     // injected vector. Excluding the terminating character covers common cases
215     // where the page immediately ends the attribute, but doesn't cover more
216     // complex cases where there is other page data following the injection.
217     // Generally, these won't parse as JavaScript, so the injected vector
218     // typically excludes them from consideration via a single-line comment or
219     // by enclosing them in a string literal terminated later by the page's own
220     // closing punctuation. Since the snippet has not been parsed, the vector
221     // may also try to introduce these via entities. As a result, we'd like to
222     // stop before the first "//", the first <!--, the first entity, or the first
223     // quote not immediately following the first equals sign (taking whitespace
224     // into consideration). To keep things simpler, we don't try to distinguish
225     // between entity-introducing ampersands vs. other uses, nor do we bother to
226     // check for a second slash for a comment, nor do we bother to check for
227     // !-- following a less-than sign. We stop instead on any ampersand
228     // slash, or less-than sign.
229     size_t position = 0;
230     if ((position = decodedSnippet.find('=')) != notFound
231         && (position = decodedSnippet.find(isNotHTMLSpace, position + 1)) != notFound
232         && (position = decodedSnippet.find(isTerminatingCharacter, isHTMLQuote(decodedSnippet[position]) ? position + 1 : position)) != notFound) {
233         decodedSnippet.truncate(position);
234     }
235 }
236
237 static bool isSemicolonSeparatedAttribute(const HTMLToken::Attribute& attribute)
238 {
239     return threadSafeMatch(attribute.name, SVGNames::valuesAttr);
240 }
241
242 static bool semicolonSeparatedValueContainsJavaScriptURL(StringView semicolonSeparatedValue)
243 {
244     for (auto value : semicolonSeparatedValue.split(';')) {
245         if (protocolIsJavaScript(value))
246             return true;
247     }
248     return false;
249 }
250
251 XSSAuditor::XSSAuditor()
252     : m_isEnabled(false)
253     , m_xssProtection(XSSProtectionDisposition::Enabled)
254     , m_didSendValidXSSProtectionHeader(false)
255     , m_state(Uninitialized)
256     , m_scriptTagNestingLevel(0)
257     , m_encoding(UTF8Encoding())
258 {
259     // Although tempting to call init() at this point, the various objects
260     // we want to reference might not all have been constructed yet.
261 }
262
263 void XSSAuditor::initForFragment()
264 {
265     ASSERT(isMainThread());
266     ASSERT(m_state == Uninitialized);
267     m_state = Initialized;
268     // When parsing a fragment, we don't enable the XSS auditor because it's
269     // too much overhead.
270     ASSERT(!m_isEnabled);
271 }
272
273 void XSSAuditor::init(Document* document, XSSAuditorDelegate* auditorDelegate)
274 {
275     const size_t minimumLengthForSuffixTree = 512; // FIXME: Tune this parameter.
276     const int suffixTreeDepth = 5;
277
278     ASSERT(isMainThread());
279     if (m_state == Initialized)
280         return;
281     ASSERT(m_state == Uninitialized);
282     m_state = Initialized;
283
284     if (Frame* frame = document->frame())
285         m_isEnabled = frame->settings().xssAuditorEnabled();
286
287     if (!m_isEnabled)
288         return;
289
290     m_documentURL = document->url().isolatedCopy();
291
292     // In theory, the Document could have detached from the Frame after the
293     // XSSAuditor was constructed.
294     if (!document->frame()) {
295         m_isEnabled = false;
296         return;
297     }
298
299     if (m_documentURL.isEmpty()) {
300         // The URL can be empty when opening a new browser window or calling window.open("").
301         m_isEnabled = false;
302         return;
303     }
304
305     if (m_documentURL.protocolIsData()) {
306         m_isEnabled = false;
307         return;
308     }
309
310     if (document->decoder())
311         m_encoding = document->decoder()->encoding();
312
313     m_decodedURL = canonicalize(m_documentURL.string(), TruncationStyle::None);
314     if (m_decodedURL.find(isRequiredForInjection) == notFound)
315         m_decodedURL = String();
316
317     String httpBodyAsString;
318     if (DocumentLoader* documentLoader = document->frame()->loader().documentLoader()) {
319         static NeverDestroyed<String> XSSProtectionHeader(ASCIILiteral("X-XSS-Protection"));
320         String headerValue = documentLoader->response().httpHeaderField(XSSProtectionHeader);
321         String errorDetails;
322         unsigned errorPosition = 0;
323         String parsedReportURL;
324         URL reportURL;
325         m_xssProtection = parseXSSProtectionHeader(headerValue, errorDetails, errorPosition, parsedReportURL);
326         m_didSendValidXSSProtectionHeader = !headerValue.isNull() && m_xssProtection != XSSProtectionDisposition::Invalid;
327
328         if ((m_xssProtection == XSSProtectionDisposition::Enabled || m_xssProtection == XSSProtectionDisposition::BlockEnabled) && !parsedReportURL.isEmpty()) {
329             reportURL = document->completeURL(parsedReportURL);
330             if (MixedContentChecker::isMixedContent(document->securityOrigin(), reportURL)) {
331                 errorDetails = "insecure reporting URL for secure page";
332                 m_xssProtection = XSSProtectionDisposition::Invalid;
333                 reportURL = URL();
334                 m_didSendValidXSSProtectionHeader = false;
335             }
336         }
337         if (m_xssProtection == XSSProtectionDisposition::Invalid) {
338             document->addConsoleMessage(MessageSource::Security, MessageLevel::Error, "Error parsing header X-XSS-Protection: " + headerValue + ": "  + errorDetails + " at character position " + String::format("%u", errorPosition) + ". The default protections will be applied.");
339             m_xssProtection = XSSProtectionDisposition::Enabled;
340         }
341
342         if (auditorDelegate)
343             auditorDelegate->setReportURL(reportURL.isolatedCopy());
344         FormData* httpBody = documentLoader->originalRequest().httpBody();
345         if (httpBody && !httpBody->isEmpty()) {
346             httpBodyAsString = httpBody->flattenToString();
347             if (!httpBodyAsString.isEmpty()) {
348                 m_decodedHTTPBody = canonicalize(httpBodyAsString, TruncationStyle::None);
349                 if (m_decodedHTTPBody.find(isRequiredForInjection) == notFound)
350                     m_decodedHTTPBody = String();
351                 if (m_decodedHTTPBody.length() >= minimumLengthForSuffixTree)
352                     m_decodedHTTPBodySuffixTree = std::make_unique<SuffixTree<ASCIICodebook>>(m_decodedHTTPBody, suffixTreeDepth);
353             }
354         }
355     }
356
357     if (m_decodedURL.isEmpty() && m_decodedHTTPBody.isEmpty()) {
358         m_isEnabled = false;
359         return;
360     }
361 }
362
363 std::unique_ptr<XSSInfo> XSSAuditor::filterToken(const FilterTokenRequest& request)
364 {
365     ASSERT(m_state == Initialized);
366     if (!m_isEnabled || m_xssProtection == XSSProtectionDisposition::Disabled)
367         return nullptr;
368
369     bool didBlockScript = false;
370     if (request.token.type() == HTMLToken::StartTag)
371         didBlockScript = filterStartToken(request);
372     else if (m_scriptTagNestingLevel) {
373         if (request.token.type() == HTMLToken::Character)
374             didBlockScript = filterCharacterToken(request);
375         else if (request.token.type() == HTMLToken::EndTag)
376             filterEndToken(request);
377     }
378
379     if (!didBlockScript)
380         return nullptr;
381
382     bool didBlockEntirePage = m_xssProtection == XSSProtectionDisposition::BlockEnabled;
383     return std::make_unique<XSSInfo>(m_documentURL, didBlockEntirePage, m_didSendValidXSSProtectionHeader);
384 }
385
386 bool XSSAuditor::filterStartToken(const FilterTokenRequest& request)
387 {
388     bool didBlockScript = eraseDangerousAttributesIfInjected(request);
389
390     if (hasName(request.token, scriptTag)) {
391         didBlockScript |= filterScriptToken(request);
392         ASSERT(request.shouldAllowCDATA || !m_scriptTagNestingLevel);
393         m_scriptTagNestingLevel++;
394     } else if (hasName(request.token, objectTag))
395         didBlockScript |= filterObjectToken(request);
396     else if (hasName(request.token, paramTag))
397         didBlockScript |= filterParamToken(request);
398     else if (hasName(request.token, embedTag))
399         didBlockScript |= filterEmbedToken(request);
400     else if (hasName(request.token, appletTag))
401         didBlockScript |= filterAppletToken(request);
402     else if (hasName(request.token, iframeTag) || hasName(request.token, frameTag))
403         didBlockScript |= filterFrameToken(request);
404     else if (hasName(request.token, metaTag))
405         didBlockScript |= filterMetaToken(request);
406     else if (hasName(request.token, baseTag))
407         didBlockScript |= filterBaseToken(request);
408     else if (hasName(request.token, formTag))
409         didBlockScript |= filterFormToken(request);
410     else if (hasName(request.token, inputTag))
411         didBlockScript |= filterInputToken(request);
412     else if (hasName(request.token, buttonTag))
413         didBlockScript |= filterButtonToken(request);
414
415     return didBlockScript;
416 }
417
418 void XSSAuditor::filterEndToken(const FilterTokenRequest& request)
419 {
420     ASSERT(m_scriptTagNestingLevel);
421     if (hasName(request.token, scriptTag)) {
422         m_scriptTagNestingLevel--;
423         ASSERT(request.shouldAllowCDATA || !m_scriptTagNestingLevel);
424     }
425 }
426
427 bool XSSAuditor::filterCharacterToken(const FilterTokenRequest& request)
428 {
429     ASSERT(m_scriptTagNestingLevel);
430     if (m_wasScriptTagFoundInRequest && isContainedInRequest(canonicalizedSnippetForJavaScript(request))) {
431         request.token.clear();
432         LChar space = ' ';
433         request.token.appendToCharacter(space); // Technically, character tokens can't be empty.
434         return true;
435     }
436     return false;
437 }
438
439 bool XSSAuditor::filterScriptToken(const FilterTokenRequest& request)
440 {
441     ASSERT(request.token.type() == HTMLToken::StartTag);
442     ASSERT(hasName(request.token, scriptTag));
443
444     m_wasScriptTagFoundInRequest = isContainedInRequest(canonicalizedSnippetForTagName(request));
445
446     bool didBlockScript = false;
447     if (m_wasScriptTagFoundInRequest) {
448         didBlockScript |= eraseAttributeIfInjected(request, srcAttr, blankURL().string(), TruncationStyle::SrcLikeAttribute);
449         didBlockScript |= eraseAttributeIfInjected(request, XLinkNames::hrefAttr, blankURL().string(), TruncationStyle::SrcLikeAttribute);
450     }
451
452     return didBlockScript;
453 }
454
455 bool XSSAuditor::filterObjectToken(const FilterTokenRequest& request)
456 {
457     ASSERT(request.token.type() == HTMLToken::StartTag);
458     ASSERT(hasName(request.token, objectTag));
459
460     bool didBlockScript = false;
461     if (isContainedInRequest(canonicalizedSnippetForTagName(request))) {
462         didBlockScript |= eraseAttributeIfInjected(request, dataAttr, blankURL().string(), TruncationStyle::SrcLikeAttribute);
463         didBlockScript |= eraseAttributeIfInjected(request, typeAttr);
464         didBlockScript |= eraseAttributeIfInjected(request, classidAttr);
465     }
466     return didBlockScript;
467 }
468
469 bool XSSAuditor::filterParamToken(const FilterTokenRequest& request)
470 {
471     ASSERT(request.token.type() == HTMLToken::StartTag);
472     ASSERT(hasName(request.token, paramTag));
473
474     size_t indexOfNameAttribute;
475     if (!findAttributeWithName(request.token, nameAttr, indexOfNameAttribute))
476         return false;
477
478     const HTMLToken::Attribute& nameAttribute = request.token.attributes().at(indexOfNameAttribute);
479     if (!HTMLParamElement::isURLParameter(String(nameAttribute.value)))
480         return false;
481
482     return eraseAttributeIfInjected(request, valueAttr, blankURL().string(), TruncationStyle::SrcLikeAttribute);
483 }
484
485 bool XSSAuditor::filterEmbedToken(const FilterTokenRequest& request)
486 {
487     ASSERT(request.token.type() == HTMLToken::StartTag);
488     ASSERT(hasName(request.token, embedTag));
489
490     bool didBlockScript = false;
491     if (isContainedInRequest(canonicalizedSnippetForTagName(request))) {
492         didBlockScript |= eraseAttributeIfInjected(request, codeAttr, String(), TruncationStyle::SrcLikeAttribute);
493         didBlockScript |= eraseAttributeIfInjected(request, srcAttr, blankURL().string(), TruncationStyle::SrcLikeAttribute);
494         didBlockScript |= eraseAttributeIfInjected(request, typeAttr);
495     }
496     return didBlockScript;
497 }
498
499 bool XSSAuditor::filterAppletToken(const FilterTokenRequest& request)
500 {
501     ASSERT(request.token.type() == HTMLToken::StartTag);
502     ASSERT(hasName(request.token, appletTag));
503
504     bool didBlockScript = false;
505     if (isContainedInRequest(canonicalizedSnippetForTagName(request))) {
506         didBlockScript |= eraseAttributeIfInjected(request, codeAttr, String(), TruncationStyle::SrcLikeAttribute);
507         didBlockScript |= eraseAttributeIfInjected(request, objectAttr);
508     }
509     return didBlockScript;
510 }
511
512 bool XSSAuditor::filterFrameToken(const FilterTokenRequest& request)
513 {
514     ASSERT(request.token.type() == HTMLToken::StartTag);
515     ASSERT(hasName(request.token, iframeTag) || hasName(request.token, frameTag));
516
517     bool didBlockScript = eraseAttributeIfInjected(request, srcdocAttr, String(), TruncationStyle::ScriptLikeAttribute);
518     if (isContainedInRequest(canonicalizedSnippetForTagName(request)))
519         didBlockScript |= eraseAttributeIfInjected(request, srcAttr, String(), TruncationStyle::SrcLikeAttribute);
520
521     return didBlockScript;
522 }
523
524 bool XSSAuditor::filterMetaToken(const FilterTokenRequest& request)
525 {
526     ASSERT(request.token.type() == HTMLToken::StartTag);
527     ASSERT(hasName(request.token, metaTag));
528
529     return eraseAttributeIfInjected(request, http_equivAttr);
530 }
531
532 bool XSSAuditor::filterBaseToken(const FilterTokenRequest& request)
533 {
534     ASSERT(request.token.type() == HTMLToken::StartTag);
535     ASSERT(hasName(request.token, baseTag));
536
537     return eraseAttributeIfInjected(request, hrefAttr);
538 }
539
540 bool XSSAuditor::filterFormToken(const FilterTokenRequest& request)
541 {
542     ASSERT(request.token.type() == HTMLToken::StartTag);
543     ASSERT(hasName(request.token, formTag));
544
545     return eraseAttributeIfInjected(request, actionAttr, blankURL().string());
546 }
547
548 bool XSSAuditor::filterInputToken(const FilterTokenRequest& request)
549 {
550     ASSERT(request.token.type() == HTMLToken::StartTag);
551     ASSERT(hasName(request.token, inputTag));
552
553     return eraseAttributeIfInjected(request, formactionAttr, blankURL().string(), TruncationStyle::SrcLikeAttribute);
554 }
555
556 bool XSSAuditor::filterButtonToken(const FilterTokenRequest& request)
557 {
558     ASSERT(request.token.type() == HTMLToken::StartTag);
559     ASSERT(hasName(request.token, buttonTag));
560
561     return eraseAttributeIfInjected(request, formactionAttr, blankURL().string(), TruncationStyle::SrcLikeAttribute);
562 }
563
564 bool XSSAuditor::eraseDangerousAttributesIfInjected(const FilterTokenRequest& request)
565 {
566     static NeverDestroyed<String> safeJavaScriptURL(ASCIILiteral("javascript:void(0)"));
567
568     bool didBlockScript = false;
569     for (size_t i = 0; i < request.token.attributes().size(); ++i) {
570         const HTMLToken::Attribute& attribute = request.token.attributes().at(i);
571         bool isInlineEventHandler = isNameOfInlineEventHandler(attribute.name);
572         // FIXME: It would be better if we didn't create a new String for every attribute in the document.
573         String strippedValue = stripLeadingAndTrailingHTMLSpaces(String(attribute.value));
574         bool valueContainsJavaScriptURL = (!isInlineEventHandler && protocolIsJavaScript(strippedValue)) || (isSemicolonSeparatedAttribute(attribute) && semicolonSeparatedValueContainsJavaScriptURL(strippedValue));
575         if (!isInlineEventHandler && !valueContainsJavaScriptURL)
576             continue;
577         if (!isContainedInRequest(canonicalize(snippetFromAttribute(request, attribute), TruncationStyle::ScriptLikeAttribute)))
578             continue;
579         request.token.eraseValueOfAttribute(i);
580         if (valueContainsJavaScriptURL)
581             request.token.appendToAttributeValue(i, safeJavaScriptURL.get());
582         didBlockScript = true;
583     }
584     return didBlockScript;
585 }
586
587 bool XSSAuditor::eraseAttributeIfInjected(const FilterTokenRequest& request, const QualifiedName& attributeName, const String& replacementValue, TruncationStyle truncationStyle)
588 {
589     size_t indexOfAttribute = 0;
590     if (!findAttributeWithName(request.token, attributeName, indexOfAttribute))
591         return false;
592
593     const HTMLToken::Attribute& attribute = request.token.attributes().at(indexOfAttribute);
594     if (!isContainedInRequest(canonicalize(snippetFromAttribute(request, attribute), truncationStyle)))
595         return false;
596
597     if (threadSafeMatch(attributeName, srcAttr)) {
598         if (isLikelySafeResource(String(attribute.value)))
599             return false;
600     } else if (threadSafeMatch(attributeName, http_equivAttr)) {
601         if (!isDangerousHTTPEquiv(String(attribute.value)))
602             return false;
603     }
604
605     request.token.eraseValueOfAttribute(indexOfAttribute);
606     if (!replacementValue.isEmpty())
607         request.token.appendToAttributeValue(indexOfAttribute, replacementValue);
608     return true;
609 }
610
611 String XSSAuditor::canonicalizedSnippetForTagName(const FilterTokenRequest& request)
612 {
613     // Grab a fixed number of characters equal to the length of the token's name plus one (to account for the "<").
614     return canonicalize(request.sourceTracker.source(request.token).substring(0, request.token.name().size() + 1), TruncationStyle::None);
615 }
616
617 String XSSAuditor::snippetFromAttribute(const FilterTokenRequest& request, const HTMLToken::Attribute& attribute)
618 {
619     // The range doesn't include the character which terminates the value. So,
620     // for an input of |name="value"|, the snippet is |name="value|. For an
621     // unquoted input of |name=value |, the snippet is |name=value|.
622     // FIXME: We should grab one character before the name also.
623     return request.sourceTracker.source(request.token, attribute.startOffset, attribute.endOffset);
624 }
625
626 String XSSAuditor::canonicalize(const String& snippet, TruncationStyle truncationStyle)
627 {
628     String decodedSnippet = fullyDecodeString(snippet, m_encoding);
629     if (truncationStyle != TruncationStyle::None) {
630         decodedSnippet.truncate(kMaximumFragmentLengthTarget);
631         if (truncationStyle == TruncationStyle::SrcLikeAttribute)
632             truncateForSrcLikeAttribute(decodedSnippet);
633         else if (truncationStyle == TruncationStyle::ScriptLikeAttribute)
634             truncateForScriptLikeAttribute(decodedSnippet);
635     }
636     return decodedSnippet.removeCharacters(&isNonCanonicalCharacter);
637 }
638
639 String XSSAuditor::canonicalizedSnippetForJavaScript(const FilterTokenRequest& request)
640 {
641     String string = request.sourceTracker.source(request.token);
642     size_t startPosition = 0;
643     size_t endPosition = string.length();
644     size_t foundPosition = notFound;
645     size_t lastNonSpacePosition = notFound;
646
647     // Skip over initial comments to find start of code.
648     while (startPosition < endPosition) {
649         while (startPosition < endPosition && isHTMLSpace(string[startPosition]))
650             startPosition++;
651
652         // Under SVG/XML rules, only HTML comment syntax matters and the parser returns
653         // these as a separate comment tokens. Having consumed whitespace, we need not look
654         // further for these.
655         if (request.shouldAllowCDATA)
656             break;
657
658         // Under HTML rules, both the HTML and JS comment synatx matters, and the HTML
659         // comment ends at the end of the line, not with -->.
660         if (startsHTMLCommentAt(string, startPosition) || startsSingleLineCommentAt(string, startPosition)) {
661             while (startPosition < endPosition && !isJSNewline(string[startPosition]))
662                 startPosition++;
663         } else if (startsMultiLineCommentAt(string, startPosition)) {
664             if (startPosition + 2 < endPosition && (foundPosition = string.find("*/", startPosition + 2)) != notFound)
665                 startPosition = foundPosition + 2;
666             else
667                 startPosition = endPosition;
668         } else
669             break;
670     }
671
672     String result;
673     while (startPosition < endPosition && !result.length()) {
674         // Stop at next comment (using the same rules as above for SVG/XML vs HTML), when we encounter a comma,
675         // when we hit an opening <script> tag, or when we exceed the maximum length target. The comma rule
676         // covers a common parameter concatenation case performed by some web servers.
677         lastNonSpacePosition = notFound;
678         for (foundPosition = startPosition; foundPosition < endPosition; foundPosition++) {
679             if (!request.shouldAllowCDATA) {
680                 if (startsSingleLineCommentAt(string, foundPosition)
681                     || startsMultiLineCommentAt(string, foundPosition)
682                     || startsHTMLCommentAt(string, foundPosition)) {
683                     break;
684                 }
685             }
686             if (string[foundPosition] == ',')
687                 break;
688
689             if (lastNonSpacePosition != notFound && startsOpeningScriptTagAt(string, foundPosition)) {
690                 foundPosition = lastNonSpacePosition + 1;
691                 break;
692             }
693             if (foundPosition > startPosition + kMaximumFragmentLengthTarget) {
694                 // After hitting the length target, we can only stop at a point where we know we are
695                 // not in the middle of a %-escape sequence. For the sake of simplicity, approximate
696                 // not stopping inside a (possibly multiply encoded) %-escape sequence by breaking on
697                 // whitespace only. We should have enough text in these cases to avoid false positives.
698                 if (isHTMLSpace(string[foundPosition]))
699                     break;
700             }
701
702             if (!isHTMLSpace(string[foundPosition]))
703                 lastNonSpacePosition = foundPosition;
704         }
705
706         result = canonicalize(string.substring(startPosition, foundPosition - startPosition), TruncationStyle::None);
707         startPosition = foundPosition + 1;
708     }
709     return result;
710 }
711
712 bool XSSAuditor::isContainedInRequest(const String& decodedSnippet)
713 {
714     if (decodedSnippet.isEmpty())
715         return false;
716     if (m_decodedURL.find(decodedSnippet, 0, false) != notFound)
717         return true;
718     if (m_decodedHTTPBodySuffixTree && !m_decodedHTTPBodySuffixTree->mightContain(decodedSnippet))
719         return false;
720     return m_decodedHTTPBody.find(decodedSnippet, 0, false) != notFound;
721 }
722
723 bool XSSAuditor::isLikelySafeResource(const String& url)
724 {
725     // Give empty URLs and about:blank a pass. Making a resourceURL from an
726     // empty string below will likely later fail the "no query args test" as
727     // it inherits the document's query args.
728     if (url.isEmpty() || url == blankURL().string())
729         return true;
730
731     // If the resource is loaded from the same host as the enclosing page, it's
732     // probably not an XSS attack, so we reduce false positives by allowing the
733     // request, ignoring scheme and port considerations. If the resource has a
734     // query string, we're more suspicious, however, because that's pretty rare
735     // and the attacker might be able to trick a server-side script into doing
736     // something dangerous with the query string.  
737     if (m_documentURL.host().isEmpty())
738         return false;
739
740     URL resourceURL(m_documentURL, url);
741     return (m_documentURL.host() == resourceURL.host() && resourceURL.query().isEmpty());
742 }
743
744 } // namespace WebCore