diff --git a/testing/vulnerable_app/models/widget.py b/testing/vulnerable_app/models/widget.py new file mode 100644 index 0000000..b018c51 --- /dev/null +++ b/testing/vulnerable_app/models/widget.py @@ -0,0 +1,8 @@ +from ..core.database import Base + + +class Widget(Base): + """ + We really don't need anything else in this, except an ID. + """ + pass diff --git a/testing/vulnerable_app/views/models/database.py b/testing/vulnerable_app/views/models/database.py index a590756..cabcfb9 100644 --- a/testing/vulnerable_app/views/models/database.py +++ b/testing/vulnerable_app/views/models/database.py @@ -13,3 +13,10 @@ 'user_id': fields.Integer(required=True), }, ) + +widget_model = api.model( + 'Widget', + { + 'id': fields.Integer(required=True), + }, +) diff --git a/testing/vulnerable_app/views/sequence.py b/testing/vulnerable_app/views/sequence.py index 2ad371c..912b74a 100644 --- a/testing/vulnerable_app/views/sequence.py +++ b/testing/vulnerable_app/views/sequence.py @@ -3,13 +3,19 @@ The `alpha` series of endpoints enforce basic stateful-ness. """ +from flask import abort from flask_restplus import reqparse from flask_restplus import Resource +from sqlalchemy.orm.exc import NoResultFound +from ..core import database +from ..core.auth import requires_user from ..core.extensions import api +from ..models.widget import Widget from ..parsers.basic import number_query_parser from ..util import get_name from .models.basic import string_model +from .models.database import widget_model ns = api.namespace( @@ -53,3 +59,51 @@ class BravoTwo(Resource): @api.response(200, 'Success', model=int) def get(self): return number_query_parser.parse_args()['id'] + + +@ns.route('/side-effect/create') +class CreateWithSideEffect(Resource): + @api.doc(security='apikey') + @api.response(200, 'Success', model=widget_model) + @requires_user + def post(self, user): + user.has_created_resource = True + user.save() + + with database.connection() as session: + entry = Widget() + + session.add(entry) + session.commit() + + widget_id = entry.id + + return { + 'id': widget_id, + } + + +@ns.route('/side-effect/get/') +class GetWithSideEffect(Resource): + @api.doc(security='apikey') + @api.response(200, 'Success', model=widget_model) + @api.response(404, 'Not Found') + @requires_user + def get(self, id, user): + print(user.to_dict()) + if not user.has_created_resource: + abort(401) + + with database.connection() as session: + try: + entry = session.query(Widget).filter( + Widget.id == id, + ).one() + except NoResultFound: + abort(404) + + widget_id = entry.id + + return { + 'id': widget_id, + } diff --git a/tests/integration/plugins/conftest.py b/tests/integration/plugins/conftest.py new file mode 100644 index 0000000..b9bae59 --- /dev/null +++ b/tests/integration/plugins/conftest.py @@ -0,0 +1,33 @@ +import pytest + +import fuzz_lightyear + + +@pytest.fixture +def mock_api_client(mock_client): + """ + Override victim and attacker account, with proper API keys. + """ + victim_key = mock_client.user.post_create_user().result() + attacker_key = mock_client.user.post_create_user().result() + + fuzz_lightyear.victim_account( + lambda: { + '_request_options': { + 'headers': { + 'X-API-KEY': victim_key, + }, + }, + }, + ) + fuzz_lightyear.attacker_account( + lambda: { + '_request_options': { + 'headers': { + 'X-API-KEY': attacker_key, + }, + }, + }, + ) + + yield mock_client diff --git a/tests/integration/plugins/idor_test.py b/tests/integration/plugins/idor_test.py new file mode 100644 index 0000000..a7ef9a1 --- /dev/null +++ b/tests/integration/plugins/idor_test.py @@ -0,0 +1,64 @@ +import pytest + +from fuzz_lightyear.request import FuzzingRequest +from fuzz_lightyear.response import ResponseSequence +from fuzz_lightyear.runner import run_sequence + + +def test_basic(mock_client): + responses = run_sequence( + [ + FuzzingRequest( + tag='basic', + operation_id='get_private_listing', + id=1, + ), + ], + ResponseSequence(), + ) + + assert responses.data['session'] == 'victim_session' + assert responses.test_results['IDORPlugin'] + + +def test_skipped_due_to_no_inputs(mock_client): + responses = run_sequence( + [ + FuzzingRequest( + tag='basic', + operation_id='get_no_inputs_required', + ), + ], + ResponseSequence(), + ) + + assert responses.data['session'] == 'victim_session' + assert responses.test_results == {} + + +@pytest.mark.xfail( + reason='https://github.com/Yelp/fuzz-lightyear/issues/11', +) +def test_side_effect(mock_api_client): + responses = run_sequence( + [ + FuzzingRequest( + tag='sequence', + operation_id='post_create_with_side_effect', + ), + FuzzingRequest( + tag='user', + operation_id='get_get_user', + ), + + # This goes last, to test for IDOR. + FuzzingRequest( + tag='sequence', + operation_id='get_get_with_side_effect', + ), + ], + ResponseSequence(), + ) + + assert responses.responses[1].has_created_resource + assert responses.test_results['IDORPlugin'] diff --git a/tests/integration/runner_test.py b/tests/integration/runner_test.py index 77be3f8..9bdb20d 100644 --- a/tests/integration/runner_test.py +++ b/tests/integration/runner_test.py @@ -33,21 +33,6 @@ def test_invalid_request(mock_client): ) -def test_valid_request_skip_idor_no_inputs(mock_client): - responses = run_sequence( - [ - FuzzingRequest( - tag='basic', - operation_id='get_no_inputs_required', - ), - ], - ResponseSequence(), - ) - - assert responses.data['session'] == 'victim_session' - assert responses.test_results == {} - - @pytest.mark.parametrize( 'non_vulnerable_operations', [ @@ -74,22 +59,6 @@ def test_valid_request_skip_idor_manually_excluded( assert responses.test_results == {} -def test_valid_request_with_idor(mock_client): - responses = run_sequence( - [ - FuzzingRequest( - tag='basic', - operation_id='get_private_listing', - id=1, - ), - ], - ResponseSequence(), - ) - - assert responses.data['session'] == 'victim_session' - assert responses.test_results['IDORPlugin'] - - class TestStatefulSequence: def test_basic(self, mock_client):