diff --git a/request_decision_graph.yml b/request_decision_graph.yml new file mode 100644 index 0000000..6b2fab3 --- /dev/null +++ b/request_decision_graph.yml @@ -0,0 +1,491 @@ +start: + set: + garbage: + - '{random 10-3000 printable}' + - '{random 10-1000 binary}' + garbage_no_whitespace: + - '{random 10-3000 printable_no_whitespace}' + - '{random 10-1000 binary_no_whitespace}' + acl_header: + - 'private' + - 'public-read' + - 'public-read-write' + - 'authenticated-read' + - 'bucket-owner-read' + - 'bucket-owner-full-control' + - '{random 3000 letters}' + - '{random 100-1000 binary_no_whitespace}' + choices: + - bucket + - object + +bucket: + set: + urlpath: '/{bucket}' + choices: + - 13 bucket_get + - 8 bucket_put + - 5 bucket_delete + - bucket_garbage_method + +bucket_garbage_method: + set: + method: + - '{random 1-100 printable}' + - '{random 10-100 binary}' + bucket: + - '{bucket_readable}' + - '{bucket_not_readable}' + - '{bucket_writable}' + - '{bucket_not_writable}' + - '2 {garbage_no_whitespace}' + choices: + - bucket_get_simple + - bucket_get_filtered + - bucket_get_uploads + - bucket_put_create + - bucket_put_versioning + - bucket_put_simple + +bucket_delete: + set: + method: DELETE + bucket: + - '{bucket_writable}' + - '{bucket_not_writable}' + - '2 {garbage_no_whitespace}' + query: + - null + - policy + - website + - '2 {garbage_no_whitespace}' + choices: [] + +bucket_get: + set: + method: GET + bucket: + - '{bucket_readable}' + - '{bucket_not_readable}' + - '2 {garbage_no_whitespace}' + choices: + - 11 bucket_get_simple + - bucket_get_filtered + - bucket_get_uploads + +bucket_get_simple: + set: + query: + - acl + - policy + - location + - logging + - notification + - versions + - requestPayment + - versioning + - website + - '2 {garbage_no_whitespace}' + choices: [] + +bucket_get_uploads: + set: + delimiter: + - null + - '3 delimiter={garbage_no_whitespace}' + prefix: + - null + - '3 prefix={garbage_no_whitespace}' + key_marker: + - null + - 'key-marker={object_readable}' + - 'key-marker={object_not_readable}' + - 'key-marker={invalid_key}' + - 'key-marker={random 100-1000 printable_no_whitespace}' + max_uploads: + - null + - 'max-uploads={random 1-5 binary_no_whitespace}' + - 'max-uploads={random 1-1000 digits}' + upload_id_marker: + - null + - '3 upload-id-marker={random 0-1000 printable_no_whitespace}' + query: + - 'uploads' + - 'uploads&{delimiter}&{prefix}' + - 'uploads&{max_uploads}&{key_marker}&{upload_id_marker}' + - '2 {garbage_no_whitespace}' + choices: [] + +bucket_get_filtered: + set: + delimiter: + - 'delimiter={garbage_no_whitespace}' + prefix: + - 'prefix={garbage_no_whitespace}' + marker: + - 'marker={object_readable}' + - 'marker={object_not_readable}' + - 'marker={invalid_key}' + - 'marker={random 100-1000 printable_no_whitespace}' + max_keys: + - 'max-keys={random 1-5 binary_no_whitespace}' + - 'max-keys={random 1-1000 digits}' + query: + - null + - '{delimiter}&{prefix}' + - '{max-keys}&{marker}' + - '2 {garbage_no_whitespace}' + choices: [] + +bucket_put: + set: + bucket: + - '{bucket_writable}' + - '{bucket_not_writable}' + - '2 {garbage_no_whitespace}' + method: PUT + choices: + - bucket_put_simple + - bucket_put_create + - bucket_put_versioning + +bucket_put_create: + set: + body: + - '2 {garbage}' + - '{random 2-10 binary}' + headers: + - ['0-5', 'x-amz-acl', '{acl_header}'] + choices: [] + +bucket_put_versioning: + set: + body: + - '{garbage}' + - '4 {versioning_status}{mfa_delete_body}' + mfa_delete_body: + - null + - '{random 2-10 binary}' + - '{random 2000-3000 printable}' + versioning_status: + - null + - '{random 2-10 binary}' + - '{random 2000-3000 printable}' + mfa_header: + - '{random 10-1000 printable_no_whitespace} {random 10-1000 printable_no_whitespace}' + headers: + - ['0-1', 'x-amz-mfa', '{mfa_header}'] + choices: [] + +bucket_put_simple: + set: + body: + - '{acl_body}' + - '{policy_body}' + - '{logging_body}' + - '{notification_body}' + - '{request_payment_body}' + - '{website_body}' + acl_body: + - null + - '{owner}{acl}' + owner: + - null + - '7 {id}{display_name}' + id: + - null + - '{random 10-200 binary}' + - '{random 1000-3000 printable}' + display_name: + - null + - '2 {random 10-200 binary}' + - '2 {random 1000-3000 printable}' + - '2 {random 10-300 letters}@{random 10-300 letters}.{random 2-4 letters}' + acl: + - null + - '10 {grantee}{permission}' + grantee: + - null + - '7 {id}{display_name}' + permission: + - null + - '7 {permission_value}' + permission_value: + - '2 {garbage}' + - FULL_CONTROL + - WRITE + - WRITE_ACP + - READ + - READ_ACP + policy_body: + - null + - '2 {garbage}' + logging_body: + - null + - '' + - '{bucket}{target_prefix}{target_grants}' + target_prefix: + - null + - '{random 10-1000 printable}' + - '{random 10-1000 binary}' + target_grants: + - null + - '10 {grantee}{permission}' + notification_body: + - null + - '' + - '2 {topic}{event}' + topic: + - null + - '2 {garbage}' + event: + - null + - 's3:ReducedRedundancyLostObject' + - '2 {garbage}' + request_payment_body: + - null + - '{payer}' + payer: + - Requester + - BucketOwner + - '2 {garbage}' + website_body: + - null + - '{suffix}{error_doc}' + suffix: + - null + - '2 {garbage}' + - '{random 2-10 printable}.html' + error_doc: + - null + - '{suffix}' + choices: [] + +object: + set: + urlpath: '/{bucket}/{object}' + + range_header: + - null + - 'bytes={random 1-2 digits}-{random 1-4 digits}' + - 'bytes={random 1-1000 binary_no_whitespace}' + if_modified_since_header: + - null + - '2 {garbage_no_whitespace}' + if_match_header: + - null + - '2 {garbage_no_whitespace}' + if_none_match_header: + - null + - '2 {garbage_no_whitespace}' + choices: + - object_delete + - object_get + - object_put + - object_head + - object_garbage_method + +object_garbage_method: + set: + method: + - '{random 1-100 printable}' + - '{random 10-100 binary}' + bucket: + - '{bucket_readable}' + - '{bucket_not_readable}' + - '{bucket_writable}' + - '{bucket_not_writable}' + - '2 {garbage_no_whitespace}' + object: + - '{object_readable}' + - '{object_not_readable}' + - '{object_writable}' + - '{object_not_writable}' + - '2 {garbage_no_whitespace}' + choices: + - object_get_query + - object_get_head_simple + +object_delete: + set: + method: DELETE + bucket: + - '5 {bucket_writable}' + - '{bucket_not_writable}' + - '{garbage_no_whitespace}' + object: + - '{object_writable}' + - '{object_not_writable}' + - '2 {garbage_no_whitespace}' + choices: [] + +object_get: + set: + method: GET + bucket: + - '5 {bucket_readable}' + - '{bucket_not_readable}' + - '{garbage_no_whitespace}' + object: + - '{object_readable}' + - '{object_not_readable}' + - '{garbage_no_whitespace}' + choices: + - 5 object_get_head_simple + - 2 object_get_query + +object_get_query: + set: + query: + - 'torrent' + - 'acl' + choices: [] + +object_get_head_simple: + set: {} + headers: + - ['0-1', 'range', '{range_header}'] + - ['0-1', 'if-modified-since', '{if_modified_since_header}'] + - ['0-1', 'if-unmodified-since', '{if_modified_since_header}'] + - ['0-1', 'if-match', '{if_match_header}'] + - ['0-1', 'if-none-match', '{if_none_match_header}'] + choices: [] + +object_head: + set: + method: HEAD + bucket: + - '5 {bucket_readable}' + - '{bucket_not_readable}' + - '{garbage_no_whitespace}' + object: + - '{object_readable}' + - '{object_not_readable}' + - '{garbage_no_whitespace}' + choices: + - object_get_head_simple + +object_put: + set: + method: PUT + bucket: + - '5 {bucket_writable}' + - '{bucket_not_writable}' + - '{garbage_no_whitespace}' + object: + - '{object_writable}' + - '{object_not_writable}' + - '{garbage_no_whitespace}' + cache_control: + - null + - '{garbage_no_whitespace}' + - 'no-cache' + content_disposition: + - null + - '{garbage_no_whitespace}' + content_encoding: + - null + - '{garbage_no_whitespace}' + content_length: + - '{random 1-20 digits}' + - '{garbage_no_whitespace}' + content_md5: + - null + - '{garbage_no_whitespace}' + content_type: + - null + - 'binary/octet-stream' + - '{garbage_no_whitespace}' + expect: + - null + - '100-continue' + - '{garbage_no_whitespace}' + expires: + - null + - '{random 1-10000000 digits}' + - '{garbage_no_whitespace}' + meta_key: + - null + - 'foo' + - '{garbage_no_whitespace}' + meta_value: + - null + - '{garbage_no_whitespace}' + choices: + - object_put_simple + - object_put_acl + - object_put_copy + +object_put_simple: + set: {} + headers: + - ['0-1', 'cache-control', '{cache_control}'] + - ['0-1', 'content-disposition', '{content_disposition}'] + - ['0-1', 'content-encoding', '{content_encoding}'] + - ['0-1', 'content-length', '{content_length}'] + - ['0-1', 'content-md5', '{content_md5}'] + - ['0-1', 'content-type', '{content_type}'] + - ['0-1', 'expect', '{expect}'] + - ['0-1', 'expires', '{expires}'] + - ['0-1', 'x-amz-acl', '{acl_header}'] + - ['0-6', 'x-amz-meta-{meta_key}', '{meta_value}'] + choices: [] + +object_put_acl: + set: + query: 'acl' + body: + - null + - '2 {garbage}' + - '{owner}{acl}' + owner: + - null + - '7 {id}{display_name}' + id: + - null + - '{random 10-200 binary}' + - '{random 1000-3000 printable}' + display_name: + - null + - '2 {random 10-200 binary}' + - '2 {random 1000-3000 printable}' + - '2 {random 10-300 letters}@{random 10-300 letters}.{random 2-4 letters}' + acl: + - null + - '10 {grantee}{permission}' + grantee: + - null + - '7 {id}{display_name}' + permission: + - null + - '7 {permission_value}' + permission_value: + - '2 {garbage}' + - FULL_CONTROL + - WRITE + - WRITE_ACP + - READ + - READ_ACP + headers: + - ['0-1', 'cache-control', '{cache_control}'] + - ['0-1', 'content-disposition', '{content_disposition}'] + - ['0-1', 'content-encoding', '{content_encoding}'] + - ['0-1', 'content-length', '{content_length}'] + - ['0-1', 'content-md5', '{content_md5}'] + - ['0-1', 'content-type', '{content_type}'] + - ['0-1', 'expect', '{expect}'] + - ['0-1', 'expires', '{expires}'] + - ['0-1', 'x-amz-acl', '{acl_header}'] + choices: [] + +object_put_copy: + set: {} + headers: + - ['1-1', 'x-amz-copy-source', '{source_object}'] + - ['0-1', 'x-amz-acl', '{acl_header}'] + - ['0-1', 'x-amz-metadata-directive', '{metadata_directive}'] + - ['0-1', 'x-amz-copy-source-if-match', '{if_match_header}'] + - ['0-1', 'x-amz-copy-source-if-none-match', '{if_none_match_header}'] + - ['0-1', 'x-amz-copy-source-if-modified-since', '{if_modified_since_header}'] + - ['0-1', 'x-amz-copy-source-if-unmodified-since', '{if_modified_since_header}'] + choices: [] diff --git a/s3tests/functional/test_fuzzer.py b/s3tests/functional/test_fuzzer.py new file mode 100644 index 0000000..717b1db --- /dev/null +++ b/s3tests/functional/test_fuzzer.py @@ -0,0 +1,390 @@ +mport sys +import itertools +import nose +import random +import string +import yaml + +from s3tests.fuzz_headers import * + +from nose.tools import eq_ as eq +from nose.tools import assert_true +from nose.plugins.attrib import attr + +from .utils import assert_raises + +_decision_graph = {} + +def check_access_denied(fn, *args, **kwargs): + e = assert_raises(boto.exception.S3ResponseError, fn, *args, **kwargs) + eq(e.status, 403) + eq(e.reason, 'Forbidden') + eq(e.error_code, 'AccessDenied') + + +def build_graph(): + graph = {} + graph['start'] = { + 'set': {}, + 'choices': ['node2'] + } + graph['leaf'] = { + 'set': { + 'key1': 'value1', + 'key2': 'value2' + }, + 'headers': [ + ['1-2', 'random-header-{random 5-10 printable}', '{random 20-30 punctuation}'] + ], + 'choices': [] + } + graph['node1'] = { + 'set': { + 'key3': 'value3', + 'header_val': [ + '3 h1', + '2 h2', + 'h3' + ] + }, + 'headers': [ + ['1-1', 'my-header', '{header_val}'], + ], + 'choices': ['leaf'] + } + graph['node2'] = { + 'set': { + 'randkey': 'value-{random 10-15 printable}', + 'path': '/{bucket_readable}', + 'indirect_key1': '{key1}' + }, + 'choices': ['leaf'] + } + graph['bad_node'] = { + 'set': { + 'key1': 'value1' + }, + 'choices': ['leaf'] + } + graph['nonexistant_child_node'] = { + 'set': {}, + 'choices': ['leafy_greens'] + } + graph['weighted_node'] = { + 'set': { + 'k1': [ + 'foo', + '2 bar', + '1 baz' + ] + }, + 'choices': [ + 'foo', + '2 bar', + '1 baz' + ] + } + graph['null_choice_node'] = { + 'set': {}, + 'choices': [None] + } + graph['repeated_headers_node'] = { + 'set': {}, + 'headers': [ + ['1-2', 'random-header-{random 5-10 printable}', '{random 20-30 punctuation}'] + ], + 'choices': ['leaf'] + } + graph['weighted_null_choice_node'] = { + 'set': {}, + 'choices': ['3 null'] + } + return graph + + +#def test_foo(): + #graph_file = open('request_decision_graph.yml', 'r') + #graph = yaml.safe_load(graph_file) + #eq(graph['bucket_put_simple']['set']['grantee'], 0) + + +def test_load_graph(): + graph_file = open('request_decision_graph.yml', 'r') + graph = yaml.safe_load(graph_file) + graph['start'] + + +def test_descend_leaf_node(): + graph = build_graph() + prng = random.Random(1) + decision = descend_graph(graph, 'leaf', prng) + + eq(decision['key1'], 'value1') + eq(decision['key2'], 'value2') + e = assert_raises(KeyError, lambda x: decision[x], 'key3') + + +def test_descend_node(): + graph = build_graph() + prng = random.Random(1) + decision = descend_graph(graph, 'node1', prng) + + eq(decision['key1'], 'value1') + eq(decision['key2'], 'value2') + eq(decision['key3'], 'value3') + + +def test_descend_bad_node(): + graph = build_graph() + prng = random.Random(1) + assert_raises(DecisionGraphError, descend_graph, graph, 'bad_node', prng) + + +def test_descend_nonexistant_child(): + graph = build_graph() + prng = random.Random(1) + assert_raises(KeyError, descend_graph, graph, 'nonexistant_child_node', prng) + + +def test_expand_random_printable(): + prng = random.Random(1) + got = expand({}, '{random 10-15 printable}', prng) + eq(got, '[/pNI$;92@') + + +def test_expand_random_binary(): + prng = random.Random(1) + got = expand({}, '{random 10-15 binary}', prng) + eq(got, '\xdfj\xf1\xd80>a\xcd\xc4\xbb') + + +def test_expand_random_printable_no_whitespace(): + prng = random.Random(1) + for _ in xrange(1000): + got = expand({}, '{random 500 printable_no_whitespace}', prng) + assert_true(reduce(lambda x, y: x and y, [x not in string.whitespace and x in string.printable for x in got])) + + +def test_expand_random_binary(): + prng = random.Random(1) + for _ in xrange(1000): + got = expand({}, '{random 500 binary_no_whitespace}', prng) + assert_true(reduce(lambda x, y: x and y, [x not in string.whitespace for x in got])) + + +def test_expand_random_no_args(): + prng = random.Random(1) + for _ in xrange(1000): + got = expand({}, '{random}', prng) + assert_true(0 <= len(got) <= 1000) + assert_true(reduce(lambda x, y: x and y, [x in string.printable for x in got])) + + +def test_expand_random_no_charset(): + prng = random.Random(1) + for _ in xrange(1000): + got = expand({}, '{random 10-30}', prng) + assert_true(10 <= len(got) <= 30) + assert_true(reduce(lambda x, y: x and y, [x in string.printable for x in got])) + + +def test_expand_random_exact_length(): + prng = random.Random(1) + for _ in xrange(1000): + got = expand({}, '{random 10 digits}', prng) + assert_true(len(got) == 10) + assert_true(reduce(lambda x, y: x and y, [x in string.digits for x in got])) + + +def test_expand_random_bad_charset(): + prng = random.Random(1) + assert_raises(KeyError, expand, {}, '{random 10-30 foo}', prng) + + +def test_expand_random_missing_length(): + prng = random.Random(1) + assert_raises(ValueError, expand, {}, '{random printable}', prng) + + +def test_assemble_decision(): + graph = build_graph() + prng = random.Random(1) + decision = assemble_decision(graph, prng) + + eq(decision['key1'], 'value1') + eq(decision['key2'], 'value2') + eq(decision['randkey'], 'value-{random 10-15 printable}') + eq(decision['indirect_key1'], '{key1}') + eq(decision['path'], '/{bucket_readable}') + assert_raises(KeyError, lambda x: decision[x], 'key3') + + +def test_expand_escape(): + prng = random.Random(1) + decision = dict( + foo='{{bar}}', + ) + got = expand(decision, '{foo}', prng) + eq(got, '{bar}') + + +def test_expand_indirect(): + prng = random.Random(1) + decision = dict( + foo='{bar}', + bar='quux', + ) + got = expand(decision, '{foo}', prng) + eq(got, 'quux') + + +def test_expand_indirect_double(): + prng = random.Random(1) + decision = dict( + foo='{bar}', + bar='{quux}', + quux='thud', + ) + got = expand(decision, '{foo}', prng) + eq(got, 'thud') + + +def test_expand_recursive(): + prng = random.Random(1) + decision = dict( + foo='{foo}', + ) + e = assert_raises(RecursionError, expand, decision, '{foo}', prng) + eq(str(e), "Runaway recursion in string formatting: 'foo'") + + +def test_expand_recursive_mutual(): + prng = random.Random(1) + decision = dict( + foo='{bar}', + bar='{foo}', + ) + e = assert_raises(RecursionError, expand, decision, '{foo}', prng) + eq(str(e), "Runaway recursion in string formatting: 'foo'") + + +def test_expand_recursive_not_too_eager(): + prng = random.Random(1) + decision = dict( + foo='bar', + ) + got = expand(decision, 100*'{foo}', prng) + eq(got, 100*'bar') + + +def test_make_choice_unweighted_with_space(): + prng = random.Random(1) + choice = make_choice(['foo bar'], prng) + eq(choice, 'foo bar') + +def test_weighted_choices(): + graph = build_graph() + prng = random.Random(1) + + choices_made = {} + for _ in xrange(1000): + choice = make_choice(graph['weighted_node']['choices'], prng) + if choices_made.has_key(choice): + choices_made[choice] += 1 + else: + choices_made[choice] = 1 + + foo_percentage = choices_made['foo'] / 1000.0 + bar_percentage = choices_made['bar'] / 1000.0 + baz_percentage = choices_made['baz'] / 1000.0 + nose.tools.assert_almost_equal(foo_percentage, 0.25, 1) + nose.tools.assert_almost_equal(bar_percentage, 0.50, 1) + nose.tools.assert_almost_equal(baz_percentage, 0.25, 1) + + +def test_null_choices(): + graph = build_graph() + prng = random.Random(1) + choice = make_choice(graph['null_choice_node']['choices'], prng) + + eq(choice, '') + + +def test_weighted_null_choices(): + graph = build_graph() + prng = random.Random(1) + choice = make_choice(graph['weighted_null_choice_node']['choices'], prng) + + eq(choice, '') + + +def test_null_child(): + graph = build_graph() + prng = random.Random(1) + decision = descend_graph(graph, 'null_choice_node', prng) + + eq(decision, {}) + + +def test_weighted_set(): + graph = build_graph() + prng = random.Random(1) + + choices_made = {} + for _ in xrange(1000): + choice = make_choice(graph['weighted_node']['set']['k1'], prng) + if choices_made.has_key(choice): + choices_made[choice] += 1 + else: + choices_made[choice] = 1 + + foo_percentage = choices_made['foo'] / 1000.0 + bar_percentage = choices_made['bar'] / 1000.0 + baz_percentage = choices_made['baz'] / 1000.0 + nose.tools.assert_almost_equal(foo_percentage, 0.25, 1) + nose.tools.assert_almost_equal(bar_percentage, 0.50, 1) + nose.tools.assert_almost_equal(baz_percentage, 0.25, 1) + + +def test_header_presence(): + graph = build_graph() + prng = random.Random(1) + decision = descend_graph(graph, 'node1', prng) + + c1 = itertools.count() + c2 = itertools.count() + for header, value in decision['headers']: + if header == 'my-header': + eq(value, '{header_val}') + assert_true(next(c1) < 1) + elif header == 'random-header-{random 5-10 printable}': + eq(value, '{random 20-30 punctuation}') + assert_true(next(c2) < 2) + else: + raise KeyError('unexpected header found: %s' % header) + + assert_true(next(c1)) + assert_true(next(c2)) + + +def test_duplicate_header(): + graph = build_graph() + prng = random.Random(1) + assert_raises(DecisionGraphError, descend_graph, graph, 'repeated_headers_node', prng) + + +def test_expand_headers(): + graph = build_graph() + prng = random.Random(1) + decision = descend_graph(graph, 'node1', prng) + expanded_headers = expand_headers(decision, prng) + + for header, value in expanded_headers.iteritems(): + if header == 'my-header': + assert_true(value in ['h1', 'h2', 'h3']) + elif header.startswith('random-header-'): + assert_true(20 <= len(value) <= 30) + assert_true(string.strip(value, RepeatExpandingFormatter.charsets['punctuation']) is '') + else: + raise DecisionGraphError('unexpected header found: "%s"' % header) + diff --git a/s3tests/fuzz_headers.py b/s3tests/fuzz_headers.py new file mode 100644 index 0000000..e49713f --- /dev/null +++ b/s3tests/fuzz_headers.py @@ -0,0 +1,376 @@ +from boto.s3.connection import S3Connection +from boto.exception import BotoServerError +from boto.s3.key import Key +from httplib import BadStatusLine +from optparse import OptionParser +from . import common + +import traceback +import itertools +import random +import string +import struct +import yaml +import sys +import re + + +class DecisionGraphError(Exception): + """ Raised when a node in a graph tries to set a header or + key that was previously set by another node + """ + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) + + +class RecursionError(Exception): + """Runaway recursion in string formatting""" + + def __init__(self, msg): + self.msg = msg + + def __str__(self): + return '{0.__doc__}: {0.msg!r}'.format(self) + + +def assemble_decision(decision_graph, prng): + """ Take in a graph describing the possible decision space and a random + number generator and traverse the graph to build a decision + """ + return descend_graph(decision_graph, 'start', prng) + + +def descend_graph(decision_graph, node_name, prng): + """ Given a graph and a particular node in that graph, set the values in + the node's "set" list, pick a choice from the "choice" list, and + recurse. Finally, return dictionary of values + """ + node = decision_graph[node_name] + + try: + choice = make_choice(node['choices'], prng) + if choice == '': + decision = {} + else: + decision = descend_graph(decision_graph, choice, prng) + except IndexError: + decision = {} + + for key, choices in node['set'].iteritems(): + if key in decision: + raise DecisionGraphError("Node %s tried to set '%s', but that key was already set by a lower node!" %(node_name, key)) + decision[key] = make_choice(choices, prng) + + if 'headers' in node: + decision.setdefault('headers', []) + + for desc in node['headers']: + try: + (repetition_range, header, value) = desc + except ValueError: + (header, value) = desc + repetition_range = '1' + + try: + size_min, size_max = repetition_range.split('-', 1) + except ValueError: + size_min = size_max = repetition_range + + size_min = int(size_min) + size_max = int(size_max) + + num_reps = prng.randint(size_min, size_max) + if header in [h for h, v in decision['headers']]: + raise DecisionGraphError("Node %s tried to add header '%s', but that header already exists!" %(node_name, header)) + for _ in xrange(num_reps): + decision['headers'].append([header, value]) + + return decision + + +def make_choice(choices, prng): + """ Given a list of (possibly weighted) options or just a single option!, + choose one of the options taking weights into account and return the + choice + """ + if isinstance(choices, str): + return choices + weighted_choices = [] + for option in choices: + if option is None: + weighted_choices.append('') + continue + try: + (weight, value) = option.split(None, 1) + weight = int(weight) + except ValueError: + weight = 1 + value = option + + if value == 'null' or value == 'None': + value = '' + + for _ in xrange(weight): + weighted_choices.append(value) + + return prng.choice(weighted_choices) + + +def expand_headers(decision, prng): + expanded_headers = {} + for header in decision['headers']: + h = expand(decision, header[0], prng) + v = expand(decision, header[1], prng) + expanded_headers[h] = v + return expanded_headers + + +def expand(decision, value, prng): + c = itertools.count() + fmt = RepeatExpandingFormatter(prng) + new = fmt.vformat(value, [], decision) + return new + + +class RepeatExpandingFormatter(string.Formatter): + charsets = { + 'printable_no_whitespace': string.printable.translate(None, string.whitespace), + 'printable': string.printable, + 'punctuation': string.punctuation, + 'whitespace': string.whitespace, + 'digits': string.digits + } + + def __init__(self, prng, _recursion=0): + super(RepeatExpandingFormatter, self).__init__() + # this class assumes it is always instantiated once per + # formatting; use that to detect runaway recursion + self.prng = prng + self._recursion = _recursion + + def get_value(self, key, args, kwargs): + fields = key.split(None, 1) + fn = getattr(self, 'special_{name}'.format(name=fields[0]), None) + if fn is not None: + if len(fields) == 1: + fields.append('') + return fn(fields[1]) + + val = super(RepeatExpandingFormatter, self).get_value(key, args, kwargs) + if self._recursion > 5: + raise RecursionError(key) + fmt = self.__class__(self.prng, _recursion=self._recursion+1) + + n = fmt.vformat(val, args, kwargs) + return n + + def special_random(self, args): + arg_list = args.split() + try: + size_min, size_max = arg_list[0].split('-', 1) + except ValueError: + size_min = size_max = arg_list[0] + except IndexError: + size_min = '0' + size_max = '1000' + + size_min = int(size_min) + size_max = int(size_max) + length = self.prng.randint(size_min, size_max) + + try: + charset_arg = arg_list[1] + except IndexError: + charset_arg = 'printable' + + if charset_arg == 'binary' or charset_arg == 'binary_no_whitespace': + num_bytes = length + 8 + tmplist = [self.prng.getrandbits(64) for _ in xrange(num_bytes / 8)] + tmpstring = struct.pack((num_bytes / 8) * 'Q', *tmplist) + if charset_arg == 'binary_no_whitespace': + tmpstring = ''.join(c for c in tmpstring if c not in string.whitespace) + return tmpstring[0:length] + else: + charset = self.charsets[charset_arg] + return ''.join([self.prng.choice(charset) for _ in xrange(length)]) # Won't scale nicely + + +def parse_options(): + parser = OptionParser() + parser.add_option('-O', '--outfile', help='write output to FILE. Defaults to STDOUT', metavar='FILE') + parser.add_option('--seed', dest='seed', type='int', help='initial seed for the random number generator') + parser.add_option('--seed-file', dest='seedfile', help='read seeds for specific requests from FILE', metavar='FILE') + parser.add_option('-n', dest='num_requests', type='int', help='issue NUM requests before stopping', metavar='NUM') + parser.add_option('-v', '--verbose', dest='verbose', action="store_true", help='turn on verbose output') + parser.add_option('-d', '--debug', dest='debug', action="store_true", help='turn on debugging (very verbose) output') + parser.add_option('--decision-graph', dest='graph_filename', help='file in which to find the request decision graph') + parser.add_option('--no-cleanup', dest='cleanup', action="store_false", help='turn off teardown so you can peruse the state of buckets after testing') + + parser.set_defaults(num_requests=5) + parser.set_defaults(cleanup=True) + parser.set_defaults(graph_filename='request_decision_graph.yml') + return parser.parse_args() + + +def randomlist(seed=None): + """ Returns an infinite generator of random numbers + """ + rng = random.Random(seed) + while True: + yield rng.randint(0,100000) #100,000 seeds is enough, right? + + +def populate_buckets(conn, alt): + """ Creates buckets and keys for fuzz testing and sets appropriate + permissions. Returns a dictionary of the bucket and key names. + """ + breadable = common.get_new_bucket(alt) + bwritable = common.get_new_bucket(alt) + bnonreadable = common.get_new_bucket(alt) + + oreadable = Key(breadable) + owritable = Key(bwritable) + ononreadable = Key(breadable) + oreadable.set_contents_from_string('oreadable body') + owritable.set_contents_from_string('owritable body') + ononreadable.set_contents_from_string('ononreadable body') + + breadable.set_acl('public-read') + bwritable.set_acl('public-read-write') + bnonreadable.set_acl('private') + oreadable.set_acl('public-read') + owritable.set_acl('public-read-write') + ononreadable.set_acl('private') + + return dict( + bucket_readable=breadable.name, + bucket_writable=bwritable.name, + bucket_not_readable=bnonreadable.name, + bucket_not_writable=breadable.name, + object_readable=oreadable.key, + object_writable=owritable.key, + object_not_readable=ononreadable.key, + object_not_writable=oreadable.key, + ) + + +def _main(): + """ The main script + """ + (options, args) = parse_options() + random.seed(options.seed if options.seed else None) + s3_connection = common.s3.main + alt_connection = common.s3.alt + + if options.outfile: + OUT = open(options.outfile, 'w') + else: + OUT = sys.stderr + + VERBOSE = DEBUG = open('/dev/null', 'w') + if options.verbose: + VERBOSE = OUT + if options.debug: + DEBUG = OUT + VERBOSE = OUT + + request_seeds = None + if options.seedfile: + FH = open(options.seedfile, 'r') + request_seeds = [int(line) for line in FH if line != '\n'] + print>>OUT, 'Seedfile: %s' %options.seedfile + print>>OUT, 'Number of requests: %d' %len(request_seeds) + else: + if options.seed: + print>>OUT, 'Initial Seed: %d' %options.seed + print>>OUT, 'Number of requests: %d' %options.num_requests + random_list = randomlist(options.seed) + request_seeds = itertools.islice(random_list, options.num_requests) + + print>>OUT, 'Decision Graph: %s' %options.graph_filename + + graph_file = open(options.graph_filename, 'r') + decision_graph = yaml.safe_load(graph_file) + + constants = populate_buckets(s3_connection, alt_connection) + print>>VERBOSE, "Test Buckets/Objects:" + for key, value in constants.iteritems(): + print>>VERBOSE, "\t%s: %s" %(key, value) + + print>>OUT, "Begin Fuzzing..." + print>>VERBOSE, '='*80 + for request_seed in request_seeds: + print>>VERBOSE, 'Seed is: %r' %request_seed + prng = random.Random(request_seed) + decision = assemble_decision(decision_graph, prng) + decision.update(constants) + + method = expand(decision, decision['method'], prng) + path = expand(decision, decision['urlpath'], prng) + + try: + body = expand(decision, decision['body'], prng) + except KeyError: + body = '' + + try: + headers = expand_headers(decision, prng) + except KeyError: + headers = {} + + print>>VERBOSE, "%r %r" %(method[:100], path[:100]) + for h, v in headers.iteritems(): + print>>VERBOSE, "%r: %r" %(h[:50], v[:50]) + print>>VERBOSE, "%r\n" % body[:100] + + print>>DEBUG, 'FULL REQUEST' + print>>DEBUG, 'Method: %r' %method + print>>DEBUG, 'Path: %r' %path + print>>DEBUG, 'Headers:' + for h, v in headers.iteritems(): + print>>DEBUG, "\t%r: %r" %(h, v) + print>>DEBUG, 'Body: %r\n' %body + + failed = False # Let's be optimistic, shall we? + try: + response = s3_connection.make_request(method, path, data=body, headers=headers, override_num_retries=1) + body = response.read() + except BotoServerError, e: + response = e + body = e.body + failed = True + except BadStatusLine, e: + print>>OUT, 'FAILED: failed to parse response (BadStatusLine); probably a NUL byte in your request?' + print>>VERBOSE, '='*80 + continue + + if failed: + print>>OUT, 'FAILED:' + OLD_VERBOSE = VERBOSE + OLD_DEBUG = DEBUG + VERBOSE = DEBUG = OUT + print>>VERBOSE, 'Seed was: %r' %request_seed + print>>VERBOSE, 'Response status code: %d %s' %(response.status, response.reason) + print>>DEBUG, 'Body:\n%s' %body + print>>VERBOSE, '='*80 + if failed: + VERBOSE = OLD_VERBOSE + DEBUG = OLD_DEBUG + + print>>OUT, '...done fuzzing' + + if options.cleanup: + common.teardown() + + +def main(): + common.setup() + try: + _main() + except Exception as e: + traceback.print_exc() + common.teardown() + diff --git a/setup.py b/setup.py index 1fc636c..edcab1a 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ setup( 's3tests-generate-objects = s3tests.generate_objects:main', 's3tests-test-readwrite = s3tests.readwrite:main', 's3tests-test-roundtrip = s3tests.roundtrip:main', + 's3tests-fuzz-headers = s3tests.fuzz_headers:main', ], },