blob: 765a564165e91b2fd7b3646bfdbf139ba0d12caa [file] [log] [blame]
# Copyright 2017 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
#
#
# This test checks that Chrome does not crash or otherwise wildly misbehave when
# it receives unexpected Chrome-Proxy header values and/or directives. This is
# done by fuzz testing which is configured through a dictionary object at the
# top of this file.
#
# The fuzz testing generates URLs which are requested from the Chrome Proxy test
# server which will modify its response based on the encoded data in the URL.
#
# The fuzz testing is configured in a dictionary-type object of the following
# format:
# {
# # This specifies a header that will be fuzzed. Only one header is fuzzed
# # per test. When a header is being fuzz tested, any entry it has in the
# # STATIC_RESPONSE_HEADERS object will be overwritten.
# "chrome-proxy": {
# # This specifies the directive key values that are possible to use in
# # fuzzing.
# "directive_keys: [
# "page-policy",
# "foo",
# ],
# # This specifies the directive values that are possible to use in
# # fuzzing. Any one of these values may end up mapped to any one of the
# # directive_keys above.
# "directive_values": [
# "empty-image",
# "bar",
# ],
# # The maximum number of directives to use in this header. If this value
# # is greater than the number of directives given above, that will be
# # used instead.
# "max_directives": 10,
#
# # The maximum number of directive values to use per directive. If this
# # value is greater than the number of directive values given above, that
# # will be used instead.
# "max_directive_values": 10,
#
# # This is used to join the directive values when multiple are used.
# "directive_value_joiner": "|",
# },
# }
#
# If the above configuration was given, the following values would be generated
# for the chrome-proxy header:
# <empty-string>
# foo
# foo=bar
# foo=empty-image
# foo=bar|empty-image
# page_policies
# page_policies=bar
# page_policies=empty-image
# page_policies=bar|empty-image
# foo,page_policies
# foo=bar,page_policies=bar
# foo=empty-image,page_policies=empty-image
# foo=bar|empty-image,page_policies=bar|empty-image
#
# Randomly generated values are also supported in the fuzz_header field
# ("chrome-proxy" in the above example), directive_keys, and directive_values.
# Randomly generated values can be specified using these formats:
# {{RAND_STR(N)}}
# Creates a random string of lowercase and digit characters of length N
#
# {{RAND_INT(N)}}
# Creates a random integer in the range [0, 10^N+1) with leading zeros
# Example: "Num cookies: {{RAND_INT(4)}}" yields "Num cookies: 0123"
#
# {{RAND_DBL(P.Q)}}
# Creates a random double in the range [0, 10^P+1) with leading zeros and
# up to Q places after the decimal point
# Example: "My money = ${{RAND_DBL(3,2)}}" yields "My money = $001.53"
#
# {{RAND_BOOL}}
# Creates a random boolean, either 'true' or 'false'
# Example: "I am awesome: {{RAND_BOOL}}" yields "I am awesome: true"
import BaseHTTPServer
import base64
import itertools
import json
import random
import re
import string
from common import TestDriver
from common import IntegrationTest
from decorators import Slow
# This dict configures how the fuzzing will operate. See documentation above for
# more information.
FUZZ_HEADERS = {
"chrome-proxy": {
"directive_keys": [
"{{RAND_STR(10)}}",
"{{RAND_STR(10)}}",
"page-policies",
],
"directive_values": [
"empty-image",
"{{RAND_INT(1)}}",
"{{RAND_INT(4)}}",
"{{RAND_STR(10)}}",
"{{RAND_STR(10)}}",
"{{RAND_STR(10)}}",
],
"max_directives": 3,
"max_directive_values": 3,
"directive_value_joiner": "|",
},
}
TEST_SERVER = "chromeproxy-test.appspot.com"
# These headers will be present in every test server response. If one of these
# entries is also a fuzzed header above, then the fuzzed value will take the
# place of the static one instead.
STATIC_RESPONSE_HEADERS = {
"content-type": ["text/html"],
"via": ["1.1 Chrome-Compression-Proxy"],
"cache-control": ["no-cache, no-store, must-revalidate"],
"pragma": ["no-cache"],
"expires": ["0"],
}
# This string will be used as the response body in every test and will be
# checked for existence on the final loaded page.
STATIC_RESPONSE_BODY = 'ok'
rand_str_re = re.compile(r'{{RAND_STR\((\d+)\)}}')
rand_int_re = re.compile(r'{{RAND_INT\((\d+)\)}}')
rand_dbl_re = re.compile(r'{{RAND_DBL\((\d+)\.(\d+)\)}}')
rand_bool_re = re.compile(r'{{RAND_BOOL}}')
def ParseRand(key, val):
"""This helper function parses the {{RAND}} expressions in the given values
and returns them with random values subsituted in place.
Args:
key: the header key with 0 or more {{RAND}} expressions
val: the header value with 0 or more {{RAND}} expressions
Returns:
A key, value tuple with subsituted random values
"""
def GenerateRand(length, charset):
return ''.join(random.choice(charset) for _ in range(length))
def _parse_rand(v):
result = v
had_match = True
while had_match:
had_match = False
str_match = rand_str_re.search(result)
if str_match:
had_match = True
mag = int(result[str_match.start(1):str_match.end(1)])
rand_str = GenerateRand(mag, string.ascii_lowercase + string.digits)
result = (result[:str_match.start()] + rand_str
+ result[str_match.end():])
int_match = rand_int_re.search(result)
if int_match:
had_match = True
mag = int(result[int_match.start(1):int_match.end(1)])
rand_int = GenerateRand(mag, string.digits)
result = (result[:int_match.start()] + rand_int
+ result[int_match.end():])
dbl_match = rand_dbl_re.search(result)
if dbl_match:
had_match = True
magN = int(result[dbl_match.start(1):dbl_match.end(1)])
magD = int(result[dbl_match.start(2):dbl_match.end(2)])
rand_dbl = GenerateRand(magN, string.digits) + '.' + GenerateRand(magD,
string.digits)
result = (result[:dbl_match.start()] + rand_dbl
+ result[dbl_match.end():])
bool_match = rand_bool_re.search(result)
if bool_match:
had_match = True
rand_bool = bool(random.getrandbits(1))
result = (result[:bool_match.start()] + str(rand_bool).lower()
+ result[bool_match.end():])
return result
return (_parse_rand(key), _parse_rand(val))
def GenerateFuzzedHeaders(cfg=FUZZ_HEADERS):
"""This function yields header key value pairs which can be used to update a
Python dict representing HTTP headers. See file level documentation for more
information.
Args:
cfg: the configuration dict that specifies how to fuzz the proxy headers
Yields:
one header key value pair
"""
for header_key in cfg:
fuzz = cfg[header_key]
dirs = fuzz['directive_keys']
vals = fuzz['directive_values']
max_dirs = min(fuzz['max_directives'], len(dirs))
max_vals = min(fuzz['max_directive_values'], len(vals))
def GenerateFuzzedValues():
for n in range(0, max_vals + 1):
for c in itertools.combinations(vals, n):
yield c
# Yield an empty header key,value pair before doing all the combinations.
yield (header_key, '')
for num_dirs in range(1, max_dirs + 1):
for directive_set in itertools.combinations(dirs, num_dirs):
for values in GenerateFuzzedValues():
value_list = list(values)
if '' in value_list:
value_list.remove('')
value_str = fuzz['directive_value_joiner'].join(value_list)
header = []
for directive in directive_set:
if len(value_str) == 0:
header.append(directive)
else:
header.append('%s=%s' % (directive, value_str))
yield ParseRand(header_key, ','.join(header))
class FuzzUnitTests(IntegrationTest):
def testParseRand(self):
tests = {
"{{RAND_STR(1)}": r"{{RAND_STR\(1\)}",
"{{RAND_INT(1)}": r"{{RAND_INT\(1\)}",
"{{RAND_DBL(1.1)}": r"{{RAND_DBL\(1\.1\)}",
"{{RAND_DBL(11)}}": r"{{RAND_DBL\(11\)}}",
"{{RAND_BOOL}": r"{{RAND_BOOL}",
"{{RAND_STR(0)}}": "",
"hi{{RAND_STR(0)}}": "hi",
"{{RAND_STR(0)}}there": "there",
"hi{{RAND_STR(0)}}there": "hithere",
"{{RAND_STR(3)}}": r"[a-z0-9][a-z0-9][a-z0-9]",
"{{RAND_STR(3)}}there": r"[a-z0-9][a-z0-9][a-z0-9]there",
"hi{{RAND_STR(3)}}": r"hi[a-z0-9][a-z0-9][a-z0-9]",
"hi{{RAND_STR(3)}}there": r"hi[a-z0-9][a-z0-9][a-z0-9]there",
"{{RAND_INT(0)}}": "",
"hi{{RAND_INT(0)}}": "hi",
"{{RAND_INT(0)}}there": "there",
"hi{{RAND_INT(0)}}there": "hithere",
"{{RAND_INT(3)}}": r"[0-9][0-9][0-9]",
"{{RAND_INT(3)}}there": r"[0-9][0-9][0-9]there",
"hi{{RAND_INT(3)}}": r"hi[0-9][0-9][0-9]",
"hi{{RAND_INT(3)}}there": r"hi[0-9][0-9][0-9]there",
"{{RAND_DBL(0.0)}}": r"\.",
"hi{{RAND_DBL(0.0)}}": r"hi\.",
"{{RAND_DBL(0.0)}}there": r"\.there",
"hi{{RAND_DBL(0.0)}}there": r"hi\.there",
"{{RAND_DBL(3.3)}}": r"[0-9][0-9][0-9]\.[0-9][0-9][0-9]",
"hi{{RAND_DBL(3.3)}}": r"hi[0-9][0-9][0-9]\.[0-9][0-9][0-9]",
"{{RAND_DBL(3.3)}}there": r"[0-9][0-9][0-9]\.[0-9][0-9][0-9]there",
"hi{{RAND_DBL(3.3)}}there": r"hi[0-9][0-9][0-9]\.[0-9][0-9][0-9]there",
"{{RAND_BOOL}}": r"(true|false)",
"{{RAND_BOOL}}there": r"(true|false)there",
"hi{{RAND_BOOL}}": r"hi(true|false)",
"hi{{RAND_BOOL}}there": r"hi(true|false)there",
"{{RAND_STR(1)}}{{RAND_STR(1)}}": r"[a-z0-9][a-z0-9]",
"{{RAND_STR(1)}}{{RAND_INT(1)}}": r"[a-z0-9][0-9]",
"{{RAND_STR(1)}}{{RAND_DBL(1.1)}}": r"[a-z0-9][0-9]\.[0-9]",
"{{RAND_STR(1)}}{{RAND_BOOL}}": r"[a-z0-9](true|false)",
"{{RAND_INT(1)}}{{RAND_STR(1)}}": r"[0-9][a-z0-9]",
"{{RAND_INT(1)}}{{RAND_INT(1)}}": r"[0-9][0-9]",
"{{RAND_INT(1)}}{{RAND_DBL(1.1)}}": r"[0-9][0-9]\.[0-9]",
"{{RAND_INT(1)}}{{RAND_BOOL}}": r"[0-9](true|false)",
"{{RAND_DBL(1.1)}}{{RAND_STR(1)}}": r"[0-9]\.[0-9][a-z0-9]",
"{{RAND_DBL(1.1)}}{{RAND_INT(1)}}": r"[0-9]\.[0-9][0-9]",
"{{RAND_DBL(1.1)}}{{RAND_DBL(1.1)}}": r"[0-9]\.[0-9][0-9]\.[0-9]",
"{{RAND_DBL(1.1)}}{{RAND_BOOL}}": r"[0-9]\.[0-9](true|false)",
"{{RAND_BOOL}}{{RAND_STR(1)}}": r"(true|false)[a-z0-9]",
"{{RAND_BOOL}}{{RAND_INT(1)}}": r"(true|false)[0-9]",
"{{RAND_BOOL}}{{RAND_DBL(1.1)}}": r"(true|false)[0-9]\.[0-9]",
"{{RAND_BOOL}}{{RAND_BOOL}}": r"(true|false)(true|false)",
}
for t in tests:
expected = re.compile('^' + tests[t] + '$')
gotK, gotV = ParseRand(t, t)
if not expected.match(gotK):
self.fail("%s doesn't match /%s/" % (gotK, tests[t]))
if not expected.match(gotV):
self.fail("%s doesn't match /%s/" % (gotK, tests[t]))
def testGenerator(self):
test_cfg = {
"chrome-proxy": {
"directive_keys": [
"foo",
"page_policies",
],
"directive_values": [
"bar",
"empty-image",
],
"max_directives": 10,
"max_directive_values": 10,
"directive_value_joiner": "|",
},
}
expected_headers = ['', 'foo', 'foo=bar', 'foo=empty-image',
'foo=bar|empty-image', 'page_policies', 'page_policies=bar',
'page_policies=empty-image', 'page_policies=bar|empty-image',
'foo,page_policies', 'foo=bar,page_policies=bar',
'foo=empty-image,page_policies=empty-image',
'foo=bar|empty-image,page_policies=bar|empty-image',
]
actual_headers = []
for h in GenerateFuzzedHeaders(cfg=test_cfg):
actual_headers.append(h[1])
expected_headers.sort()
actual_headers.sort()
self.assertEqual(expected_headers, actual_headers)
class ProtocolFuzzer(IntegrationTest):
def GenerateTestURLs(self):
"""This function yields test URLs which will cause the test server to
respond with the given given headers and body.
Yields:
URLs suitable for testing fuzzed response headers
"""
for fz_key, fz_val in GenerateFuzzedHeaders():
headers = {}
headers.update(STATIC_RESPONSE_HEADERS)
headers.update({fz_key: [fz_val]})
json_headers = json.dumps(headers)
b64_headers = base64.b64encode(json_headers)
url = "http://%s/default?respBody=%s&respHeader=%s" % (TEST_SERVER,
base64.b64encode(STATIC_RESPONSE_BODY), b64_headers)
yield (json_headers, url)
@Slow
def testFuzzing(self):
with TestDriver() as t:
t.AddChromeArg('--enable-spdy-proxy-auth')
t.AddChromeArg('--data-reduction-proxy-http-proxies='
'https://chromeproxy-test.appspot.com')
for headers, url in self.GenerateTestURLs():
try:
t.LoadURL(url)
# The main test is to make sure Chrome doesn't crash after loading a
# page with fuzzed headers, which would be raised as a ChromeDriver
# exception. Otherwise, we'll do a simple check and make sure the page
# body is correct and Chrome isn't displaying some kind of error page.
body = t.ExecuteJavascriptStatement('document.body.innerHTML')
self.assertEqual(body, STATIC_RESPONSE_BODY)
except Exception as e:
print 'Response headers: ' + headers
print 'URL: ' + url
raise e
if __name__ == '__main__':
IntegrationTest.RunAllTests()