atlas-iac/testing/tests/test_hermes_webui_hux_bff.py

458 lines
20 KiB
Python

"""Pinned patch and live-process security tests for the WebUI HUX BFF."""
from __future__ import annotations
from contextlib import contextmanager
from email.message import Message
import hashlib
import hmac
import http.client
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import importlib.util
import io
from pathlib import Path
import shutil
import sys
import threading
import time
from types import ModuleType, SimpleNamespace
import pytest
ROOT = Path(__file__).parents[2]
PATCHER = ROOT / "dockerfiles/hermes-webui-hux-bff-patch.py"
FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181"
FIXTURE_ROUTES_SHA = "099f961f023342a3284719de024ad7c2e767e7c84f0fbfd6d7240d792ebf0903"
TEST_CONTEXT_KEY = b"k" * 32
TEST_SUBJECT = "usr_" + hmac.new(TEST_CONTEXT_KEY, b"hux.subject.id.v1\0slot-3",
hashlib.sha256).hexdigest()
def load_patcher(name: str = "hermes_hux_bff_patch"):
spec = importlib.util.spec_from_file_location(name, PATCHER)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def fixture_tree(tmp_path: Path) -> Path:
target = tmp_path / "webui"
shutil.copytree(FIXTURE, target)
routes = target / "api/routes.py"
source = routes.read_text()
pinned_post = ' """Pinned POST route anchors for the Atlas voice patch."""'
upstream_post = ' """Handle all POST routes. Returns True if handled, False for 404."""'
assert source.count(pinned_post) == 1
source = source.replace(pinned_post, upstream_post, 1)
for method in ("PUT", "PATCH", "DELETE"):
source += (f"\n\ndef handle_{method.lower()}(handler, parsed) -> bool:\n"
f' """Handle all {method} routes. Returns True if handled, False for 404."""\n'
" return False\n")
routes.write_text(source)
return target
class UpstreamHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
status = 200
response_headers = {"Content-Type": "application/json"}
response_body = b'{"ok":true}'
delay = 0.0
requests: list[dict] = []
def _run(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
type(self).requests.append({"method": self.command, "path": self.path,
"headers": dict(self.headers.items()), "body": body})
if type(self).delay:
time.sleep(type(self).delay)
self.send_response(type(self).status)
headers = dict(type(self).response_headers)
if "Content-Length" not in headers:
headers["Content-Length"] = str(len(type(self).response_body))
for name, value in headers.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(type(self).response_body)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
def log_message(self, *_args):
return
class ReusableServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
@contextmanager
def upstream(**values):
previous = {name: getattr(UpstreamHandler, name) for name in
("status", "response_headers", "response_body", "delay")}
UpstreamHandler.requests = []
for name, value in values.items():
setattr(UpstreamHandler, name, value)
server = ReusableServer(("127.0.0.1", 8790), UpstreamHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield UpstreamHandler
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
for name, value in previous.items():
setattr(UpstreamHandler, name, value)
def auth_modules(monkeypatch, *, info=None, valid_cookie="valid.cookie", valid_csrf="csrf-value"):
package = ModuleType("api")
package.__path__ = []
auth = ModuleType("api.auth")
auth.CSRF_HEADER_NAME = "X-Hermes-CSRF-Token"
auth.parse_cookie = lambda handler: valid_cookie if "session=valid" in handler.headers.get("Cookie", "") else None
auth.verify_session = lambda cookie: cookie == valid_cookie
auth.ensure_trusted_auth_session = lambda _handler: info or {
"auth_type": "trusted", "username": "slot-3"}
auth.verify_csrf_token = lambda cookie, token: cookie == valid_cookie and token == valid_csrf
package.auth = auth
monkeypatch.setitem(sys.modules, "api", package)
monkeypatch.setitem(sys.modules, "api.auth", auth)
def relay_key(module, tmp_path: Path, value="relay_key_0123456789abcdef0123456789abcdef") -> Path:
directory = tmp_path / "webui-only"
directory.mkdir(mode=0o700)
directory.chmod(0o700)
key = directory / "relay-key"
key.write_text(value)
key.chmod(0o400)
module.RELAY_KEY_FILE = key
context_key = directory / "context-key"
context_key.write_bytes(TEST_CONTEXT_KEY)
context_key.chmod(0o600)
module.CONTEXT_KEY_FILE = context_key
return key
@contextmanager
def bff_server(module):
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _run(self):
from urllib.parse import urlsplit
module.proxy_hux(self, urlsplit(self.path), self.command)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
def log_message(self, *_args):
return
server = ReusableServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server.server_address[1]
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def request(port: int, method="GET", path="/hux/v1/capabilities", body=None, headers=None):
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
base = {"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "slot-3",
"X-Hux-Subject": TEST_SUBJECT}
base.update(headers or {})
base = {name: value for name, value in base.items() if value is not None}
connection.request(method, path, body=body, headers=base)
response = connection.getresponse()
payload = response.read()
result = response.status, dict(response.getheaders()), payload
connection.close()
return result
def test_pinned_fixture_patch_is_atomic_and_installs_all_methods(tmp_path: Path):
module = load_patcher("hux_bff_fixture")
assert hashlib.sha256((FIXTURE / "api/routes.py").read_bytes()).hexdigest() == FIXTURE_ROUTES_SHA
assert module.PINNED_UPSTREAM_COMMIT == "7a94e34a6d639576576baa9131acf6765f6d2b98"
target = fixture_tree(tmp_path)
module.apply(target)
routes = (target / "api/routes.py").read_text()
for method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
assert routes.count(f'return proxy_hux(handler, parsed, "{method}")') == 1
installed = target / "api/hux_bff.py"
assert installed.read_text() == PATCHER.read_text()
with pytest.raises(SystemExit, match="already installed"):
module.apply(target)
def test_pinned_drift_fails_before_writing_any_file(tmp_path: Path):
module = load_patcher("hux_bff_drift")
target = fixture_tree(tmp_path)
routes = target / "api/routes.py"
routes.write_text(routes.read_text().replace("Handle all PATCH routes", "Changed PATCH routes"))
before = routes.read_bytes()
with pytest.raises(SystemExit, match="patch context changed"):
module.apply(target)
assert routes.read_bytes() == before
assert not (target / "api/hux_bff.py").exists()
def test_target_body_and_header_validators_fail_closed():
module = load_patcher("hux_bff_validators")
assert module._header_values(SimpleNamespace(), "Missing") == []
assert module._header_values(SimpleNamespace(headers={}), "Missing") == []
assert module._header_values(SimpleNamespace(headers={"Present": 7}), "Present") == ["7"]
assert module._safe_target(SimpleNamespace(path="/hux/v1/memory", query="status=active&limit=20")) == \
"/hux/v1/memory?status=active&limit=20"
assert module._safe_target(SimpleNamespace(path="/hux/v1/search", query="q=kidney%20stone")) == \
"/hux/v1/search?q=kidney+stone"
bad_targets = [SimpleNamespace(path="/api/memory", query=""),
SimpleNamespace(path="/hux/v1/../memory", query=""),
SimpleNamespace(path="/hux/v1/memory", query="x=1&x=2"),
SimpleNamespace(path="/hux/v1/memory", query="=empty"),
SimpleNamespace(path="/hux/v1/memory", query="x=%2Fetc"),
SimpleNamespace(path="/hux/v1/memory", query="not-a-pair"),
SimpleNamespace(path="/hux/v1/memory", query="x=" + "a" * 4097)]
for target in bad_targets:
with pytest.raises(module.BffError) as error:
module._safe_target(target)
assert error.value.status == 400
def fake(headers, body=b""):
return SimpleNamespace(headers=headers, rfile=io.BytesIO(body), path="/hux/v1/memory")
valid = Message()
valid["Content-Length"] = "2"
valid["Content-Type"] = "application/json"
assert module._read_request(fake(valid, b"{}"), "POST") == b"{}"
class ShortReader(io.BytesIO):
def read(self, size=-1):
return super().read(min(size, 1))
assert module._read_request(SimpleNamespace(headers=valid, rfile=ShortReader(b"{}")), "POST") == b"{}"
with pytest.raises(module.BffError, match="Incomplete"):
module._read_request(fake(valid, b"{"), "POST")
empty = Message()
assert module._read_request(fake(empty), "GET") is None
cases = []
for name, value in (("Transfer-Encoding", "chunked"), ("Content-Length", "nope")):
headers = Message()
headers[name] = value
cases.append((headers, "POST", 400))
duplicate = Message()
duplicate["Content-Length"] = "1"
duplicate["Content-Length"] = "1"
cases.append((duplicate, "POST", 400))
get_body = Message()
get_body["Content-Length"] = "1"
get_body["Content-Type"] = "application/json"
cases.append((get_body, "GET", 400))
huge = Message()
huge["Content-Length"] = str(module.MAX_REQUEST_BYTES + 1)
cases.append((huge, "POST", 413))
wrong_type = Message()
wrong_type["Content-Length"] = "1"
wrong_type["Content-Type"] = "text/plain"
cases.append((wrong_type, "POST", 415))
for headers, method, status in cases:
with pytest.raises(module.BffError) as error:
module._read_request(fake(headers, b"x"), method)
assert error.value.status == status
assert module._request_limit("/hux/v1/artifacts", "POST") == module.MAX_ARTIFACT_REQUEST_BYTES
assert module._request_limit("/hux/v1/artifacts/art_1234/versions", "POST") == \
module.MAX_ARTIFACT_REQUEST_BYTES
assert module._request_limit("/hux/v1/memory", "POST") == module.MAX_REQUEST_BYTES
assert module._response_limit("/hux/v1/artifacts/art_1234/versions/1") == \
module.MAX_ARTIFACT_RESPONSE_BYTES
assert module._response_limit("/hux/v1/artifacts/art_1234/versions/2/diff?from=1") == \
module.MAX_ARTIFACT_RESPONSE_BYTES
def test_relay_key_requires_private_owned_regular_file(tmp_path: Path):
module = load_patcher("hux_bff_key")
key = relay_key(module, tmp_path)
assert module._secure_relay_key().startswith("relay_key_")
key.chmod(0o440)
with pytest.raises(module.BffError, match="unavailable"):
module._secure_relay_key()
key.chmod(0o400)
key.parent.chmod(0o750)
with pytest.raises(module.BffError):
module._secure_relay_key()
key.parent.chmod(0o700)
key.chmod(0o600)
key.write_bytes(b"\xff" * 40)
key.chmod(0o400)
with pytest.raises(module.BffError):
module._secure_relay_key()
key.chmod(0o600)
key.write_text("short")
key.chmod(0o400)
with pytest.raises(module.BffError):
module._secure_relay_key()
module.RELAY_KEY_FILE = tmp_path / "missing" / "key"
with pytest.raises(module.BffError):
module._secure_relay_key()
def test_subject_is_server_derived_from_exact_context_key(tmp_path: Path):
module = load_patcher("hux_bff_subject")
relay_key(module, tmp_path)
assert module._derived_subject("slot-3") == TEST_SUBJECT
assert module._derived_subject("slot-4") != TEST_SUBJECT
module.CONTEXT_KEY_FILE.chmod(0o440)
with pytest.raises(module.BffError, match="identity"):
module._derived_subject("slot-3")
module.CONTEXT_KEY_FILE = tmp_path / "missing"
with pytest.raises(module.BffError, match="identity"):
module._derived_subject("slot-3")
def test_live_get_injects_only_server_identity_and_safe_headers(monkeypatch, tmp_path: Path):
module = load_patcher("hux_bff_live_get")
auth_modules(monkeypatch)
relay_key(module, tmp_path)
upstream_headers = {"Content-Type": "application/vnd.hermes.hux+json; version=1",
"ETag": '"3"', "HUX-Replayed": "true", "HUX-Audit-Stale": "false",
"Content-Disposition": "attachment", "Set-Cookie": "upstream=bad"}
with upstream(response_headers=upstream_headers), bff_server(module) as port:
status, headers, body = request(port, path="/hux/v1/memory?limit=2", headers={
"Authorization": "Bearer oauth", "X-Hux-Relay-Key": "browser-forgery",
"X-Hux-Trust": "worker",
"X-Hux-Subject": None, "If-Match": "3", "Idempotency-Key": "browser:key:0001",
"Cookie": "session=valid; oauth=secret"})
assert status == 200 and body == b'{"ok":true}'
assert headers["Cache-Control"] == "no-store"
assert headers["X-Content-Type-Options"] == "nosniff"
assert headers["ETag"] == '"3"' and headers["HUX-Replayed"] == "true"
assert headers["HUX-Audit-Stale"] == "false" and headers["Content-Disposition"] == "attachment"
assert "Set-Cookie" not in headers and "Access-Control-Allow-Origin" not in headers
captured = UpstreamHandler.requests[0]
assert captured["headers"]["X-Hux-Subject"] == TEST_SUBJECT
assert captured["headers"]["X-Hux-Trust"] == "relay"
assert captured["headers"]["X-Hux-Surface"] == "chat"
assert captured["headers"]["X-Hermes-Tenant-Identity"] == "slot-3"
assert captured["headers"]["X-Hux-Relay-Key"].startswith("relay_key_")
lowered = {name.lower() for name in captured["headers"]}
assert not ({"authorization", "cookie", "x-hermes-csrf-token"} & lowered)
@pytest.mark.parametrize("headers,info", [
({"Cookie": "bad", "X-Hermes-Tenant-Identity": "slot-3"}, None),
({"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "slot-3"},
{"auth_type": "local", "username": "slot-3"}),
({"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "slot-4"},
{"auth_type": "trusted", "username": "slot-3"}),
({"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "tenant"},
{"auth_type": "trusted", "username": "tenant"}),
({"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "slot-3",
"X-Hux-Subject": "usr_too_short"},
{"auth_type": "trusted", "username": "slot-3"}),
])
def test_live_get_requires_exact_trusted_session(monkeypatch, tmp_path: Path, headers, info):
module = load_patcher("hux_bff_auth_" + hashlib.sha1(repr(headers).encode()).hexdigest())
auth_modules(monkeypatch, info=info)
relay_key(module, tmp_path)
with upstream(), bff_server(module) as port:
status, response_headers, _body = request(port, headers=headers)
assert status == 401
assert response_headers["Cache-Control"] == "no-store"
assert not UpstreamHandler.requests
def test_live_mutation_requires_csrf_and_preserves_valid_concurrency(monkeypatch, tmp_path: Path):
module = load_patcher("hux_bff_mutation")
auth_modules(monkeypatch)
relay_key(module, tmp_path)
payload = b'{"kind":"fact","identity":{"subject":"browser"}}'
with upstream(), bff_server(module) as port:
status, _, _ = request(port, method="POST", path="/hux/v1/memory", body=payload,
headers={"Content-Type": "application/json"})
assert status == 403 and not UpstreamHandler.requests
status, _, _ = request(port, method="POST", path="/hux/v1/memory", body=payload, headers={
"Content-Type": "application/json", "X-Hermes-CSRF-Token": "csrf-value",
"If-Match": "7", "Idempotency-Key": "memory:key:0001"})
assert status == 200
captured = UpstreamHandler.requests[0]
assert captured["method"] == "POST" and captured["body"] == payload
assert captured["headers"]["If-Match"] == "7"
assert captured["headers"]["Idempotency-Key"] == "memory:key:0001"
def test_live_invalid_concurrency_headers_and_methods_never_reach_hux(monkeypatch, tmp_path: Path):
module = load_patcher("hux_bff_invalid_headers")
auth_modules(monkeypatch)
relay_key(module, tmp_path)
with upstream(), bff_server(module) as port:
for name, value in (("If-Match", '"bad"'), ("Idempotency-Key", "short"),
("Last-Event-ID", "1 OR 1")):
status, _, _ = request(port, headers={name: value})
assert status == 400
status, _, _ = request(port, method="POST", body=b"{}", headers={
"Content-Type": "application/json", "X-Hermes-CSRF-Token": "csrf-value",
"Last-Event-ID": "2"})
assert status == 400
handler = SimpleNamespace(headers=Message(), path="/hux/v1/memory",
rfile=io.BytesIO(), wfile=io.BytesIO())
handler.send_response = lambda status: setattr(handler, "status", status)
handler.send_header = lambda *_args: None
handler.end_headers = lambda: None
assert module.proxy_hux(handler, SimpleNamespace(path="/hux/v1/memory", query=""), "TRACE")
assert handler.status == 405
assert not UpstreamHandler.requests
def test_live_response_bounds_redirects_content_types_and_outage(monkeypatch, tmp_path: Path):
module = load_patcher("hux_bff_response_guards")
auth_modules(monkeypatch)
relay_key(module, tmp_path)
with upstream(status=302, response_headers={"Content-Type": "application/json", "Location": "https://evil.test"}), \
bff_server(module) as port:
status, headers, _ = request(port)
assert status == 502 and "Location" not in headers
with upstream(response_headers={"Content-Type": "text/html"}), bff_server(module) as port:
assert request(port)[0] == 502
module.MAX_RESPONSE_BYTES = 8
with upstream(response_body=b"x" * 9), bff_server(module) as port:
assert request(port)[0] == 502
with bff_server(module) as port:
assert request(port)[0] == 502
def test_live_sse_is_bounded_uncached_and_preserves_numeric_cursor(monkeypatch, tmp_path: Path):
module = load_patcher("hux_bff_sse")
auth_modules(monkeypatch)
relay_key(module, tmp_path)
event = b"id: 2\nevent: run.completed\ndata: {}\n\n"
with upstream(response_headers={"Content-Type": "text/event-stream"}, response_body=event), \
bff_server(module) as port:
status, headers, body = request(port, path="/hux/v1/conversations/conv_alpha/events/stream",
headers={"Last-Event-ID": "1"})
assert status == 200 and body == event
assert headers["Cache-Control"] == "no-store" and headers["Connection"] == "close"
assert UpstreamHandler.requests[0]["headers"]["Last-Event-ID"] == "1"
module.MAX_STREAM_BYTES = 5
with upstream(response_headers={"Content-Type": "text/event-stream"}, response_body=event), \
bff_server(module) as port:
assert request(port, path="/hux/v1/conversations/conv_alpha/events/stream")[2] == b""
def test_patch_is_small_isolated_and_wired_once():
source = PATCHER.read_text()
assert len(source.splitlines()) < 500
assert "127.0.0.1" in source and "UPSTREAM_PORT = 8790" in source
assert "HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT" in source
assert "requests." not in source and "urlopen" not in source
assert "Authorization" not in source and '"Cookie"' not in source
assert "X-Hux-Relay-Key" in source and "0o400" in source and "O_NOFOLLOW" in source
dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text()
assert dockerfile.count(f"COPY dockerfiles/{PATCHER.name} /tmp/{PATCHER.name}") == 1
assert dockerfile.count(f"python /tmp/{PATCHER.name}") == 1