170 lines
6.7 KiB
Python
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()
|