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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
38
src/testdriver/http.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue