Constrain authenticated HTTP origins and verify realization surfaces
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e76f-be98-7ae3-965d-e0b31290a4c4
This commit is contained in:
parent
0f8559f442
commit
e419bfe029
9 changed files with 273 additions and 8 deletions
130
tests/test_http_boundaries.py
Normal file
130
tests/test_http_boundaries.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Authenticated traffic stays on origin, including standalone descendants."""
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Thread
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from scenarios.alice_bob_carol import build, USE_CASE
|
||||
from testdriver import Runner, Verdict, SurfaceNotPermitted
|
||||
from testdriver.browser import Session
|
||||
from testdriver.classification import classify
|
||||
from testdriver.crystallization import Trajectory, generate_test_module, assess_stability
|
||||
from testdriver.http import _origin
|
||||
|
||||
|
||||
@contextmanager
|
||||
def server():
|
||||
receipts = []
|
||||
routes = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
receipts.append((self.path, self.headers.get('Authorization')))
|
||||
code, target = routes.get(self.path, (200, None))
|
||||
self.send_response(code)
|
||||
if target:
|
||||
self.send_header('Location', target)
|
||||
self.end_headers()
|
||||
|
||||
do_POST = do_GET
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
http = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
thread = Thread(target=lambda: http.serve_forever(poll_interval=0.01), daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f'http://127.0.0.1:{http.server_port}', routes, receipts
|
||||
finally:
|
||||
http.shutdown()
|
||||
http.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
@pytest.fixture(params=['session', 'generated', 'checked_in'])
|
||||
def post(request):
|
||||
if request.param == 'session':
|
||||
return lambda base, path: Session(base, 'synthetic-test-token').post_form(path, {})[0]
|
||||
if request.param == 'checked_in':
|
||||
from crystallized.test_grant_access import _post
|
||||
else:
|
||||
source = generate_test_module(
|
||||
trajectory=Trajectory('s2', 'grant_access', 'browser', '/grant', ()),
|
||||
action_args={}, ancestor_id='a', ancestor_maturity='T1', descendant_id='b',
|
||||
runs=3, sut_version='test', claims=USE_CASE.claims[:1],
|
||||
claims_module='scenarios.alice_bob_carol')
|
||||
namespace = {}
|
||||
exec(compile(source, '<generated>', 'exec'), namespace)
|
||||
_post = namespace['_post']
|
||||
return lambda base, path: _post(base, 'synthetic-test-token', path, {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('network_path', [False, True])
|
||||
def test_external_targets_are_rejected_before_any_credential_is_sent(post, network_path):
|
||||
with server() as (base, _, original), server() as (other, _, receiver):
|
||||
target = other + '/grant'
|
||||
if network_path:
|
||||
target = target.removeprefix('http:')
|
||||
with pytest.raises((SurfaceNotPermitted, ValueError), match="authenticated"):
|
||||
post(base, target)
|
||||
assert receiver == original == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('code', [301, 302, 303, 307, 308])
|
||||
def test_redirects_cannot_forward_credentials_to_another_origin(post, code):
|
||||
with server() as (base, routes, original), server() as (other, _, receiver):
|
||||
routes['/start'] = (code, other + '/capture')
|
||||
with pytest.raises((SurfaceNotPermitted, ValueError), match="authenticated"):
|
||||
post(base, '/start')
|
||||
assert original == [('/start', 'Bearer synthetic-test-token')]
|
||||
assert receiver == []
|
||||
|
||||
|
||||
def test_same_origin_targets_and_redirects_still_work(post):
|
||||
with server() as (base, routes, receipts):
|
||||
routes['/start'] = (302, '/next')
|
||||
routes['/next'] = (302, base + '/finish')
|
||||
assert post(base, base + '/start') == 200
|
||||
assert receipts == [(path, 'Bearer synthetic-test-token')
|
||||
for path in ('/start', '/next', '/finish')]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('target', ['https://example.test/path', 'file:///tmp/nope',
|
||||
'http://user:pass@example.test/path'])
|
||||
def test_scheme_changes_and_userinfo_are_not_allowed(post, target):
|
||||
with pytest.raises((SurfaceNotPermitted, ValueError), match="authenticated"):
|
||||
post('http://example.test', target)
|
||||
|
||||
|
||||
def test_default_ports_and_host_case_have_normal_origin_semantics():
|
||||
assert _origin('https://EXAMPLE.test/path') == _origin('https://example.test:443/')
|
||||
assert _origin('http://example.test') == _origin('http://example.test:80')
|
||||
assert _origin('http://example.test:0') != _origin('http://example.test')
|
||||
|
||||
|
||||
def test_runner_rejects_reported_surface_even_when_driver_omits_its_guard():
|
||||
packs = []
|
||||
for _ in range(3):
|
||||
world, driver, observer, asset, oracle = build()
|
||||
calls = []
|
||||
|
||||
class UncheckedDriver:
|
||||
def realize(self, actor, action):
|
||||
calls.append(action.name)
|
||||
return driver.realize(actor, replace(action, permitted_surfaces=frozenset({'api'})))
|
||||
|
||||
asset.scenario = replace(asset.scenario, steps=tuple(
|
||||
replace(step, action=replace(step.action, permitted_surfaces=frozenset({'browser'})))
|
||||
for step in asset.scenario.steps))
|
||||
result = Runner(world, UncheckedDriver(), observer, oracle).run(asset)
|
||||
assert result.verdict is Verdict.INCONCLUSIVE
|
||||
assert len(calls) == 1
|
||||
assert all(j.verdict is Verdict.INCONCLUSIVE for j in result.judgments)
|
||||
assert any(o.kind == 'surface_violation' for o in result.evidence.observations)
|
||||
packs.append(json.loads(result.evidence.to_json()))
|
||||
assert not classify(packs[0], packs[1]).safe_to_accept
|
||||
assert not assess_stability(packs).stable
|
||||
Loading…
Add table
Add a link
Reference in a new issue