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:
tegwick 2026-09-28 14:12:31 +02:00
parent 0f8559f442
commit e419bfe029
9 changed files with 273 additions and 8 deletions

View file

@ -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

View file

@ -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.

View file

@ -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` |

View file

@ -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()

View file

@ -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,
)),
)

38
src/testdriver/http.py Normal file
View file

@ -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))

View file

@ -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

View 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

View file

@ -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.