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