c6300c1911fd7ec51f76e5103efc037db033b0ca
[WebKit.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 "DOMWindow.h"
30 #include "Document.h"
31 #include "DocumentLoader.h"
32 #include "Frame.h"
33 #include "HTMLDocumentParser.h"
34 #include "HTMLNames.h"
35 #include "HTMLParamElement.h"
36 #include "HTMLParserIdioms.h"
37 #include "Settings.h"
38 #include "TextEncoding.h"
39 #include "TextResourceDecoder.h"
40 #include <wtf/text/CString.h>
41
42 namespace WebCore {
43
44 using namespace HTMLNames;
45
46 namespace {
47
48 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 String canonicalize(const String& string)
61 {
62     return string.removeCharacters(&isNonCanonicalCharacter);
63 }
64
65 bool isRequiredForInjection(UChar c)
66 {
67     return (c == '\'' || c == '"' || c == '<' || c == '>');
68 }
69
70 bool hasName(const HTMLToken& token, const QualifiedName& name)
71 {
72     return equalIgnoringNullity(token.name(), static_cast<const String&>(name.localName()));
73 }
74
75 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 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 bool containsJavaScriptURL(const Vector<UChar, 32>& value)
95 {
96     static const char javaScriptScheme[] = "javascript:";
97     static const size_t lengthOfJavaScriptScheme = sizeof(javaScriptScheme) - 1;
98
99     size_t i;;
100     for (i = 0; i < value.size(); ++i) {
101         if (!isHTMLSpace(value[i]))
102             break;
103     }
104
105     if (value.size() - i < lengthOfJavaScriptScheme)
106         return false;
107
108     return equalIgnoringCase(value.data() + i, javaScriptScheme, lengthOfJavaScriptScheme);
109 }
110
111 String decodeURL(const String& string, const TextEncoding& encoding)
112 {
113     String workingString = string;
114     workingString.replace('+', ' ');
115     workingString = decodeURLEscapeSequences(workingString);
116     CString workingStringUTF8 = workingString.utf8();
117     String decodedString = encoding.decode(workingStringUTF8.data(), workingStringUTF8.length());
118     // FIXME: Is this check necessary?
119     if (decodedString.isEmpty())
120         return canonicalize(workingString);
121     return canonicalize(decodedString);
122 }
123
124 }
125
126 XSSFilter::XSSFilter(HTMLDocumentParser* parser)
127     : m_parser(parser)
128     , m_isEnabled(false)
129     , m_xssProtection(XSSProtectionEnabled)
130     , m_state(Uninitialized)
131 {
132     ASSERT(m_parser);
133     if (Frame* frame = parser->document()->frame()) {
134         if (Settings* settings = frame->settings())
135             m_isEnabled = settings->xssAuditorEnabled();
136     }
137     // Although tempting to call init() at this point, the various objects
138     // we want to reference might not all have been constructed yet.
139 }
140
141 void XSSFilter::init()
142 {
143     const size_t miniumLengthForSuffixTree = 512; // FIXME: Tune this parameter.
144     const int suffixTreeDepth = 5;
145
146     ASSERT(m_state == Uninitialized);
147     m_state = Initial;
148
149     if (!m_isEnabled)
150         return;
151     
152     // In theory, the Document could have detached from the Frame after the
153     // XSSFilter was constructed.
154     if (!m_parser->document()->frame()) {
155         m_isEnabled = false;
156         return;
157     }
158
159     const TextEncoding& encoding = m_parser->document()->decoder()->encoding();
160     const KURL& url = m_parser->document()->url();
161     if (url.protocolIsData()) {
162         m_isEnabled = false;
163         return;
164     }
165     m_decodedURL = decodeURL(url.string(), encoding);
166     if (m_decodedURL.find(isRequiredForInjection, 0) == notFound)
167         m_decodedURL = String();
168
169     if (DocumentLoader* documentLoader = m_parser->document()->frame()->loader()->documentLoader()) {
170         DEFINE_STATIC_LOCAL(String, XSSProtectionHeader, ("X-XSS-Protection"));
171         m_xssProtection = parseXSSProtectionHeader(documentLoader->response().httpHeaderField(XSSProtectionHeader));
172
173         FormData* httpBody = documentLoader->originalRequest().httpBody();
174         if (httpBody && !httpBody->isEmpty()) {
175             m_decodedHTTPBody = decodeURL(httpBody->flattenToString(), encoding);
176             if (m_decodedHTTPBody.find(isRequiredForInjection, 0) == notFound)
177                 m_decodedHTTPBody = String();
178             if (m_decodedHTTPBody.length() >= miniumLengthForSuffixTree)
179                 m_decodedHTTPBodySuffixTree = adoptPtr(new SuffixTree<ASCIICodebook>(m_decodedHTTPBody, suffixTreeDepth));
180         }
181     }
182
183     if (m_decodedURL.isEmpty() && m_decodedHTTPBody.isEmpty())
184         m_isEnabled = false;
185 }
186
187 void XSSFilter::filterToken(HTMLToken& token)
188 {
189     if (m_state == Uninitialized) {
190         init();
191         ASSERT(m_state == Initial);
192     }
193
194     if (!m_isEnabled || m_xssProtection == XSSProtectionDisabled)
195         return;
196
197     bool didBlockScript = false;
198
199     switch (m_state) {
200     case Uninitialized:
201         ASSERT_NOT_REACHED();
202         break;
203     case Initial: 
204         didBlockScript = filterTokenInitial(token);
205         break;
206     case AfterScriptStartTag:
207         didBlockScript = filterTokenAfterScriptStartTag(token);
208         ASSERT(m_state == Initial);
209         m_cachedSnippet = String();
210         break;
211     }
212
213     if (didBlockScript) {
214         // FIXME: Consider using a more helpful console message.
215         DEFINE_STATIC_LOCAL(String, consoleMessage, ("Refused to execute a JavaScript script. Source code of script found within request.\n"));
216         // FIXME: We should add the real line number to the console.
217         m_parser->document()->domWindow()->console()->addMessage(JSMessageSource, LogMessageType, ErrorMessageLevel, consoleMessage, 1, String());
218
219         if (m_xssProtection == XSSProtectionBlockEnabled) {
220             m_parser->document()->frame()->loader()->stopAllLoaders();
221             m_parser->document()->frame()->navigationScheduler()->scheduleLocationChange(m_parser->document()->securityOrigin(), blankURL(), String());
222         }
223     }
224 }
225
226 bool XSSFilter::filterTokenInitial(HTMLToken& token)
227 {
228     ASSERT(m_state == Initial);
229
230     if (token.type() != HTMLToken::StartTag)
231         return false;
232
233     bool didBlockScript = eraseDangerousAttributesIfInjected(token);
234
235     if (hasName(token, scriptTag))
236         didBlockScript |= filterScriptToken(token);
237     else if (hasName(token, objectTag))
238         didBlockScript |= filterObjectToken(token);
239     else if (hasName(token, paramTag))
240         didBlockScript |= filterParamToken(token);
241     else if (hasName(token, embedTag))
242         didBlockScript |= filterEmbedToken(token);
243     else if (hasName(token, appletTag))
244         didBlockScript |= filterAppletToken(token);
245     else if (hasName(token, metaTag))
246         didBlockScript |= filterMetaToken(token);
247     else if (hasName(token, baseTag))
248         didBlockScript |= filterBaseToken(token);
249
250     return didBlockScript;
251 }
252
253 bool XSSFilter::filterTokenAfterScriptStartTag(HTMLToken& token)
254 {
255     ASSERT(m_state == AfterScriptStartTag);
256     m_state = Initial;
257
258     if (token.type() != HTMLToken::Character) {
259         ASSERT(token.type() == HTMLToken::EndTag || token.type() == HTMLToken::EndOfFile);
260         return false;
261     }
262
263     int start = 0;
264     // FIXME: We probably want to grab only the first few characters of the
265     //        contents of the script element.
266     int end = token.endIndex() - token.startIndex();
267     if (isContainedInRequest(m_cachedSnippet + snippetForRange(token, start, end))) {
268         token.eraseCharacters();
269         token.appendToCharacter(' '); // Technically, character tokens can't be empty.
270         return true;
271     }
272     return false;
273 }
274
275 bool XSSFilter::filterScriptToken(HTMLToken& token)
276 {
277     ASSERT(m_state == Initial);
278     ASSERT(token.type() == HTMLToken::StartTag);
279     ASSERT(hasName(token, scriptTag));
280
281     if (eraseAttributeIfInjected(token, srcAttr, blankURL().string()))
282         return true;
283
284     m_state = AfterScriptStartTag;
285     m_cachedSnippet = m_parser->sourceForToken(token);
286     return false;
287 }
288
289 bool XSSFilter::filterObjectToken(HTMLToken& token)
290 {
291     ASSERT(m_state == Initial);
292     ASSERT(token.type() == HTMLToken::StartTag);
293     ASSERT(hasName(token, objectTag));
294
295     bool didBlockScript = false;
296
297     didBlockScript |= eraseAttributeIfInjected(token, dataAttr, blankURL().string());
298     didBlockScript |= eraseAttributeIfInjected(token, typeAttr);
299     didBlockScript |= eraseAttributeIfInjected(token, classidAttr);
300
301     return didBlockScript;
302 }
303
304 bool XSSFilter::filterParamToken(HTMLToken& token)
305 {
306     ASSERT(m_state == Initial);
307     ASSERT(token.type() == HTMLToken::StartTag);
308     ASSERT(hasName(token, paramTag));
309
310     size_t indexOfNameAttribute;
311     if (!findAttributeWithName(token, nameAttr, indexOfNameAttribute))
312         return false;
313
314     const HTMLToken::Attribute& nameAttribute = token.attributes().at(indexOfNameAttribute);
315     String name = String(nameAttribute.m_value.data(), nameAttribute.m_value.size());
316
317     if (!HTMLParamElement::isURLParameter(name))
318         return false;
319
320     return eraseAttributeIfInjected(token, valueAttr, blankURL().string());
321 }
322
323 bool XSSFilter::filterEmbedToken(HTMLToken& token)
324 {
325     ASSERT(m_state == Initial);
326     ASSERT(token.type() == HTMLToken::StartTag);
327     ASSERT(hasName(token, embedTag));
328
329     bool didBlockScript = false;
330
331     didBlockScript |= eraseAttributeIfInjected(token, srcAttr, blankURL().string());
332     didBlockScript |= eraseAttributeIfInjected(token, typeAttr);
333
334     return didBlockScript;
335 }
336
337 bool XSSFilter::filterAppletToken(HTMLToken& token)
338 {
339     ASSERT(m_state == Initial);
340     ASSERT(token.type() == HTMLToken::StartTag);
341     ASSERT(hasName(token, appletTag));
342
343     bool didBlockScript = false;
344
345     didBlockScript |= eraseAttributeIfInjected(token, codeAttr);
346     didBlockScript |= eraseAttributeIfInjected(token, objectAttr);
347
348     return didBlockScript;
349 }
350
351 bool XSSFilter::filterMetaToken(HTMLToken& token)
352 {
353     ASSERT(m_state == Initial);
354     ASSERT(token.type() == HTMLToken::StartTag);
355     ASSERT(hasName(token, metaTag));
356
357     return eraseAttributeIfInjected(token, http_equivAttr);
358 }
359
360 bool XSSFilter::filterBaseToken(HTMLToken& token)
361 {
362     ASSERT(m_state == Initial);
363     ASSERT(token.type() == HTMLToken::StartTag);
364     ASSERT(hasName(token, baseTag));
365
366     return eraseAttributeIfInjected(token, hrefAttr);
367 }
368
369 bool XSSFilter::eraseDangerousAttributesIfInjected(HTMLToken& token)
370 {
371     DEFINE_STATIC_LOCAL(String, safeJavaScriptURL, ("javascript:void(0)"));
372
373     bool didBlockScript = false;
374     for (size_t i = 0; i < token.attributes().size(); ++i) {
375         const HTMLToken::Attribute& attribute = token.attributes().at(i);
376         bool isInlineEventHandler = isNameOfInlineEventHandler(attribute.m_name);
377         bool valueContainsJavaScriptURL = isInlineEventHandler ? false : containsJavaScriptURL(attribute.m_value);
378         if (!isInlineEventHandler && !valueContainsJavaScriptURL)
379             continue;
380         if (!isContainedInRequest(snippetForAttribute(token, attribute)))
381             continue;
382         token.eraseValueOfAttribute(i);
383         if (valueContainsJavaScriptURL)
384             token.appendToAttributeValue(i, safeJavaScriptURL);
385         didBlockScript = true;
386     }
387     return didBlockScript;
388 }
389
390 bool XSSFilter::eraseAttributeIfInjected(HTMLToken& token, const QualifiedName& attributeName, const String& replacementValue)
391 {
392     size_t indexOfAttribute;
393     if (findAttributeWithName(token, attributeName, indexOfAttribute)) {
394         const HTMLToken::Attribute& attribute = token.attributes().at(indexOfAttribute);
395         if (isContainedInRequest(snippetForAttribute(token, attribute))) {
396             if (attributeName == srcAttr && isSameOriginResource(String(attribute.m_value.data(), attribute.m_value.size())))
397                 return false;
398             token.eraseValueOfAttribute(indexOfAttribute);
399             if (!replacementValue.isEmpty())
400                 token.appendToAttributeValue(indexOfAttribute, replacementValue);
401             return true;
402         }
403     }
404     return false;
405 }
406
407 String XSSFilter::snippetForRange(const HTMLToken& token, int start, int end)
408 {
409     // FIXME: There's an extra allocation here that we could save by
410     //        passing the range to the parser.
411     return m_parser->sourceForToken(token).substring(start, end - start);
412 }
413
414 String XSSFilter::snippetForAttribute(const HTMLToken& token, const HTMLToken::Attribute& attribute)
415 {
416     // FIXME: We should grab one character before the name also.
417     int start = attribute.m_nameRange.m_start - token.startIndex();
418     // FIXME: We probably want to grab only the first few characters of the attribute value.
419     int end = attribute.m_valueRange.m_end - token.startIndex();
420     return snippetForRange(token, start, end);
421 }
422
423 bool XSSFilter::isContainedInRequest(const String& snippet)
424 {
425     ASSERT(!snippet.isEmpty());
426     String canonicalizedSnippet = canonicalize(snippet);
427     ASSERT(!canonicalizedSnippet.isEmpty());
428     if (m_decodedURL.find(canonicalizedSnippet, 0, false) != notFound)
429         return true;
430     if (m_decodedHTTPBodySuffixTree && !m_decodedHTTPBodySuffixTree->mightContain(canonicalizedSnippet))
431         return false;
432     return m_decodedHTTPBody.find(canonicalizedSnippet, 0, false) != notFound;
433 }
434
435 bool XSSFilter::isSameOriginResource(const String& url)
436 {
437     // If the resource is loaded from the same URL as the enclosing page, it's
438     // probably not an XSS attack, so we reduce false positives by allowing the
439     // request. If the resource has a query string, we're more suspicious,
440     // however, because that's pretty rare and the attacker might be able to
441     // trick a server-side script into doing something dangerous with the query
442     // string.
443     KURL resourceURL(m_parser->document()->url(), url);
444     return (m_parser->document()->url().host() == resourceURL.host() && resourceURL.query().isEmpty());
445 }
446
447 }