0d7997ba4251f3c239c20c3d60ca1d2f85d0aeba
[WebKit-https.git] / Source / WebCore / html / parser / XSSFilter.cpp
1 /*
2  * Copyright (C) 2011 Adam Barth. All Rights Reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
14  * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL APPLE INC. OR
17  * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
18  * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
19  * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
20  * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
21  * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 #include "config.h"
27 #include "XSSFilter.h"
28
29 #include "Console.h"
30 #include "DOMWindow.h"
31 #include "Document.h"
32 #include "DocumentLoader.h"
33 #include "Frame.h"
34 #include "HTMLDocumentParser.h"
35 #include "HTMLNames.h"
36 #include "HTMLParamElement.h"
37 #include "HTMLParserIdioms.h"
38 #include "SecurityOrigin.h"
39 #include "Settings.h"
40 #include "TextEncoding.h"
41 #include "TextResourceDecoder.h"
42 #include <wtf/text/CString.h>
43
44 namespace WebCore {
45
46 using namespace HTMLNames;
47
48 static bool isNonCanonicalCharacter(UChar c)
49 {
50     // We remove all non-ASCII characters, including non-printable ASCII characters.
51     //
52     // Note, we don't remove backslashes like PHP stripslashes(), which among other things converts "\\0" to the \0 character.
53     // Instead, we remove backslashes and zeros (since the string "\\0" =(remove backslashes)=> "0"). However, this has the 
54     // adverse effect that we remove any legitimate zeros from a string.
55     //
56     // For instance: new String("http://localhost:8000") => new String("http://localhost:8").
57     return (c == '\\' || c == '0' || c == '\0' || c >= 127);
58 }
59
60 static String canonicalize(const String& string)
61 {
62     return string.removeCharacters(&isNonCanonicalCharacter);
63 }
64
65 static bool isRequiredForInjection(UChar c)
66 {
67     return (c == '\'' || c == '"' || c == '<' || c == '>');
68 }
69
70 static bool hasName(const HTMLToken& token, const QualifiedName& name)
71 {
72     return equalIgnoringNullity(token.name(), static_cast<const String&>(name.localName()));
73 }
74
75 static bool findAttributeWithName(const HTMLToken& token, const QualifiedName& name, size_t& indexOfMatchingAttribute)
76 {
77     for (size_t i = 0; i < token.attributes().size(); ++i) {
78         if (equalIgnoringNullity(token.attributes().at(i).m_name, name.localName())) {
79             indexOfMatchingAttribute = i;
80             return true;
81         }
82     }
83     return false;
84 }
85
86 static bool isNameOfInlineEventHandler(const Vector<UChar, 32>& name)
87 {
88     const size_t lengthOfShortestInlineEventHandlerName = 5; // To wit: oncut.
89     if (name.size() < lengthOfShortestInlineEventHandlerName)
90         return false;
91     return name[0] == 'o' && name[1] == 'n';
92 }
93
94 static bool isDangerousHTTPEquiv(const String& value)
95 {
96     String equiv = value.stripWhiteSpace();
97     return equalIgnoringCase(equiv, "refresh") || equalIgnoringCase(equiv, "set-cookie");
98 }
99
100 static bool containsJavaScriptURL(const Vector<UChar, 32>& value)
101 {
102     static const char javaScriptScheme[] = "javascript:";
103     static const size_t lengthOfJavaScriptScheme = sizeof(javaScriptScheme) - 1;
104
105     size_t i;
106     for (i = 0; i < value.size(); ++i) {
107         if (!isHTMLSpace(value[i]))
108             break;
109     }
110
111     if (value.size() - i < lengthOfJavaScriptScheme)
112         return false;
113
114     return equalIgnoringCase(value.data() + i, javaScriptScheme, lengthOfJavaScriptScheme);
115 }
116
117 static String decodeURL(const String& string, const TextEncoding& encoding)
118 {
119     String workingString = string;
120     workingString.replace('+', ' ');
121     workingString = decodeURLEscapeSequences(workingString);
122     CString workingStringUTF8 = workingString.utf8();
123     String decodedString = encoding.decode(workingStringUTF8.data(), workingStringUTF8.length());
124     // FIXME: Is this check necessary?
125     if (decodedString.isEmpty())
126         return canonicalize(workingString);
127     return canonicalize(decodedString);
128 }
129
130 XSSFilter::XSSFilter(HTMLDocumentParser* parser)
131     : m_parser(parser)
132     , m_isEnabled(false)
133     , m_xssProtection(XSSProtectionEnabled)
134     , m_state(Uninitialized)
135 {
136     ASSERT(m_parser);
137     if (Frame* frame = parser->document()->frame()) {
138         if (Settings* settings = frame->settings())
139             m_isEnabled = settings->xssAuditorEnabled();
140     }
141     // Although tempting to call init() at this point, the various objects
142     // we want to reference might not all have been constructed yet.
143 }
144
145 void XSSFilter::init()
146 {
147     const size_t miniumLengthForSuffixTree = 512; // FIXME: Tune this parameter.
148     const int suffixTreeDepth = 5;
149
150     ASSERT(m_state == Uninitialized);
151     m_state = Initial;
152
153     if (!m_isEnabled)
154         return;
155     
156     // In theory, the Document could have detached from the Frame after the
157     // XSSFilter was constructed.
158     if (!m_parser->document()->frame()) {
159         m_isEnabled = false;
160         return;
161     }
162
163     const KURL& url = m_parser->document()->url();
164
165     if (url.protocolIsData()) {
166         m_isEnabled = false;
167         return;
168     }
169
170     TextResourceDecoder* decoder = m_parser->document()->decoder();
171     m_decodedURL = decoder ? decodeURL(url.string(), decoder->encoding()) : url.string();
172     if (m_decodedURL.find(isRequiredForInjection, 0) == notFound)
173         m_decodedURL = String();
174
175     if (DocumentLoader* documentLoader = m_parser->document()->frame()->loader()->documentLoader()) {
176         DEFINE_STATIC_LOCAL(String, XSSProtectionHeader, ("X-XSS-Protection"));
177         m_xssProtection = parseXSSProtectionHeader(documentLoader->response().httpHeaderField(XSSProtectionHeader));
178
179         FormData* httpBody = documentLoader->originalRequest().httpBody();
180         if (httpBody && !httpBody->isEmpty()) {
181             String httpBodyAsString = httpBody->flattenToString();
182             m_decodedHTTPBody = decoder ? decodeURL(httpBodyAsString, decoder->encoding()) : httpBodyAsString;
183             if (m_decodedHTTPBody.find(isRequiredForInjection, 0) == notFound)
184                 m_decodedHTTPBody = String();
185             if (m_decodedHTTPBody.length() >= miniumLengthForSuffixTree)
186                 m_decodedHTTPBodySuffixTree = adoptPtr(new SuffixTree<ASCIICodebook>(m_decodedHTTPBody, suffixTreeDepth));
187         }
188     }
189
190     if (m_decodedURL.isEmpty() && m_decodedHTTPBody.isEmpty())
191         m_isEnabled = false;
192 }
193
194 void XSSFilter::filterToken(HTMLToken& token)
195 {
196     if (m_state == Uninitialized) {
197         init();
198         ASSERT(m_state == Initial);
199     }
200
201     if (!m_isEnabled || m_xssProtection == XSSProtectionDisabled)
202         return;
203
204     bool didBlockScript = false;
205
206     switch (m_state) {
207     case Uninitialized:
208         ASSERT_NOT_REACHED();
209         break;
210     case Initial: 
211         didBlockScript = filterTokenInitial(token);
212         break;
213     case AfterScriptStartTag:
214         didBlockScript = filterTokenAfterScriptStartTag(token);
215         ASSERT(m_state == Initial);
216         m_cachedSnippet = String();
217         break;
218     }
219
220     if (didBlockScript) {
221         // FIXME: Consider using a more helpful console message.
222         DEFINE_STATIC_LOCAL(String, consoleMessage, ("Refused to execute a JavaScript script. Source code of script found within request.\n"));
223         // FIXME: We should add the real line number to the console.
224         m_parser->document()->domWindow()->console()->addMessage(JSMessageSource, LogMessageType, ErrorMessageLevel, consoleMessage, 1, String());
225
226         if (m_xssProtection == XSSProtectionBlockEnabled) {
227             m_parser->document()->frame()->loader()->stopAllLoaders();
228             m_parser->document()->frame()->navigationScheduler()->scheduleLocationChange(m_parser->document()->securityOrigin(), blankURL(), String());
229         }
230     }
231 }
232
233 bool XSSFilter::filterTokenInitial(HTMLToken& token)
234 {
235     ASSERT(m_state == Initial);
236
237     if (token.type() != HTMLToken::StartTag)
238         return false;
239
240     bool didBlockScript = eraseDangerousAttributesIfInjected(token);
241
242     if (hasName(token, scriptTag))
243         didBlockScript |= filterScriptToken(token);
244     else if (hasName(token, objectTag))
245         didBlockScript |= filterObjectToken(token);
246     else if (hasName(token, paramTag))
247         didBlockScript |= filterParamToken(token);
248     else if (hasName(token, embedTag))
249         didBlockScript |= filterEmbedToken(token);
250     else if (hasName(token, appletTag))
251         didBlockScript |= filterAppletToken(token);
252     else if (hasName(token, iframeTag))
253         didBlockScript |= filterIframeToken(token);
254     else if (hasName(token, metaTag))
255         didBlockScript |= filterMetaToken(token);
256     else if (hasName(token, baseTag))
257         didBlockScript |= filterBaseToken(token);
258     else if (hasName(token, formTag))
259         didBlockScript |= filterFormToken(token);
260
261     return didBlockScript;
262 }
263
264 bool XSSFilter::filterTokenAfterScriptStartTag(HTMLToken& token)
265 {
266     ASSERT(m_state == AfterScriptStartTag);
267     m_state = Initial;
268
269     if (token.type() != HTMLToken::Character) {
270         ASSERT(token.type() == HTMLToken::EndTag || token.type() == HTMLToken::EndOfFile);
271         return false;
272     }
273
274     int start = 0;
275     // FIXME: We probably want to grab only the first few characters of the
276     //        contents of the script element.
277     int end = token.endIndex() - token.startIndex();
278     if (isContainedInRequest(m_cachedSnippet + snippetForRange(token, start, end))) {
279         token.eraseCharacters();
280         token.appendToCharacter(' '); // Technically, character tokens can't be empty.
281         return true;
282     }
283     return false;
284 }
285
286 bool XSSFilter::filterScriptToken(HTMLToken& token)
287 {
288     ASSERT(m_state == Initial);
289     ASSERT(token.type() == HTMLToken::StartTag);
290     ASSERT(hasName(token, scriptTag));
291
292     if (eraseAttributeIfInjected(token, srcAttr, blankURL().string()))
293         return true;
294
295     m_state = AfterScriptStartTag;
296     m_cachedSnippet = m_parser->sourceForToken(token);
297     return false;
298 }
299
300 bool XSSFilter::filterObjectToken(HTMLToken& token)
301 {
302     ASSERT(m_state == Initial);
303     ASSERT(token.type() == HTMLToken::StartTag);
304     ASSERT(hasName(token, objectTag));
305
306     bool didBlockScript = false;
307
308     didBlockScript |= eraseAttributeIfInjected(token, dataAttr, blankURL().string());
309     didBlockScript |= eraseAttributeIfInjected(token, typeAttr);
310     didBlockScript |= eraseAttributeIfInjected(token, classidAttr);
311
312     return didBlockScript;
313 }
314
315 bool XSSFilter::filterParamToken(HTMLToken& token)
316 {
317     ASSERT(m_state == Initial);
318     ASSERT(token.type() == HTMLToken::StartTag);
319     ASSERT(hasName(token, paramTag));
320
321     size_t indexOfNameAttribute;
322     if (!findAttributeWithName(token, nameAttr, indexOfNameAttribute))
323         return false;
324
325     const HTMLToken::Attribute& nameAttribute = token.attributes().at(indexOfNameAttribute);
326     String name = String(nameAttribute.m_value.data(), nameAttribute.m_value.size());
327
328     if (!HTMLParamElement::isURLParameter(name))
329         return false;
330
331     return eraseAttributeIfInjected(token, valueAttr, blankURL().string());
332 }
333
334 bool XSSFilter::filterEmbedToken(HTMLToken& token)
335 {
336     ASSERT(m_state == Initial);
337     ASSERT(token.type() == HTMLToken::StartTag);
338     ASSERT(hasName(token, embedTag));
339
340     bool didBlockScript = false;
341
342     didBlockScript |= eraseAttributeIfInjected(token, srcAttr, blankURL().string());
343     didBlockScript |= eraseAttributeIfInjected(token, typeAttr);
344
345     return didBlockScript;
346 }
347
348 bool XSSFilter::filterAppletToken(HTMLToken& token)
349 {
350     ASSERT(m_state == Initial);
351     ASSERT(token.type() == HTMLToken::StartTag);
352     ASSERT(hasName(token, appletTag));
353
354     bool didBlockScript = false;
355
356     didBlockScript |= eraseAttributeIfInjected(token, codeAttr);
357     didBlockScript |= eraseAttributeIfInjected(token, objectAttr);
358
359     return didBlockScript;
360 }
361
362 bool XSSFilter::filterIframeToken(HTMLToken& token)
363 {
364     ASSERT(m_state == Initial);
365     ASSERT(token.type() == HTMLToken::StartTag);
366     ASSERT(hasName(token, iframeTag));
367
368     return eraseAttributeIfInjected(token, srcAttr);
369 }
370
371 bool XSSFilter::filterMetaToken(HTMLToken& token)
372 {
373     ASSERT(m_state == Initial);
374     ASSERT(token.type() == HTMLToken::StartTag);
375     ASSERT(hasName(token, metaTag));
376
377     return eraseAttributeIfInjected(token, http_equivAttr);
378 }
379
380 bool XSSFilter::filterBaseToken(HTMLToken& token)
381 {
382     ASSERT(m_state == Initial);
383     ASSERT(token.type() == HTMLToken::StartTag);
384     ASSERT(hasName(token, baseTag));
385
386     return eraseAttributeIfInjected(token, hrefAttr);
387 }
388
389 bool XSSFilter::filterFormToken(HTMLToken& token)
390 {
391     ASSERT(m_state == Initial);
392     ASSERT(token.type() == HTMLToken::StartTag);
393     ASSERT(hasName(token, formTag));
394
395     return eraseAttributeIfInjected(token, actionAttr);
396 }
397
398 bool XSSFilter::eraseDangerousAttributesIfInjected(HTMLToken& token)
399 {
400     DEFINE_STATIC_LOCAL(String, safeJavaScriptURL, ("javascript:void(0)"));
401
402     bool didBlockScript = false;
403     for (size_t i = 0; i < token.attributes().size(); ++i) {
404         const HTMLToken::Attribute& attribute = token.attributes().at(i);
405         bool isInlineEventHandler = isNameOfInlineEventHandler(attribute.m_name);
406         bool valueContainsJavaScriptURL = isInlineEventHandler ? false : containsJavaScriptURL(attribute.m_value);
407         if (!isInlineEventHandler && !valueContainsJavaScriptURL)
408             continue;
409         if (!isContainedInRequest(snippetForAttribute(token, attribute)))
410             continue;
411         token.eraseValueOfAttribute(i);
412         if (valueContainsJavaScriptURL)
413             token.appendToAttributeValue(i, safeJavaScriptURL);
414         didBlockScript = true;
415     }
416     return didBlockScript;
417 }
418
419 bool XSSFilter::eraseAttributeIfInjected(HTMLToken& token, const QualifiedName& attributeName, const String& replacementValue)
420 {
421     size_t indexOfAttribute;
422     if (findAttributeWithName(token, attributeName, indexOfAttribute)) {
423         const HTMLToken::Attribute& attribute = token.attributes().at(indexOfAttribute);
424         if (isContainedInRequest(snippetForAttribute(token, attribute))) {
425             if (attributeName == srcAttr && isSameOriginResource(String(attribute.m_value.data(), attribute.m_value.size())))
426                 return false;
427             if (attributeName == http_equivAttr && !isDangerousHTTPEquiv(String(attribute.m_value.data(), attribute.m_value.size())))
428                 return false;
429             token.eraseValueOfAttribute(indexOfAttribute);
430             if (!replacementValue.isEmpty())
431                 token.appendToAttributeValue(indexOfAttribute, replacementValue);
432             return true;
433         }
434     }
435     return false;
436 }
437
438 String XSSFilter::snippetForRange(const HTMLToken& token, int start, int end)
439 {
440     // FIXME: There's an extra allocation here that we could save by
441     //        passing the range to the parser.
442     return m_parser->sourceForToken(token).substring(start, end - start);
443 }
444
445 String XSSFilter::snippetForAttribute(const HTMLToken& token, const HTMLToken::Attribute& attribute)
446 {
447     // FIXME: We should grab one character before the name also.
448     int start = attribute.m_nameRange.m_start - token.startIndex();
449     // FIXME: We probably want to grab only the first few characters of the attribute value.
450     int end = attribute.m_valueRange.m_end - token.startIndex();
451     return snippetForRange(token, start, end);
452 }
453
454 bool XSSFilter::isContainedInRequest(const String& snippet)
455 {
456     ASSERT(!snippet.isEmpty());
457     String canonicalizedSnippet = canonicalize(snippet);
458     ASSERT(!canonicalizedSnippet.isEmpty());
459     if (m_decodedURL.find(canonicalizedSnippet, 0, false) != notFound)
460         return true;
461     if (m_decodedHTTPBodySuffixTree && !m_decodedHTTPBodySuffixTree->mightContain(canonicalizedSnippet))
462         return false;
463     return m_decodedHTTPBody.find(canonicalizedSnippet, 0, false) != notFound;
464 }
465
466 bool XSSFilter::isSameOriginResource(const String& url)
467 {
468     // If the resource is loaded from the same URL as the enclosing page, it's
469     // probably not an XSS attack, so we reduce false positives by allowing the
470     // request. If the resource has a query string, we're more suspicious,
471     // however, because that's pretty rare and the attacker might be able to
472     // trick a server-side script into doing something dangerous with the query
473     // string.
474     KURL resourceURL(m_parser->document()->url(), url);
475     return (m_parser->document()->url().host() == resourceURL.host() && resourceURL.query().isEmpty());
476 }
477
478 }