From e419bfe0299aa98562f48b53a2d0046e8a8da625 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 28 Sep 2026 14:12:31 +0200 Subject: [PATCH] Constrain authenticated HTTP origins and verify realization surfaces Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e76f-be98-7ae3-965d-e0b31290a4c4 --- crystallized/test_grant_access.py | 40 +++++- docs/TestDriverGeneralisationReview.md | 27 ++++ research/decisions/README.md | 1 + src/testdriver/browser.py | 13 +- src/testdriver/crystallization.py | 13 +- src/testdriver/http.py | 38 +++++ src/testdriver/runner.py | 3 +- tests/test_http_boundaries.py | 130 ++++++++++++++++++ workplans/TD-WP-0003-generalise-and-settle.md | 16 +++ 9 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 src/testdriver/http.py create mode 100644 tests/test_http_boundaries.py diff --git a/crystallized/test_grant_access.py b/crystallized/test_grant_access.py index 4834fe3..c2be009 100644 --- a/crystallized/test_grant_access.py +++ b/crystallized/test_grant_access.py @@ -39,9 +39,45 @@ TARGET = '/resources/R/grant' FIELDS = {'permission': 'READ', 'subject_id': 'bob'} +class OriginViolation(ValueError): + """An authenticated request attempted to leave its configured origin.""" + + +def _origin(url): + try: + parsed = urllib.parse.urlsplit(url) + if (parsed.scheme not in ('http', 'https') or not parsed.hostname + or parsed.username is not None or parsed.password is not None): + raise ValueError + return (parsed.scheme, parsed.hostname, + parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)) + except ValueError: + raise OriginViolation('invalid authenticated HTTP origin') from None + + +class _OriginRedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, origin): + self.origin = origin + + def redirect_request(self, req, fp, code, msg, headers, newurl): + if _origin(newurl) != self.origin: + raise OriginViolation('authenticated redirect leaves configured origin') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def authenticated_target(base_url, path): + origin = _origin(base_url) + target = urllib.parse.urljoin(base_url, path) + if _origin(target) != origin: + raise OriginViolation('authenticated request leaves configured origin') + # The caller supplies the authorization header only after target validation. + return target, urllib.request.build_opener(_OriginRedirectHandler(origin)) + + def _post(base_url: str, token: str, path: str, fields: dict) -> int: + target, opener = authenticated_target(base_url, path) request = urllib.request.Request( - urllib.parse.urljoin(base_url, path), + target, data=urllib.parse.urlencode(fields).encode(), method="POST", headers={ @@ -50,7 +86,7 @@ def _post(base_url: str, token: str, path: str, fields: dict) -> int: }, ) try: - with urllib.request.urlopen(request, timeout=10) as response: + with opener.open(request, timeout=10) as response: return response.status except urllib.error.HTTPError as error: return error.code diff --git a/docs/TestDriverGeneralisationReview.md b/docs/TestDriverGeneralisationReview.md index 1ce0ba3..8969ffe 100644 --- a/docs/TestDriverGeneralisationReview.md +++ b/docs/TestDriverGeneralisationReview.md @@ -292,3 +292,30 @@ isolation guards before and during runs. Sixteen failed before implementation; the focused suite passed 69 tests afterward. Decision: `2ecac1c5-5254-4ce9-b092-b47c3e1283a9`. Full-suite validation: **363 tests passed** in 153.10 seconds. + +## Authenticated HTTP and surface boundaries (2026-09-28) + +Authenticated HTTP now stays on the configured scheme, host and effective port. +Absolute and protocol-relative targets are validated before adding credentials; +redirects are validated before forwarding a request. Userinfo and unsupported +schemes are rejected. Same-origin relative/absolute URLs and redirects continue +to work. Browser sessions turn origin violations into existing surface findings. +The generator embeds the same stdlib helper definitions in standalone descendants; +the checked-in descendant is updated too. No runtime framework dependency is +introduced into generated tests, and no package dependency is added. + +Runner also validates the realization's reported surface against the original +scheduled action, so a driver that forgets its check cannot certify forbidden +surface substitution. This is a post-call guard: drivers must still check before +acting, and an actual side effect cannot be undone by the runner. It does not +attest a dishonest driver's report. Origin restrictions likewise do not claim +path-level authorization or protect against an already compromised allowed host. + +Tests use synthetic bearer credentials and local HTTP servers to verify that +rejected targets/redirects receive no requests, across sessions, freshly generated +modules and the checked-in descendant. Coverage includes five redirect codes, +protocol-relative targets, scheme/userinfo rejection, normal origin equivalence, +same-origin redirect chains and driver omission blocking acceptance/freezing. +The focused subset passed **107 tests**. Decision: `88490ee8-839d-40dc-affa-b00a1c68e3c5`. + +Full-suite validation: **398 tests passed** in 165.81 seconds. diff --git a/research/decisions/README.md b/research/decisions/README.md index 5bd8e2d..7eac123 100644 --- a/research/decisions/README.md +++ b/research/decisions/README.md @@ -12,3 +12,4 @@ file is a pointer table, not a second source of truth. | TD-WP-0003 T08 admission follow-up | Qualified crystallization, passing baselines and intent revisions | `88e51e10-cd32-44d2-a2d6-055675b5ce04` | `docs/TestDriverGeneralisationReview.md` | | TD-WP-0003 T08 isolation/replay follow-up | Isolation acceptance gate and step-bound frozen replay | `e8fabf6e-3e5e-424e-9a60-152bf3dec641` | `docs/TestDriverGeneralisationReview.md` | | TD-WP-0003 T08 guard-integrity follow-up | Strict boolean judgments and intact isolation guards | `2ecac1c5-5254-4ce9-b092-b47c3e1283a9` | `docs/TestDriverGeneralisationReview.md` | +| TD-WP-0003 T08 HTTP/surface follow-up | Origin-bound credentials and runner surface validation | `88490ee8-839d-40dc-affa-b00a1c68e3c5` | `docs/TestDriverGeneralisationReview.md` | diff --git a/src/testdriver/browser.py b/src/testdriver/browser.py index b1bd233..c5a12f9 100644 --- a/src/testdriver/browser.py +++ b/src/testdriver/browser.py @@ -19,10 +19,11 @@ import urllib.request from dataclasses import dataclass from typing import Any -from .actions import SemanticAction, Surface +from .actions import SemanticAction, Surface, SurfaceNotPermitted from .agentic import ActorRuntime, RealizationFailed from .drivers import Realization, UnsupportedAction from .html import Document +from .http import OriginViolation, authenticated_target from .world import Actor @@ -34,8 +35,12 @@ class Session: token: str def _open(self, method: str, path: str, body: bytes | None, content_type: str): + try: + target, opener = authenticated_target(self.base_url, path) + except OriginViolation as exc: + raise SurfaceNotPermitted(str(exc)) from exc request = urllib.request.Request( - urllib.parse.urljoin(self.base_url, path), + target, data=body, method=method, headers={ @@ -44,8 +49,10 @@ class Session: }, ) try: - with urllib.request.urlopen(request, timeout=10) as response: + with opener.open(request, timeout=10) as response: return response.status, response.read().decode() + except OriginViolation as exc: + raise SurfaceNotPermitted(str(exc)) from exc except urllib.error.HTTPError as error: return error.code, error.read().decode() diff --git a/src/testdriver/crystallization.py b/src/testdriver/crystallization.py index 6750407..16a3e71 100644 --- a/src/testdriver/crystallization.py +++ b/src/testdriver/crystallization.py @@ -19,6 +19,7 @@ patched. from __future__ import annotations import json +import inspect from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, Mapping, Sequence @@ -27,6 +28,7 @@ from .actions import SemanticAction from .classification import classify from .drivers import Realization from .world import Actor +from .http import OriginViolation, _origin, _OriginRedirectHandler, authenticated_target @dataclass(frozen=True, slots=True) @@ -228,9 +230,13 @@ TARGET = {target!r} FIELDS = {fields!r} +{http_helpers} + + def _post(base_url: str, token: str, path: str, fields: dict) -> int: + target, opener = authenticated_target(base_url, path) request = urllib.request.Request( - urllib.parse.urljoin(base_url, path), + target, data=urllib.parse.urlencode(fields).encode(), method="POST", headers={{ @@ -239,7 +245,7 @@ def _post(base_url: str, token: str, path: str, fields: dict) -> int: }}, ) try: - with urllib.request.urlopen(request, timeout=10) as response: + with opener.open(request, timeout=10) as response: return response.status except urllib.error.HTTPError as error: return error.code @@ -294,4 +300,7 @@ def generate_test_module( action_name=trajectory.action_name, test_name=trajectory.action_name, assertions=assertions, + http_helpers="\n\n".join(inspect.getsource(obj) for obj in ( + OriginViolation, _origin, _OriginRedirectHandler, authenticated_target, + )), ) diff --git a/src/testdriver/http.py b/src/testdriver/http.py new file mode 100644 index 0000000..8185402 --- /dev/null +++ b/src/testdriver/http.py @@ -0,0 +1,38 @@ +"""Origin-bound authenticated HTTP, also embedded in standalone regressions.""" +import urllib.parse +import urllib.request + + +class OriginViolation(ValueError): + """An authenticated request attempted to leave its configured origin.""" + + +def _origin(url): + try: + parsed = urllib.parse.urlsplit(url) + if (parsed.scheme not in ('http', 'https') or not parsed.hostname + or parsed.username is not None or parsed.password is not None): + raise ValueError + return (parsed.scheme, parsed.hostname, + parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)) + except ValueError: + raise OriginViolation('invalid authenticated HTTP origin') from None + + +class _OriginRedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, origin): + self.origin = origin + + def redirect_request(self, req, fp, code, msg, headers, newurl): + if _origin(newurl) != self.origin: + raise OriginViolation('authenticated redirect leaves configured origin') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def authenticated_target(base_url, path): + origin = _origin(base_url) + target = urllib.parse.urljoin(base_url, path) + if _origin(target) != origin: + raise OriginViolation('authenticated request leaves configured origin') + # The caller supplies the authorization header only after target validation. + return target, urllib.request.build_opener(_OriginRedirectHandler(origin)) diff --git a/src/testdriver/runner.py b/src/testdriver/runner.py index 3fdb6d1..a258ceb 100644 --- a/src/testdriver/runner.py +++ b/src/testdriver/runner.py @@ -188,6 +188,7 @@ class Runner: # --- S1: how it was done ------------------------------------- try: realization = realize_step(self._driver, actor, step.id, step.action) + step.action.check_surface(realization.surface_id) except SurfaceNotPermitted as exc: # D-05: routing around a control is a finding, not a recovery. self._record( @@ -209,7 +210,7 @@ class Runner: assertion.id, assertion.text, Verdict.INCONCLUSIVE, skipped.id, {"reason": f"run aborted at {step.id!r}; " - "this scheduled step was not executed"}, + "this scheduled step has no permitted realization"}, )) break diff --git a/tests/test_http_boundaries.py b/tests/test_http_boundaries.py new file mode 100644 index 0000000..cfe1c50 --- /dev/null +++ b/tests/test_http_boundaries.py @@ -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, '', '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 diff --git a/workplans/TD-WP-0003-generalise-and-settle.md b/workplans/TD-WP-0003-generalise-and-settle.md index 8c83020..c9bba37 100644 --- a/workplans/TD-WP-0003-generalise-and-settle.md +++ b/workplans/TD-WP-0003-generalise-and-settle.md @@ -366,3 +366,19 @@ checks passed, and the full suite passed **363 tests** in 153.10 seconds. `git diff --check` is clean. Decision: `2ecac1c5-5254-4ce9-b092-b47c3e1283a9`. No new task or workplan was opened; T01/T06/T07 remain waiting and this workplan remains blocked. + + +**2026-09-28 HTTP/surface follow-up — done.** Authenticated requests and +redirects now stay on the configured HTTP(S) origin; userinfo and invalid schemes +are rejected before sending credentials. Browser sessions, generated standalone +tests and the checked-in descendant share the transport policy. Runner also +checks the driver's reported surface against scheduled permissions, recording a +surface violation and aborting if the driver omitted its own check. Pre-action +driver checks remain necessary because post-call validation cannot undo effects. + +Validation: 35 new regression cases, **107 focused tests passed**, and all +**398 tests passed** in 165.81 seconds. Local HTTP receivers and synthetic tokens +verify no cross-origin credential delivery for direct targets and five redirect +codes; same-origin requests/redirects remain functional. `git diff --check` is +clean. Decision: `88490ee8-839d-40dc-affa-b00a1c68e3c5`. No new task, workplan or +dependency. T01/T06/T07 remain waiting and this workplan remains blocked.