blob: 27bff31a48ace88d5feda9470d4ae0c3d370d3cb [file] [log] [blame]
/*
* Copyright (C) 2011 Adam Barth. All Rights Reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
* OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
#include "config.h"
#include "XSSFilter.h"
#include "DOMWindow.h"
#include "Document.h"
#include "DocumentLoader.h"
#include "Frame.h"
#include "HTMLDocumentParser.h"
#include "HTMLNames.h"
#include "HTMLParamElement.h"
#include "HTMLParserIdioms.h"
#include "Settings.h"
#include "TextEncoding.h"
#include "TextResourceDecoder.h"
#include <wtf/text/CString.h>
namespace WebCore {
using namespace HTMLNames;
static bool isNonCanonicalCharacter(UChar c)
{
// We remove all non-ASCII characters, including non-printable ASCII characters.
//
// Note, we don't remove backslashes like PHP stripslashes(), which among other things converts "\\0" to the \0 character.
// Instead, we remove backslashes and zeros (since the string "\\0" =(remove backslashes)=> "0"). However, this has the
// adverse effect that we remove any legitimate zeros from a string.
//
// For instance: new String("http://localhost:8000") => new String("http://localhost:8").
return (c == '\\' || c == '0' || c == '\0' || c >= 127);
}
static String canonicalize(const String& string)
{
return string.removeCharacters(&isNonCanonicalCharacter);
}
static bool isRequiredForInjection(UChar c)
{
return (c == '\'' || c == '"' || c == '<' || c == '>');
}
static bool hasName(const HTMLToken& token, const QualifiedName& name)
{
return equalIgnoringNullity(token.name(), static_cast<const String&>(name.localName()));
}
static bool findAttributeWithName(const HTMLToken& token, const QualifiedName& name, size_t& indexOfMatchingAttribute)
{
for (size_t i = 0; i < token.attributes().size(); ++i) {
if (equalIgnoringNullity(token.attributes().at(i).m_name, name.localName())) {
indexOfMatchingAttribute = i;
return true;
}
}
return false;
}
static bool isNameOfInlineEventHandler(const Vector<UChar, 32>& name)
{
const size_t lengthOfShortestInlineEventHandlerName = 5; // To wit: oncut.
if (name.size() < lengthOfShortestInlineEventHandlerName)
return false;
return name[0] == 'o' && name[1] == 'n';
}
static bool isDangerousHTTPEquiv(const String& value)
{
String equiv = value.stripWhiteSpace();
return equalIgnoringCase(equiv, "refresh") || equalIgnoringCase(equiv, "set-cookie");
}
static bool containsJavaScriptURL(const Vector<UChar, 32>& value)
{
static const char javaScriptScheme[] = "javascript:";
static const size_t lengthOfJavaScriptScheme = sizeof(javaScriptScheme) - 1;
size_t i;
for (i = 0; i < value.size(); ++i) {
if (!isHTMLSpace(value[i]))
break;
}
if (value.size() - i < lengthOfJavaScriptScheme)
return false;
return equalIgnoringCase(value.data() + i, javaScriptScheme, lengthOfJavaScriptScheme);
}
static String decodeURL(const String& string, const TextEncoding& encoding)
{
String workingString = string;
workingString.replace('+', ' ');
workingString = decodeURLEscapeSequences(workingString);
CString workingStringUTF8 = workingString.utf8();
String decodedString = encoding.decode(workingStringUTF8.data(), workingStringUTF8.length());
// FIXME: Is this check necessary?
if (decodedString.isEmpty())
return canonicalize(workingString);
return canonicalize(decodedString);
}
XSSFilter::XSSFilter(HTMLDocumentParser* parser)
: m_parser(parser)
, m_isEnabled(false)
, m_xssProtection(XSSProtectionEnabled)
, m_state(Uninitialized)
{
ASSERT(m_parser);
if (Frame* frame = parser->document()->frame()) {
if (Settings* settings = frame->settings())
m_isEnabled = settings->xssAuditorEnabled();
}
// Although tempting to call init() at this point, the various objects
// we want to reference might not all have been constructed yet.
}
void XSSFilter::init()
{
const size_t miniumLengthForSuffixTree = 512; // FIXME: Tune this parameter.
const int suffixTreeDepth = 5;
ASSERT(m_state == Uninitialized);
m_state = Initial;
if (!m_isEnabled)
return;
// In theory, the Document could have detached from the Frame after the
// XSSFilter was constructed.
if (!m_parser->document()->frame()) {
m_isEnabled = false;
return;
}
const KURL& url = m_parser->document()->url();
if (url.protocolIsData()) {
m_isEnabled = false;
return;
}
TextResourceDecoder* decoder = m_parser->document()->decoder();
m_decodedURL = decoder ? decodeURL(url.string(), decoder->encoding()) : url.string();
if (m_decodedURL.find(isRequiredForInjection, 0) == notFound)
m_decodedURL = String();
if (DocumentLoader* documentLoader = m_parser->document()->frame()->loader()->documentLoader()) {
DEFINE_STATIC_LOCAL(String, XSSProtectionHeader, ("X-XSS-Protection"));
m_xssProtection = parseXSSProtectionHeader(documentLoader->response().httpHeaderField(XSSProtectionHeader));
FormData* httpBody = documentLoader->originalRequest().httpBody();
if (httpBody && !httpBody->isEmpty()) {
String httpBodyAsString = httpBody->flattenToString();
m_decodedHTTPBody = decoder ? decodeURL(httpBodyAsString, decoder->encoding()) : httpBodyAsString;
if (m_decodedHTTPBody.find(isRequiredForInjection, 0) == notFound)
m_decodedHTTPBody = String();
if (m_decodedHTTPBody.length() >= miniumLengthForSuffixTree)
m_decodedHTTPBodySuffixTree = adoptPtr(new SuffixTree<ASCIICodebook>(m_decodedHTTPBody, suffixTreeDepth));
}
}
if (m_decodedURL.isEmpty() && m_decodedHTTPBody.isEmpty())
m_isEnabled = false;
}
void XSSFilter::filterToken(HTMLToken& token)
{
if (m_state == Uninitialized) {
init();
ASSERT(m_state == Initial);
}
if (!m_isEnabled || m_xssProtection == XSSProtectionDisabled)
return;
bool didBlockScript = false;
switch (m_state) {
case Uninitialized:
ASSERT_NOT_REACHED();
break;
case Initial:
didBlockScript = filterTokenInitial(token);
break;
case AfterScriptStartTag:
didBlockScript = filterTokenAfterScriptStartTag(token);
ASSERT(m_state == Initial);
m_cachedSnippet = String();
break;
}
if (didBlockScript) {
// FIXME: Consider using a more helpful console message.
DEFINE_STATIC_LOCAL(String, consoleMessage, ("Refused to execute a JavaScript script. Source code of script found within request.\n"));
// FIXME: We should add the real line number to the console.
m_parser->document()->domWindow()->console()->addMessage(JSMessageSource, LogMessageType, ErrorMessageLevel, consoleMessage, 1, String());
if (m_xssProtection == XSSProtectionBlockEnabled) {
m_parser->document()->frame()->loader()->stopAllLoaders();
m_parser->document()->frame()->navigationScheduler()->scheduleLocationChange(m_parser->document()->securityOrigin(), blankURL(), String());
}
}
}
bool XSSFilter::filterTokenInitial(HTMLToken& token)
{
ASSERT(m_state == Initial);
if (token.type() != HTMLToken::StartTag)
return false;
bool didBlockScript = eraseDangerousAttributesIfInjected(token);
if (hasName(token, scriptTag))
didBlockScript |= filterScriptToken(token);
else if (hasName(token, objectTag))
didBlockScript |= filterObjectToken(token);
else if (hasName(token, paramTag))
didBlockScript |= filterParamToken(token);
else if (hasName(token, embedTag))
didBlockScript |= filterEmbedToken(token);
else if (hasName(token, appletTag))
didBlockScript |= filterAppletToken(token);
else if (hasName(token, iframeTag))
didBlockScript |= filterIframeToken(token);
else if (hasName(token, metaTag))
didBlockScript |= filterMetaToken(token);
else if (hasName(token, baseTag))
didBlockScript |= filterBaseToken(token);
else if (hasName(token, formTag))
didBlockScript |= filterFormToken(token);
return didBlockScript;
}
bool XSSFilter::filterTokenAfterScriptStartTag(HTMLToken& token)
{
ASSERT(m_state == AfterScriptStartTag);
m_state = Initial;
if (token.type() != HTMLToken::Character) {
ASSERT(token.type() == HTMLToken::EndTag || token.type() == HTMLToken::EndOfFile);
return false;
}
int start = 0;
// FIXME: We probably want to grab only the first few characters of the
// contents of the script element.
int end = token.endIndex() - token.startIndex();
if (isContainedInRequest(m_cachedSnippet + snippetForRange(token, start, end))) {
token.eraseCharacters();
token.appendToCharacter(' '); // Technically, character tokens can't be empty.
return true;
}
return false;
}
bool XSSFilter::filterScriptToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, scriptTag));
if (eraseAttributeIfInjected(token, srcAttr, blankURL().string()))
return true;
m_state = AfterScriptStartTag;
m_cachedSnippet = m_parser->sourceForToken(token);
return false;
}
bool XSSFilter::filterObjectToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, objectTag));
bool didBlockScript = false;
didBlockScript |= eraseAttributeIfInjected(token, dataAttr, blankURL().string());
didBlockScript |= eraseAttributeIfInjected(token, typeAttr);
didBlockScript |= eraseAttributeIfInjected(token, classidAttr);
return didBlockScript;
}
bool XSSFilter::filterParamToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, paramTag));
size_t indexOfNameAttribute;
if (!findAttributeWithName(token, nameAttr, indexOfNameAttribute))
return false;
const HTMLToken::Attribute& nameAttribute = token.attributes().at(indexOfNameAttribute);
String name = String(nameAttribute.m_value.data(), nameAttribute.m_value.size());
if (!HTMLParamElement::isURLParameter(name))
return false;
return eraseAttributeIfInjected(token, valueAttr, blankURL().string());
}
bool XSSFilter::filterEmbedToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, embedTag));
bool didBlockScript = false;
didBlockScript |= eraseAttributeIfInjected(token, srcAttr, blankURL().string());
didBlockScript |= eraseAttributeIfInjected(token, typeAttr);
return didBlockScript;
}
bool XSSFilter::filterAppletToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, appletTag));
bool didBlockScript = false;
didBlockScript |= eraseAttributeIfInjected(token, codeAttr);
didBlockScript |= eraseAttributeIfInjected(token, objectAttr);
return didBlockScript;
}
bool XSSFilter::filterIframeToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, iframeTag));
return eraseAttributeIfInjected(token, srcAttr);
}
bool XSSFilter::filterMetaToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, metaTag));
return eraseAttributeIfInjected(token, http_equivAttr);
}
bool XSSFilter::filterBaseToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, baseTag));
return eraseAttributeIfInjected(token, hrefAttr);
}
bool XSSFilter::filterFormToken(HTMLToken& token)
{
ASSERT(m_state == Initial);
ASSERT(token.type() == HTMLToken::StartTag);
ASSERT(hasName(token, formTag));
return eraseAttributeIfInjected(token, actionAttr);
}
bool XSSFilter::eraseDangerousAttributesIfInjected(HTMLToken& token)
{
DEFINE_STATIC_LOCAL(String, safeJavaScriptURL, ("javascript:void(0)"));
bool didBlockScript = false;
for (size_t i = 0; i < token.attributes().size(); ++i) {
const HTMLToken::Attribute& attribute = token.attributes().at(i);
bool isInlineEventHandler = isNameOfInlineEventHandler(attribute.m_name);
bool valueContainsJavaScriptURL = isInlineEventHandler ? false : containsJavaScriptURL(attribute.m_value);
if (!isInlineEventHandler && !valueContainsJavaScriptURL)
continue;
if (!isContainedInRequest(snippetForAttribute(token, attribute)))
continue;
token.eraseValueOfAttribute(i);
if (valueContainsJavaScriptURL)
token.appendToAttributeValue(i, safeJavaScriptURL);
didBlockScript = true;
}
return didBlockScript;
}
bool XSSFilter::eraseAttributeIfInjected(HTMLToken& token, const QualifiedName& attributeName, const String& replacementValue)
{
size_t indexOfAttribute;
if (findAttributeWithName(token, attributeName, indexOfAttribute)) {
const HTMLToken::Attribute& attribute = token.attributes().at(indexOfAttribute);
if (isContainedInRequest(snippetForAttribute(token, attribute))) {
if (attributeName == srcAttr && isSameOriginResource(String(attribute.m_value.data(), attribute.m_value.size())))
return false;
if (attributeName == http_equivAttr && !isDangerousHTTPEquiv(String(attribute.m_value.data(), attribute.m_value.size())))
return false;
token.eraseValueOfAttribute(indexOfAttribute);
if (!replacementValue.isEmpty())
token.appendToAttributeValue(indexOfAttribute, replacementValue);
return true;
}
}
return false;
}
String XSSFilter::snippetForRange(const HTMLToken& token, int start, int end)
{
// FIXME: There's an extra allocation here that we could save by
// passing the range to the parser.
return m_parser->sourceForToken(token).substring(start, end - start);
}
String XSSFilter::snippetForAttribute(const HTMLToken& token, const HTMLToken::Attribute& attribute)
{
// FIXME: We should grab one character before the name also.
int start = attribute.m_nameRange.m_start - token.startIndex();
// FIXME: We probably want to grab only the first few characters of the attribute value.
int end = attribute.m_valueRange.m_end - token.startIndex();
return snippetForRange(token, start, end);
}
bool XSSFilter::isContainedInRequest(const String& snippet)
{
ASSERT(!snippet.isEmpty());
String canonicalizedSnippet = canonicalize(snippet);
ASSERT(!canonicalizedSnippet.isEmpty());
if (m_decodedURL.find(canonicalizedSnippet, 0, false) != notFound)
return true;
if (m_decodedHTTPBodySuffixTree && !m_decodedHTTPBodySuffixTree->mightContain(canonicalizedSnippet))
return false;
return m_decodedHTTPBody.find(canonicalizedSnippet, 0, false) != notFound;
}
bool XSSFilter::isSameOriginResource(const String& url)
{
// If the resource is loaded from the same URL as the enclosing page, it's
// probably not an XSS attack, so we reduce false positives by allowing the
// request. If the resource has a query string, we're more suspicious,
// however, because that's pretty rare and the attacker might be able to
// trick a server-side script into doing something dangerous with the query
// string.
KURL resourceURL(m_parser->document()->url(), url);
return (m_parser->document()->url().host() == resourceURL.host() && resourceURL.query().isEmpty());
}
}