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