atlas-iac/testing/tests/test_hermes_hux_http_security.py

170 lines
6.7 KiB
Python

"""Adversarial HTTP boundary tests for the loopback HUX foundation service."""
from __future__ import annotations
import json
import socket
import sys
import threading
from http.client import HTTPConnection
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import contracts # noqa: E402
from hux import http as hux_http # noqa: E402
from hux.http import DEFAULT_REQUEST_TIMEOUT_SECONDS, RateLimiter, Router, serve # noqa: E402
from hux.server import build_router # noqa: E402
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
HEADERS = {
"X-Hermes-Tenant-Identity": "slot-3",
"X-Hux-Subject": "usr_0123456789abcdef",
"X-Hux-Surface": "chat",
"X-Hux-Relay-Key": "rk",
}
def _router(tmp_path: Path, **extra: str) -> Router:
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", **extra})
def _start(router: Router):
server = serve(router, "127.0.0.1", 0)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
def _raw_request(address: tuple[str, int], request: bytes) -> bytes:
"""Send one HTTP/1.0 request and return its complete close-delimited response."""
with socket.create_connection(address, timeout=1) as client:
client.sendall(request)
chunks = []
while chunk := client.recv(4096):
chunks.append(chunk)
return b"".join(chunks)
def test_declared_body_is_rejected_before_socket_read(tmp_path):
"""An oversized Content-Length receives 413 without waiting for the claimed body."""
server = _start(_router(tmp_path))
try:
conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=1)
conn.putrequest("POST", "/hux/v1/capabilities")
conn.putheader("Content-Length", str(1024 * 1024 + 1))
conn.endheaders()
reply = conn.getresponse()
body = json.loads(reply.read())
assert (reply.status, body["code"]) == (413, "too_large")
assert reply.getheader("Cache-Control") == "no-store"
finally:
server.shutdown()
server.server_close()
def test_incomplete_body_times_out_and_transfer_encoding_is_rejected(tmp_path):
"""Accepted sockets have a deadline, and unsupported framing fails closed."""
server = _start(_router(tmp_path, HUX_REQUEST_TIMEOUT_SECONDS="0.1"))
try:
reply = _raw_request(
server.server_address,
b"POST /hux/v1/capabilities HTTP/1.0\r\nContent-Length: 2\r\n\r\n{",
)
assert b" 400 " in reply and b"request body is incomplete or timed out" in reply
conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=1)
conn.putrequest("POST", "/hux/v1/capabilities")
conn.putheader("Transfer-Encoding", "chunked")
conn.endheaders()
assert conn.getresponse().status == 400
finally:
server.shutdown()
server.server_close()
def test_malformed_duplicate_and_absurd_content_lengths_fail_closed(tmp_path):
"""Ambiguous or computationally large length headers never reach ``int`` or a body allocation."""
server = _start(_router(tmp_path))
try:
malformed = _raw_request(
server.server_address,
b"POST /hux/v1/capabilities HTTP/1.0\r\nContent-Length: nope\r\n\r\n",
)
duplicate = _raw_request(
server.server_address,
b"POST /hux/v1/capabilities HTTP/1.0\r\nContent-Length: 0\r\nContent-Length: 0\r\n\r\n",
)
absurd = _raw_request(
server.server_address,
b"POST /hux/v1/capabilities HTTP/1.0\r\nContent-Length: 99999999999\r\n\r\n",
)
assert b" 400 " in malformed and b" 400 " in duplicate
assert b" 413 " in absurd
finally:
server.shutdown()
server.server_close()
def test_rate_limit_is_per_subject_and_method_class(tmp_path):
"""Read and write buckets are distinct and return a bounded Retry-After."""
router = _router(tmp_path, HUX_READS_PER_MINUTE="1", HUX_WRITES_PER_MINUTE="1")
first = router.dispatch("GET", "/hux/v1/capabilities", HEADERS, b"")
limited = router.dispatch("GET", "/hux/v1/capabilities", HEADERS, b"")
write = router.dispatch("POST", "/hux/v1/capabilities", HEADERS, b"{}")
assert first.status == 200
assert (limited.status, limited.body["code"]) == (429, "rate_limited")
assert 1 <= int(limited.headers["Retry-After"]) <= 60
assert write.status == 405, "method routing precedes the write rate bucket"
live = _start(_router(tmp_path / "live", HUX_READS_PER_MINUTE="1"))
try:
conn = HTTPConnection("127.0.0.1", live.server_address[1], timeout=1)
conn.request("GET", "/hux/v1/capabilities", headers=HEADERS)
conn.getresponse().read()
conn.request("GET", "/hux/v1/capabilities", headers=HEADERS)
reply = conn.getresponse()
reply.read()
assert reply.status == 429 and reply.getheader("Retry-After")
assert reply.getheader("Cache-Control") == "no-store"
finally:
live.shutdown()
live.server_close()
def test_limiter_expires_windows_and_invalid_settings_use_safe_defaults(tmp_path, monkeypatch):
"""Expired subjects are evicted and malformed operator settings cannot disable bounds."""
moments = iter((0.0, 0.0, 61.0))
limiter = RateLimiter(1, 1, clock=lambda: next(moments))
assert limiter.check("a", "GET") is None
assert limiter.check("a", "GET") == 60
assert limiter.check("a", "GET") is None
monkeypatch.setattr(hux_http, "MAX_RATE_BUCKETS", 1)
bounded = RateLimiter(2, 2, clock=lambda: 0.0)
assert bounded.check("a", "GET") is None
assert bounded.check("b", "GET") == 60
router = _router(
tmp_path,
HUX_READS_PER_MINUTE="nan",
HUX_WRITES_PER_MINUTE="0",
HUX_REQUEST_TIMEOUT_SECONDS="forever",
)
assert router.rate_limiter.limits == {"read": 300, "write": 30}
assert router.request_timeout == DEFAULT_REQUEST_TIMEOUT_SECONDS
def test_health_and_json_are_no_store_and_health_refuses_bodies(tmp_path):
"""Sensitive JSON never enters browser caches; health cannot be used for body smuggling."""
server = _start(_router(tmp_path))
try:
conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=1)
conn.request("GET", "/healthz")
reply = conn.getresponse()
reply.read()
assert reply.getheader("Cache-Control") == "no-store"
conn.request("GET", "/healthz", body=b"x", headers={"Content-Length": "1"})
assert conn.getresponse().status == 413
finally:
server.shutdown()
server.server_close()