mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-10 00:14:30 +00:00
1460 lines
54 KiB
Python
1460 lines
54 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for credential-proxy.py.
|
|
|
|
Starts a real credential proxy and a mock upstream HTTP server,
|
|
then verifies header injection, stripping, streaming, error handling, etc.
|
|
"""
|
|
|
|
import base64
|
|
import http.client
|
|
import http.server
|
|
import json
|
|
import os
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import unittest
|
|
|
|
|
|
def _proxy_auth_header(secret):
|
|
"""Build a Proxy-Authorization header value from a shared secret."""
|
|
return "Basic " + base64.b64encode(f"_:{secret}".encode()).decode()
|
|
|
|
|
|
def _stop_proxy(proc):
|
|
"""Stop a credential proxy subprocess cleanly."""
|
|
proc.send_signal(signal.SIGTERM)
|
|
try:
|
|
proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=2)
|
|
|
|
|
|
def _start_mock_upstream(handler_class):
|
|
"""Start a mock HTTP server, return (server, port)."""
|
|
server = http.server.HTTPServer(("127.0.0.1", 0), handler_class)
|
|
server.daemon_threads = True
|
|
t = threading.Thread(target=server.serve_forever, daemon=True)
|
|
t.start()
|
|
return server, server.server_address[1]
|
|
|
|
|
|
def _start_credential_proxy(rules, env_extra=None):
|
|
"""Start credential-proxy.py as a subprocess, return (proc, port)."""
|
|
env = os.environ.copy()
|
|
env["CREDENTIAL_PROXY_RULES"] = json.dumps(rules)
|
|
env.pop("CREDENTIAL_PROXY_DEBUG", None)
|
|
if env_extra:
|
|
env.update(env_extra)
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "credential-proxy.py"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
line = proc.stdout.readline().decode().strip()
|
|
if not line.isdigit():
|
|
proc.kill()
|
|
stderr = proc.stderr.read().decode()
|
|
raise RuntimeError(f"Proxy failed to start: {line!r} stderr={stderr!r}")
|
|
return proc, int(line)
|
|
|
|
|
|
def _proxy_request(port, method, path, headers=None, body=None):
|
|
"""Send a request to the credential proxy, return (status, resp_headers, body)."""
|
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
|
hdrs = headers or {}
|
|
conn.request(method, path, body=body, headers=hdrs)
|
|
resp = conn.getresponse()
|
|
data = resp.read()
|
|
resp_headers = dict(resp.getheaders())
|
|
conn.close()
|
|
return resp.status, resp_headers, data
|
|
|
|
|
|
# ── Mock upstream that echoes request info ─────────────────────────────────
|
|
|
|
|
|
class EchoHandler(http.server.BaseHTTPRequestHandler):
|
|
"""Mock upstream: returns JSON with received method, path, headers, body."""
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def _handle(self):
|
|
content_length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(content_length) if content_length else b""
|
|
resp = json.dumps({
|
|
"method": self.command,
|
|
"path": self.path,
|
|
"headers": dict(self.headers.items()),
|
|
"body": body.decode("utf-8", errors="replace"),
|
|
}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(resp)))
|
|
self.end_headers()
|
|
self.wfile.write(resp)
|
|
|
|
do_GET = _handle
|
|
do_POST = _handle
|
|
do_PUT = _handle
|
|
do_PATCH = _handle
|
|
do_DELETE = _handle
|
|
|
|
|
|
class ChunkedHandler(http.server.BaseHTTPRequestHandler):
|
|
"""Mock upstream: returns a chunked response (3 chunks)."""
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
self.send_response(200)
|
|
self.send_header("Transfer-Encoding", "chunked")
|
|
self.send_header("Content-Type", "text/plain")
|
|
self.end_headers()
|
|
for i in range(3):
|
|
chunk = f"chunk-{i}\n".encode()
|
|
self.wfile.write(f"{len(chunk):x}\r\n".encode())
|
|
self.wfile.write(chunk)
|
|
self.wfile.write(b"\r\n")
|
|
self.wfile.write(b"0\r\n\r\n")
|
|
self.wfile.flush()
|
|
|
|
|
|
class ErrorHandler(http.server.BaseHTTPRequestHandler):
|
|
"""Mock upstream: returns 500."""
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
self.send_response(500)
|
|
body = b'{"error": "server error"}'
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
|
|
class WebSocketUpgradeHandler(http.server.BaseHTTPRequestHandler):
|
|
"""Mock upstream that accepts a websocket upgrade and echoes one frame payload."""
|
|
|
|
protocol_version = "HTTP/1.1"
|
|
received_headers = None
|
|
received_path = None
|
|
echoed_payload = None
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
type(self).received_headers = dict(self.headers.items())
|
|
type(self).received_path = self.path
|
|
self.send_response(101, "Switching Protocols")
|
|
self.send_header("Upgrade", "websocket")
|
|
self.send_header("Connection", "Upgrade")
|
|
self.send_header("Sec-WebSocket-Accept", "test-accept")
|
|
self.end_headers()
|
|
self.wfile.flush()
|
|
frame = self.connection.recv(1024)
|
|
payload_len = frame[1] & 0x7F
|
|
mask = frame[2:6]
|
|
payload = frame[6:6 + payload_len]
|
|
unmasked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
type(self).echoed_payload = unmasked
|
|
response = bytes([0x81, len(unmasked)]) + unmasked
|
|
self.connection.sendall(response)
|
|
|
|
|
|
# ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCredentialProxy(unittest.TestCase):
|
|
"""Tests that use a mock upstream (HTTP, not HTTPS) to verify proxy behavior."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
# Start mock upstream
|
|
cls.upstream_server, cls.upstream_port = _start_mock_upstream(EchoHandler)
|
|
|
|
# Start credential proxy with rules pointing to our mock upstream
|
|
# We use X-Original-Scheme: http so the proxy connects via HTTP
|
|
cls.rules = [
|
|
{
|
|
"domain": f"127.0.0.1",
|
|
"headers": {
|
|
"Authorization": "Bearer injected-secret-token",
|
|
"X-Custom-Header": "custom-value",
|
|
},
|
|
},
|
|
]
|
|
cls.proxy_proc, cls.proxy_port = _start_credential_proxy(cls.rules)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
_stop_proxy(cls.proxy_proc)
|
|
cls.upstream_server.shutdown()
|
|
|
|
def _request(self, method="GET", path="/test", extra_headers=None, body=None):
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
}
|
|
if extra_headers:
|
|
headers.update(extra_headers)
|
|
return _proxy_request(self.proxy_port, method, path, headers, body)
|
|
|
|
def test_header_injection(self):
|
|
"""Proxy injects configured auth headers."""
|
|
status, _, data = self._request()
|
|
self.assertEqual(status, 200)
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["headers"]["Authorization"], "Bearer injected-secret-token")
|
|
self.assertEqual(echo["headers"]["X-Custom-Header"], "custom-value")
|
|
|
|
def test_host_header_set(self):
|
|
"""Proxy sets Host header to the original host."""
|
|
status, _, data = self._request()
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["headers"]["Host"], "127.0.0.1")
|
|
|
|
def test_accept_encoding_stripped(self):
|
|
"""Proxy strips Accept-Encoding from upstream request."""
|
|
status, _, data = self._request(extra_headers={"Accept-Encoding": "gzip, deflate"})
|
|
echo = json.loads(data)
|
|
# Python's http.client adds "identity" automatically, but "gzip" must be gone
|
|
ae = echo["headers"].get("Accept-Encoding", "")
|
|
self.assertNotIn("gzip", ae)
|
|
self.assertNotIn("deflate", ae)
|
|
|
|
def test_proxy_metadata_stripped(self):
|
|
"""X-Original-* headers are NOT forwarded to upstream."""
|
|
status, _, data = self._request()
|
|
echo = json.loads(data)
|
|
for key in echo["headers"]:
|
|
self.assertFalse(key.startswith("X-Original-"),
|
|
f"Proxy metadata header {key} leaked upstream")
|
|
|
|
def test_existing_auth_overwritten(self):
|
|
"""Proxy overwrites existing Authorization header from client."""
|
|
status, _, data = self._request(
|
|
extra_headers={"Authorization": "Bearer old-token"}
|
|
)
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["headers"]["Authorization"], "Bearer injected-secret-token")
|
|
|
|
def test_post_with_body(self):
|
|
"""POST with body is forwarded correctly."""
|
|
body = json.dumps({"model": "test", "stream": False}).encode()
|
|
status, _, data = self._request(
|
|
method="POST", path="/v1/messages",
|
|
extra_headers={"Content-Type": "application/json"},
|
|
body=body,
|
|
)
|
|
self.assertEqual(status, 200)
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["method"], "POST")
|
|
self.assertEqual(echo["path"], "/v1/messages")
|
|
self.assertEqual(json.loads(echo["body"])["model"], "test")
|
|
|
|
def test_put_method(self):
|
|
"""PUT method works."""
|
|
status, _, data = self._request(method="PUT", body=b"data")
|
|
self.assertEqual(status, 200)
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["method"], "PUT")
|
|
|
|
def test_delete_method(self):
|
|
"""DELETE method works."""
|
|
status, _, data = self._request(method="DELETE")
|
|
self.assertEqual(status, 200)
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["method"], "DELETE")
|
|
|
|
def test_patch_method(self):
|
|
"""PATCH method works."""
|
|
status, _, data = self._request(method="PATCH", body=b"patch-data")
|
|
self.assertEqual(status, 200)
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["method"], "PATCH")
|
|
|
|
def test_path_preserved(self):
|
|
"""Request path is forwarded unchanged."""
|
|
status, _, data = self._request(path="/v1/messages?foo=bar")
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["path"], "/v1/messages?foo=bar")
|
|
|
|
def test_duplicate_headers_merged(self):
|
|
"""Duplicate header values are merged with comma."""
|
|
# Send two X-Test values
|
|
conn = http.client.HTTPConnection("127.0.0.1", self.proxy_port, timeout=10)
|
|
conn.putrequest("GET", "/test")
|
|
conn.putheader("X-Original-Host", "127.0.0.1")
|
|
conn.putheader("X-Original-Port", str(self.upstream_port))
|
|
conn.putheader("X-Original-Scheme", "http")
|
|
conn.putheader("X-Test", "value1")
|
|
conn.putheader("X-Test", "value2")
|
|
conn.endheaders()
|
|
resp = conn.getresponse()
|
|
data = resp.read()
|
|
conn.close()
|
|
echo = json.loads(data)
|
|
self.assertIn("value1", echo["headers"].get("X-Test", ""))
|
|
self.assertIn("value2", echo["headers"].get("X-Test", ""))
|
|
|
|
|
|
class TestCredentialProxyErrors(unittest.TestCase):
|
|
"""Error handling tests."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.rules = [{"domain": "test.example", "headers": {}}]
|
|
cls.proxy_proc, cls.proxy_port = _start_credential_proxy(cls.rules)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
_stop_proxy(cls.proxy_proc)
|
|
|
|
def test_missing_x_original_host(self):
|
|
"""Returns 400 when X-Original-Host is missing."""
|
|
status, _, data = _proxy_request(self.proxy_port, "GET", "/test")
|
|
self.assertEqual(status, 400)
|
|
body = json.loads(data)
|
|
self.assertIn("Missing X-Original-Host", body["error"]["message"])
|
|
|
|
def test_body_too_large(self):
|
|
"""Returns 413 when Content-Length exceeds limit."""
|
|
headers = {
|
|
"X-Original-Host": "test.example",
|
|
"Content-Length": str(64 * 1024 * 1024), # 64 MB > 32 MB limit
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "POST", "/test", headers)
|
|
self.assertEqual(status, 413)
|
|
|
|
def test_invalid_content_length(self):
|
|
"""Returns 400 when Content-Length is not a number."""
|
|
headers = {
|
|
"X-Original-Host": "test.example",
|
|
"Content-Length": "not-a-number",
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "POST", "/test", headers)
|
|
self.assertEqual(status, 400)
|
|
|
|
def test_upstream_unreachable(self):
|
|
"""Returns 502 when upstream is unreachable."""
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": "1", # nothing listening
|
|
"X-Original-Scheme": "http",
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "GET", "/test", headers)
|
|
self.assertEqual(status, 502)
|
|
|
|
|
|
class TestCredentialProxySecret(unittest.TestCase):
|
|
"""Tests for shared secret verification (cross-VM isolation)."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.upstream_server, cls.upstream_port = _start_mock_upstream(EchoHandler)
|
|
cls.secret = "test-secret-token-12345"
|
|
cls.rules = [
|
|
{"domain": "127.0.0.1", "headers": {"Authorization": "Bearer secret-value"}},
|
|
]
|
|
cls.proxy_proc, cls.proxy_port = _start_credential_proxy(
|
|
cls.rules, env_extra={"CREDENTIAL_PROXY_SECRET": cls.secret})
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
_stop_proxy(cls.proxy_proc)
|
|
cls.upstream_server.shutdown()
|
|
|
|
def test_valid_secret_accepted(self):
|
|
"""Request with correct Proxy-Authorization succeeds."""
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
"Proxy-Authorization": _proxy_auth_header(self.secret),
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "GET", "/test", headers)
|
|
self.assertEqual(status, 200)
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["headers"]["Authorization"], "Bearer secret-value")
|
|
|
|
def test_missing_secret_rejected(self):
|
|
"""Request without Proxy-Authorization returns 407."""
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "GET", "/test", headers)
|
|
self.assertEqual(status, 407)
|
|
|
|
def test_wrong_secret_rejected(self):
|
|
"""Request with wrong Proxy-Authorization returns 407."""
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
"Proxy-Authorization": _proxy_auth_header("wrong-secret"),
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "GET", "/test", headers)
|
|
self.assertEqual(status, 407)
|
|
|
|
def test_secret_not_forwarded_upstream(self):
|
|
"""Proxy-Authorization is stripped before forwarding to upstream."""
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
"Proxy-Authorization": _proxy_auth_header(self.secret),
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "GET", "/test", headers)
|
|
echo = json.loads(data)
|
|
self.assertNotIn("Proxy-Authorization", echo["headers"])
|
|
|
|
|
|
class TestCredentialProxyUnmatched(unittest.TestCase):
|
|
"""Requests to domains without credential rules."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.upstream_server, cls.upstream_port = _start_mock_upstream(EchoHandler)
|
|
cls.rules = [
|
|
{"domain": "only-this-domain.example", "headers": {"Authorization": "Bearer secret"}},
|
|
]
|
|
cls.proxy_proc, cls.proxy_port = _start_credential_proxy(cls.rules)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
_stop_proxy(cls.proxy_proc)
|
|
cls.upstream_server.shutdown()
|
|
|
|
def test_unmatched_domain_no_injection(self):
|
|
"""Requests to unmatched domains are forwarded without header injection."""
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "GET", "/test", headers)
|
|
self.assertEqual(status, 200)
|
|
echo = json.loads(data)
|
|
self.assertNotIn("Authorization", echo["headers"])
|
|
|
|
|
|
class TestCredentialProxyStreaming(unittest.TestCase):
|
|
"""Tests chunked/streaming responses."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.upstream_server, cls.upstream_port = _start_mock_upstream(ChunkedHandler)
|
|
cls.rules = [{"domain": "127.0.0.1", "headers": {}}]
|
|
cls.proxy_proc, cls.proxy_port = _start_credential_proxy(cls.rules)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
_stop_proxy(cls.proxy_proc)
|
|
cls.upstream_server.shutdown()
|
|
|
|
def test_chunked_response(self):
|
|
"""Chunked upstream responses are re-chunked to the client."""
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
}
|
|
status, resp_headers, data = _proxy_request(
|
|
self.proxy_port, "GET", "/stream", headers)
|
|
self.assertEqual(status, 200)
|
|
body = data.decode()
|
|
self.assertIn("chunk-0", body)
|
|
self.assertIn("chunk-1", body)
|
|
self.assertIn("chunk-2", body)
|
|
|
|
|
|
class TestCredentialProxyWebSocket(unittest.TestCase):
|
|
"""Tests websocket upgrade forwarding with header injection."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.upstream_server, cls.upstream_port = _start_mock_upstream(WebSocketUpgradeHandler)
|
|
cls.rules = [{
|
|
"domain": "127.0.0.1",
|
|
"headers": {"Authorization": "Bearer websocket-secret"},
|
|
}]
|
|
cls.proxy_proc, cls.proxy_port = _start_credential_proxy(cls.rules)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
_stop_proxy(cls.proxy_proc)
|
|
cls.upstream_server.shutdown()
|
|
|
|
def test_websocket_upgrade_and_tunnel(self):
|
|
sock = socket.create_connection(("127.0.0.1", self.proxy_port), timeout=10)
|
|
request = "\r\n".join([
|
|
"GET /backend-api/codex/responses HTTP/1.1",
|
|
"Host: proxy.local",
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
"Sec-WebSocket-Version: 13",
|
|
"Sec-WebSocket-Key: test-key",
|
|
"X-Original-Host: 127.0.0.1",
|
|
f"X-Original-Port: {self.upstream_port}",
|
|
"X-Original-Scheme: http",
|
|
"",
|
|
"",
|
|
]).encode()
|
|
sock.sendall(request)
|
|
response = b""
|
|
while b"\r\n\r\n" not in response:
|
|
response += sock.recv(4096)
|
|
self.assertIn(b"101 Switching Protocols", response)
|
|
|
|
payload = b"ping"
|
|
mask = b"\x01\x02\x03\x04"
|
|
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
sock.sendall(bytes([0x81, 0x80 | len(payload)]) + mask + masked)
|
|
frame = sock.recv(1024)
|
|
sock.close()
|
|
|
|
self.assertEqual(frame[:2], bytes([0x81, len(payload)]))
|
|
self.assertEqual(frame[2:2 + len(payload)], payload)
|
|
self.assertEqual(WebSocketUpgradeHandler.echoed_payload, payload)
|
|
self.assertEqual(WebSocketUpgradeHandler.received_path, "/backend-api/codex/responses")
|
|
self.assertEqual(
|
|
WebSocketUpgradeHandler.received_headers["Authorization"],
|
|
"Bearer websocket-secret",
|
|
)
|
|
|
|
|
|
class TestCredentialProxyStartup(unittest.TestCase):
|
|
"""Tests for startup behavior."""
|
|
|
|
def test_empty_rules_exits(self):
|
|
"""Proxy exits with error when CREDENTIAL_PROXY_RULES is empty."""
|
|
env = os.environ.copy()
|
|
env["CREDENTIAL_PROXY_RULES"] = "[]"
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "credential-proxy.py"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
proc.wait(timeout=5)
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
stderr = proc.stderr.read().decode()
|
|
self.assertIn("empty", stderr.lower())
|
|
|
|
def test_invalid_json_exits(self):
|
|
"""Proxy exits with error when CREDENTIAL_PROXY_RULES is invalid JSON."""
|
|
env = os.environ.copy()
|
|
env["CREDENTIAL_PROXY_RULES"] = "not-json"
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "credential-proxy.py"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
proc.wait(timeout=5)
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
|
|
def test_sigterm_shutdown(self):
|
|
"""Proxy shuts down cleanly on SIGTERM."""
|
|
rules = [{"domain": "test.example", "headers": {}}]
|
|
proc, port = _start_credential_proxy(rules)
|
|
proc.send_signal(signal.SIGTERM)
|
|
try:
|
|
proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=2)
|
|
self.fail("Proxy did not shut down within 3 seconds of SIGTERM")
|
|
self.assertEqual(proc.returncode, 0)
|
|
|
|
|
|
class TestBuildCredentialRules(unittest.TestCase):
|
|
"""Test the _claude_vm_build_credential_rules shell function."""
|
|
|
|
def _build_rules(self, anthropic_token, openai_token, repos_json):
|
|
result = subprocess.run(
|
|
["bash", "-c",
|
|
f'source claude-vm.sh 2>/dev/null && '
|
|
f"_claude_vm_build_credential_rules '{anthropic_token}' '{openai_token}' '{repos_json}'"],
|
|
capture_output=True, text=True, timeout=15,
|
|
)
|
|
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
|
|
return json.loads(result.stdout)
|
|
|
|
def test_single_repo(self):
|
|
"""Single repo produces Anthropic/OpenAI/ChatGPT + per-repo rules + fallback."""
|
|
repos = json.dumps({"owner/repo": "ghu_test"})
|
|
rules = self._build_rules("", "", repos)
|
|
# 1 anthropic + 1 openai + 1 chatgpt + 2 per-repo
|
|
# + 2 fallback = 7
|
|
self.assertEqual(len(rules), 7)
|
|
# Per-repo rules have path_prefix
|
|
repo_rules = [r for r in rules if r.get("path_prefix")]
|
|
self.assertEqual(len(repo_rules), 2)
|
|
|
|
def test_multi_repo(self):
|
|
"""Multiple repos produce per-repo rules with different tokens."""
|
|
repos = json.dumps({"org1/repo1": "token1", "org2/repo2": "token2"})
|
|
rules = self._build_rules("", "", repos)
|
|
# 1 anthropic + 1 openai + 1 chatgpt + 2 repos * 2 rules each + 2 fallback = 9
|
|
self.assertEqual(len(rules), 9)
|
|
# Check different path prefixes
|
|
git_rules = [r for r in rules if r["domain"] == "github.com" and r.get("path_prefix")]
|
|
prefixes = [r["path_prefix"] for r in git_rules]
|
|
self.assertIn("/org1/repo1", prefixes)
|
|
self.assertIn("/org2/repo2", prefixes)
|
|
|
|
def test_multi_repo_different_tokens(self):
|
|
"""Each repo gets its own token injected."""
|
|
repos = json.dumps({"org1/repo1": "token_AAA", "org2/repo2": "token_BBB"})
|
|
rules = self._build_rules("", "", repos)
|
|
api_rules = {r["path_prefix"]: r for r in rules
|
|
if r["domain"] == "api.github.com" and r.get("path_prefix")}
|
|
self.assertEqual(api_rules["/repos/org1/repo1"]["headers"]["Authorization"],
|
|
"token token_AAA")
|
|
self.assertEqual(api_rules["/repos/org2/repo2"]["headers"]["Authorization"],
|
|
"token token_BBB")
|
|
|
|
def test_both_tokens(self):
|
|
"""Anthropic/OpenAI/GitHub tokens produce correct rules."""
|
|
repos = json.dumps({"owner/repo": "ghu_test"})
|
|
rules = self._build_rules("sk-ant-test", "sk-openai-test", repos)
|
|
# 1 anthropic + 1 openai + 1 chatgpt + 2 per-repo + 2 fallback = 7
|
|
self.assertEqual(len(rules), 7)
|
|
domains = [r["domain"] for r in rules]
|
|
self.assertIn("api.anthropic.com", domains)
|
|
self.assertIn("api.openai.com", domains)
|
|
self.assertIn("chatgpt.com", domains)
|
|
self.assertIn("github.com", domains)
|
|
self.assertIn("api.github.com", domains)
|
|
|
|
def test_anthropic_only(self):
|
|
"""Anthropic-only auth still includes OpenAI and ChatGPT routing rules."""
|
|
rules = self._build_rules("sk-ant-test", "", "")
|
|
self.assertEqual(len(rules), 3)
|
|
self.assertEqual(rules[0]["domain"], "api.anthropic.com")
|
|
self.assertEqual(rules[0]["headers"]["Authorization"], "Bearer sk-ant-test")
|
|
self.assertNotIn("anthropic-beta", rules[0]["headers"])
|
|
self.assertTrue(rules[0]["use_proxy"])
|
|
self.assertEqual(rules[1]["domain"], "api.openai.com")
|
|
self.assertEqual(rules[1]["headers"], {})
|
|
self.assertTrue(rules[1]["use_proxy"])
|
|
self.assertEqual(rules[2]["domain"], "chatgpt.com")
|
|
self.assertEqual(rules[2]["headers"], {})
|
|
self.assertTrue(rules[2]["use_proxy"])
|
|
|
|
def test_openai_only(self):
|
|
"""OpenAI-only auth produces OpenAI/ChatGPT bearer rules plus Anthropic routing."""
|
|
rules = self._build_rules("", "sk-openai-test", "")
|
|
self.assertEqual(len(rules), 3)
|
|
self.assertEqual(rules[0]["domain"], "api.anthropic.com")
|
|
self.assertEqual(rules[0]["headers"], {})
|
|
self.assertTrue(rules[0]["use_proxy"])
|
|
self.assertEqual(rules[1]["domain"], "api.openai.com")
|
|
self.assertEqual(rules[1]["headers"]["Authorization"], "Bearer sk-openai-test")
|
|
self.assertTrue(rules[1]["use_proxy"])
|
|
self.assertEqual(rules[2]["domain"], "chatgpt.com")
|
|
self.assertEqual(rules[2]["headers"]["Authorization"], "Bearer sk-openai-test")
|
|
self.assertTrue(rules[2]["use_proxy"])
|
|
|
|
def test_github_basic_auth_encoding(self):
|
|
"""GitHub token is base64-encoded as Basic auth for git."""
|
|
repos = json.dumps({"owner/repo": "ghu_test"})
|
|
rules = self._build_rules("", "", repos)
|
|
github_rule = next(r for r in rules
|
|
if r["domain"] == "github.com" and r.get("path_prefix"))
|
|
auth = github_rule["headers"]["Authorization"]
|
|
self.assertTrue(auth.startswith("Basic "))
|
|
decoded = base64.b64decode(auth.split(" ", 1)[1]).decode()
|
|
self.assertEqual(decoded, "x-access-token:ghu_test")
|
|
self.assertFalse(github_rule.get("use_proxy", False))
|
|
|
|
def test_github_api_token_auth(self):
|
|
"""api.github.com uses token auth."""
|
|
repos = json.dumps({"owner/repo": "ghu_test"})
|
|
rules = self._build_rules("", "", repos)
|
|
api_rule = next(r for r in rules
|
|
if r["domain"] == "api.github.com" and r.get("path_prefix"))
|
|
self.assertEqual(api_rule["headers"]["Authorization"], "token ghu_test")
|
|
|
|
def test_fallback_rules(self):
|
|
"""Domain-level fallback rules have no path_prefix."""
|
|
repos = json.dumps({"owner/repo": "ghu_test"})
|
|
rules = self._build_rules("", "", repos)
|
|
fallback = [r for r in rules if not r.get("path_prefix")]
|
|
# 1 anthropic + 1 openai + 1 chatgpt + 2 github fallback = 5
|
|
self.assertEqual(len(fallback), 5)
|
|
fallback_domains = {r["domain"] for r in fallback}
|
|
self.assertEqual(
|
|
fallback_domains,
|
|
{"api.anthropic.com", "api.openai.com", "chatgpt.com", "github.com", "api.github.com"},
|
|
)
|
|
|
|
def test_no_tokens(self):
|
|
"""No tokens still produce Anthropic/OpenAI/ChatGPT rules for proxy routing."""
|
|
rules = self._build_rules("", "", "")
|
|
self.assertEqual(len(rules), 3)
|
|
self.assertEqual(rules[0]["domain"], "api.anthropic.com")
|
|
self.assertEqual(rules[0]["headers"], {})
|
|
self.assertTrue(rules[0]["use_proxy"])
|
|
self.assertEqual(rules[1]["domain"], "api.openai.com")
|
|
self.assertEqual(rules[1]["headers"], {})
|
|
self.assertTrue(rules[1]["use_proxy"])
|
|
self.assertEqual(rules[2]["domain"], "chatgpt.com")
|
|
self.assertEqual(rules[2]["headers"], {})
|
|
self.assertTrue(rules[2]["use_proxy"])
|
|
|
|
|
|
class TestCodexHostAuthHelpers(unittest.TestCase):
|
|
"""Tests for host-side Codex auth extraction and placeholder generation."""
|
|
|
|
@staticmethod
|
|
def _fake_jwt(payload):
|
|
header = {"alg": "RS256", "typ": "JWT"}
|
|
h = base64.urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode()).decode().rstrip("=")
|
|
p = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")
|
|
return f"{h}.{p}.sig"
|
|
|
|
def test_read_valid_host_auth(self):
|
|
"""Helper accepts a ChatGPT auth.json with token data."""
|
|
import tempfile
|
|
|
|
account_id = "acct-123"
|
|
id_token = self._fake_jwt({
|
|
"iss": "https://auth.openai.com",
|
|
"aud": ["app_EMoamEEZ73f0CkXaXp7hrann"],
|
|
"iat": 100,
|
|
"exp": 200,
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": account_id,
|
|
"chatgpt_plan_type": "plus",
|
|
},
|
|
})
|
|
access_token = self._fake_jwt({
|
|
"iss": "https://auth.openai.com",
|
|
"aud": ["https://api.openai.com/v1"],
|
|
"iat": 100,
|
|
"nbf": 100,
|
|
"exp": 200,
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": account_id,
|
|
"chatgpt_plan_type": "plus",
|
|
},
|
|
})
|
|
auth = {
|
|
"auth_mode": "chatgpt",
|
|
"OPENAI_API_KEY": None,
|
|
"tokens": {
|
|
"id_token": id_token,
|
|
"access_token": access_token,
|
|
"refresh_token": "rt_test",
|
|
"account_id": account_id,
|
|
},
|
|
"last_refresh": "2026-03-30T12:20:21Z",
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
with open(os.path.join(td, "auth.json"), "w", encoding="utf-8") as f:
|
|
json.dump(auth, f)
|
|
result = subprocess.run(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
"source claude-vm.sh 2>/dev/null && _codex_vm_read_valid_host_auth",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
env={**os.environ, "CODEX_HOME": td},
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
parsed = json.loads(result.stdout)
|
|
self.assertEqual(parsed["access_token"], access_token)
|
|
self.assertEqual(parsed["account_id"], account_id)
|
|
self.assertEqual(parsed["plan_type"], "plus")
|
|
self.assertEqual(parsed["access_exp"], 200)
|
|
|
|
def test_build_placeholder_auth_json(self):
|
|
"""Placeholder auth keeps metadata but replaces secrets."""
|
|
host_auth = json.dumps({
|
|
"access_token": "access-token-real",
|
|
"account_id": "acct-123",
|
|
"plan_type": "plus",
|
|
"access_exp": 1234567890,
|
|
"last_refresh": "2026-03-30T12:20:21Z",
|
|
})
|
|
result = subprocess.run(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
f"source claude-vm.sh 2>/dev/null && _codex_vm_build_placeholder_auth_json '{host_auth}'",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
placeholder = json.loads(result.stdout)
|
|
self.assertEqual(placeholder["auth_mode"], "chatgpt")
|
|
self.assertIsNone(placeholder["OPENAI_API_KEY"])
|
|
self.assertEqual(placeholder["tokens"]["account_id"], "acct-123")
|
|
self.assertEqual(
|
|
placeholder["tokens"]["refresh_token"],
|
|
"placeholder-refresh-token-injected-by-proxy",
|
|
)
|
|
self.assertNotEqual(placeholder["tokens"]["access_token"], "access-token-real")
|
|
payload = placeholder["tokens"]["access_token"].split(".")[1]
|
|
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
self.assertEqual(claims["exp"], 1234567890)
|
|
self.assertEqual(
|
|
claims["https://api.openai.com/auth"]["chatgpt_account_id"],
|
|
"acct-123",
|
|
)
|
|
|
|
def test_build_opencode_oauth_auth_json(self):
|
|
"""OpenCode host-auth helper emits native OAuth credentials."""
|
|
host_auth = json.dumps({
|
|
"access_token": "access-token-real",
|
|
"account_id": "acct-123",
|
|
"plan_type": "plus",
|
|
"access_exp": 1234567890,
|
|
"last_refresh": "2026-03-30T12:20:21Z",
|
|
})
|
|
result = subprocess.run(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
f"source claude-vm.sh 2>/dev/null && _opencode_vm_build_oauth_auth_json '{host_auth}'",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
parsed = json.loads(result.stdout)
|
|
self.assertEqual(
|
|
parsed,
|
|
{
|
|
"type": "oauth",
|
|
"refresh": "placeholder-refresh-token-injected-by-proxy",
|
|
"access": parsed["access"],
|
|
"expires": 1234567890000,
|
|
"accountId": "acct-123",
|
|
},
|
|
)
|
|
self.assertNotEqual(parsed["access"], "access-token-real")
|
|
payload = parsed["access"].split(".")[1]
|
|
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
self.assertEqual(claims["exp"], 1234567890)
|
|
self.assertEqual(
|
|
claims["https://api.openai.com/auth"]["chatgpt_account_id"],
|
|
"acct-123",
|
|
)
|
|
|
|
|
|
class TestCredentialProxyPathMatching(unittest.TestCase):
|
|
"""Tests path-prefix-based credential matching for multi-repo support."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.upstream_server, cls.upstream_port = _start_mock_upstream(EchoHandler)
|
|
cls.rules = [
|
|
# Per-repo rules with path_prefix
|
|
{
|
|
"domain": "127.0.0.1",
|
|
"path_prefix": "/org1/repo1",
|
|
"headers": {"Authorization": "token token-for-repo1"},
|
|
},
|
|
{
|
|
"domain": "127.0.0.1",
|
|
"path_prefix": "/org2/repo2",
|
|
"headers": {"Authorization": "token token-for-repo2"},
|
|
},
|
|
# Domain-level fallback (no path_prefix)
|
|
{
|
|
"domain": "127.0.0.1",
|
|
"headers": {"Authorization": "token fallback-token"},
|
|
},
|
|
]
|
|
cls.proxy_proc, cls.proxy_port = _start_credential_proxy(cls.rules)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
_stop_proxy(cls.proxy_proc)
|
|
cls.upstream_server.shutdown()
|
|
|
|
def _request(self, path):
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
}
|
|
return _proxy_request(self.proxy_port, "GET", path, headers)
|
|
|
|
def test_repo1_gets_repo1_token(self):
|
|
"""Requests to /org1/repo1/... get repo1's token."""
|
|
status, _, data = self._request("/org1/repo1/info/refs?service=git-upload-pack")
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["headers"]["Authorization"], "token token-for-repo1")
|
|
|
|
def test_repo2_gets_repo2_token(self):
|
|
"""Requests to /org2/repo2/... get repo2's token."""
|
|
status, _, data = self._request("/org2/repo2/info/refs?service=git-upload-pack")
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["headers"]["Authorization"], "token token-for-repo2")
|
|
|
|
def test_unknown_path_gets_fallback(self):
|
|
"""Requests to unknown paths get the fallback token."""
|
|
status, _, data = self._request("/other/repo/info/refs")
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["headers"]["Authorization"], "token fallback-token")
|
|
|
|
def test_root_path_gets_fallback(self):
|
|
"""Requests to / get the fallback token."""
|
|
status, _, data = self._request("/user")
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["headers"]["Authorization"], "token fallback-token")
|
|
|
|
|
|
class TestMitmproxyAddon(unittest.TestCase):
|
|
"""Tests for the mitmproxy addon request() function logic.
|
|
|
|
We don't import mitmproxy (not installed on host), but we test
|
|
the addon logic by simulating the flow object.
|
|
"""
|
|
|
|
def test_addon_rewrites_flow(self):
|
|
"""Addon sets X-Original-* headers and rewrites host/scheme/port."""
|
|
# Simulate what the addon does without importing mitmproxy
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", """
|
|
import os, types, sys
|
|
|
|
# Mock mitmproxy.http module
|
|
mock_http = types.ModuleType("mitmproxy.http")
|
|
|
|
class Headers(dict):
|
|
def __setitem__(self, key, value):
|
|
super().__setitem__(key, value)
|
|
|
|
class Request:
|
|
def __init__(self):
|
|
self.host = "api.anthropic.com"
|
|
self.port = 443
|
|
self.scheme = "https"
|
|
self.headers = Headers()
|
|
|
|
class HTTPFlow:
|
|
def __init__(self):
|
|
self.request = Request()
|
|
|
|
mock_http.HTTPFlow = HTTPFlow
|
|
sys.modules["mitmproxy"] = types.ModuleType("mitmproxy")
|
|
sys.modules["mitmproxy.http"] = mock_http
|
|
|
|
os.environ["CREDENTIAL_PROXY_HOST"] = "host.lima.internal"
|
|
os.environ["CREDENTIAL_PROXY_PORT"] = "12345"
|
|
os.environ["CREDENTIAL_PROXY_DOMAINS"] = "api.anthropic.com,github.com"
|
|
|
|
# Import the addon
|
|
sys.path.insert(0, ".")
|
|
# Manually exec to avoid import caching issues
|
|
with open("mitmproxy-addon.py") as f:
|
|
code = f.read()
|
|
ns = {"__name__": "mitmproxy_addon"}
|
|
exec(compile(code, "mitmproxy-addon.py", "exec"), ns)
|
|
|
|
# Test: configured domain gets redirected
|
|
flow = HTTPFlow()
|
|
ns["request"](flow)
|
|
|
|
assert flow.request.headers["X-Original-Host"] == "api.anthropic.com"
|
|
assert flow.request.headers["X-Original-Port"] == "443"
|
|
assert flow.request.headers["X-Original-Scheme"] == "https"
|
|
assert flow.request.scheme == "http"
|
|
assert flow.request.host == "host.lima.internal"
|
|
assert flow.request.port == 12345
|
|
print("ALL_PASSED")
|
|
"""],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
self.assertIn("ALL_PASSED", result.stdout, f"stderr: {result.stderr}")
|
|
|
|
def test_addon_different_port(self):
|
|
"""Addon preserves non-443 port in X-Original-Port."""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", """
|
|
import os, types, sys
|
|
|
|
mock_http = types.ModuleType("mitmproxy.http")
|
|
class Headers(dict):
|
|
pass
|
|
class Request:
|
|
def __init__(self):
|
|
self.host = "github.com"
|
|
self.port = 8443
|
|
self.scheme = "https"
|
|
self.headers = Headers()
|
|
class HTTPFlow:
|
|
def __init__(self):
|
|
self.request = Request()
|
|
mock_http.HTTPFlow = HTTPFlow
|
|
sys.modules["mitmproxy"] = types.ModuleType("mitmproxy")
|
|
sys.modules["mitmproxy.http"] = mock_http
|
|
|
|
os.environ["CREDENTIAL_PROXY_HOST"] = "proxy-host"
|
|
os.environ["CREDENTIAL_PROXY_PORT"] = "9999"
|
|
os.environ["CREDENTIAL_PROXY_DOMAINS"] = "github.com,api.github.com"
|
|
|
|
sys.path.insert(0, ".")
|
|
with open("mitmproxy-addon.py") as f:
|
|
code = f.read()
|
|
ns = {"__name__": "mitmproxy_addon"}
|
|
exec(compile(code, "mitmproxy-addon.py", "exec"), ns)
|
|
|
|
flow = HTTPFlow()
|
|
ns["request"](flow)
|
|
|
|
assert flow.request.headers["X-Original-Port"] == "8443"
|
|
assert flow.request.port == 9999
|
|
assert flow.request.host == "proxy-host"
|
|
print("ALL_PASSED")
|
|
"""],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
self.assertIn("ALL_PASSED", result.stdout, f"stderr: {result.stderr}")
|
|
|
|
|
|
def test_addon_injects_secret(self):
|
|
"""Addon injects Proxy-Authorization when CREDENTIAL_PROXY_SECRET is set."""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", """
|
|
import base64, os, types, sys
|
|
|
|
mock_http = types.ModuleType("mitmproxy.http")
|
|
class Headers(dict):
|
|
pass
|
|
class Request:
|
|
def __init__(self):
|
|
self.host = "api.anthropic.com"
|
|
self.port = 443
|
|
self.scheme = "https"
|
|
self.headers = Headers()
|
|
class HTTPFlow:
|
|
def __init__(self):
|
|
self.request = Request()
|
|
mock_http.HTTPFlow = HTTPFlow
|
|
sys.modules["mitmproxy"] = types.ModuleType("mitmproxy")
|
|
sys.modules["mitmproxy.http"] = mock_http
|
|
|
|
os.environ["CREDENTIAL_PROXY_HOST"] = "host.lima.internal"
|
|
os.environ["CREDENTIAL_PROXY_PORT"] = "12345"
|
|
os.environ["CREDENTIAL_PROXY_SECRET"] = "my-secret-token"
|
|
os.environ["CREDENTIAL_PROXY_DOMAINS"] = "api.anthropic.com"
|
|
|
|
sys.path.insert(0, ".")
|
|
with open("mitmproxy-addon.py") as f:
|
|
code = f.read()
|
|
ns = {"__name__": "mitmproxy_addon"}
|
|
exec(compile(code, "mitmproxy-addon.py", "exec"), ns)
|
|
|
|
flow = HTTPFlow()
|
|
ns["request"](flow)
|
|
|
|
expected = "Basic " + base64.b64encode(b"_:my-secret-token").decode()
|
|
assert flow.request.headers["Proxy-Authorization"] == expected, flow.request.headers.get("Proxy-Authorization")
|
|
print("ALL_PASSED")
|
|
"""],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
self.assertIn("ALL_PASSED", result.stdout, f"stderr: {result.stderr}")
|
|
|
|
def test_addon_no_secret_no_header(self):
|
|
"""Addon does not inject Proxy-Authorization when secret is empty."""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", """
|
|
import os, types, sys
|
|
|
|
mock_http = types.ModuleType("mitmproxy.http")
|
|
class Headers(dict):
|
|
pass
|
|
class Request:
|
|
def __init__(self):
|
|
self.host = "api.anthropic.com"
|
|
self.port = 443
|
|
self.scheme = "https"
|
|
self.headers = Headers()
|
|
class HTTPFlow:
|
|
def __init__(self):
|
|
self.request = Request()
|
|
mock_http.HTTPFlow = HTTPFlow
|
|
sys.modules["mitmproxy"] = types.ModuleType("mitmproxy")
|
|
sys.modules["mitmproxy.http"] = mock_http
|
|
|
|
os.environ["CREDENTIAL_PROXY_HOST"] = "host.lima.internal"
|
|
os.environ["CREDENTIAL_PROXY_PORT"] = "12345"
|
|
os.environ.pop("CREDENTIAL_PROXY_SECRET", None)
|
|
os.environ["CREDENTIAL_PROXY_DOMAINS"] = "api.anthropic.com"
|
|
|
|
sys.path.insert(0, ".")
|
|
with open("mitmproxy-addon.py") as f:
|
|
code = f.read()
|
|
ns = {"__name__": "mitmproxy_addon"}
|
|
exec(compile(code, "mitmproxy-addon.py", "exec"), ns)
|
|
|
|
flow = HTTPFlow()
|
|
ns["request"](flow)
|
|
|
|
assert "Proxy-Authorization" not in flow.request.headers
|
|
print("ALL_PASSED")
|
|
"""],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
self.assertIn("ALL_PASSED", result.stdout, f"stderr: {result.stderr}")
|
|
|
|
|
|
def test_addon_skips_non_configured_domain(self):
|
|
"""Addon does not redirect requests to non-configured domains."""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", """
|
|
import os, types, sys
|
|
|
|
mock_http = types.ModuleType("mitmproxy.http")
|
|
class Headers(dict):
|
|
pass
|
|
class Request:
|
|
def __init__(self):
|
|
self.host = "example.com"
|
|
self.port = 443
|
|
self.scheme = "https"
|
|
self.headers = Headers()
|
|
class HTTPFlow:
|
|
def __init__(self):
|
|
self.request = Request()
|
|
mock_http.HTTPFlow = HTTPFlow
|
|
sys.modules["mitmproxy"] = types.ModuleType("mitmproxy")
|
|
sys.modules["mitmproxy.http"] = mock_http
|
|
|
|
os.environ["CREDENTIAL_PROXY_HOST"] = "host.lima.internal"
|
|
os.environ["CREDENTIAL_PROXY_PORT"] = "12345"
|
|
os.environ["CREDENTIAL_PROXY_DOMAINS"] = "api.github.com,github.com"
|
|
|
|
sys.path.insert(0, ".")
|
|
with open("mitmproxy-addon.py") as f:
|
|
code = f.read()
|
|
ns = {"__name__": "mitmproxy_addon"}
|
|
exec(compile(code, "mitmproxy-addon.py", "exec"), ns)
|
|
|
|
flow = HTTPFlow()
|
|
ns["request"](flow)
|
|
|
|
# Non-configured domain should NOT be redirected
|
|
assert flow.request.host == "example.com", f"host was changed to {flow.request.host}"
|
|
assert flow.request.port == 443
|
|
assert flow.request.scheme == "https"
|
|
assert "X-Original-Host" not in flow.request.headers
|
|
print("ALL_PASSED")
|
|
"""],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
self.assertIn("ALL_PASSED", result.stdout, f"stderr: {result.stderr}")
|
|
|
|
def test_addon_blocks_blocked_domain(self):
|
|
"""Addon returns 403 for blocked domains."""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", """
|
|
import os, types, sys
|
|
|
|
mock_http = types.ModuleType("mitmproxy.http")
|
|
class Headers(dict):
|
|
pass
|
|
class Request:
|
|
def __init__(self):
|
|
self.host = "datadoghq.com"
|
|
self.port = 443
|
|
self.scheme = "https"
|
|
self.headers = Headers()
|
|
class Response:
|
|
@staticmethod
|
|
def make(status, body):
|
|
return {"status": status, "body": body}
|
|
class HTTPFlow:
|
|
def __init__(self):
|
|
self.request = Request()
|
|
self.response = None
|
|
mock_http.HTTPFlow = HTTPFlow
|
|
mock_http.Response = Response
|
|
sys.modules["mitmproxy"] = types.ModuleType("mitmproxy")
|
|
sys.modules["mitmproxy.http"] = mock_http
|
|
|
|
os.environ["CREDENTIAL_PROXY_HOST"] = "host.lima.internal"
|
|
os.environ["CREDENTIAL_PROXY_PORT"] = "12345"
|
|
os.environ["CREDENTIAL_PROXY_DOMAINS"] = "api.anthropic.com"
|
|
os.environ["BLOCKED_DOMAINS"] = "datadoghq.com,telemetry.example.com"
|
|
|
|
sys.path.insert(0, ".")
|
|
with open("mitmproxy-addon.py") as f:
|
|
code = f.read()
|
|
ns = {"__name__": "mitmproxy_addon"}
|
|
exec(compile(code, "mitmproxy-addon.py", "exec"), ns)
|
|
|
|
flow = HTTPFlow()
|
|
ns["request"](flow)
|
|
|
|
assert flow.response is not None, "blocked domain should get a response"
|
|
assert flow.response["status"] == 403
|
|
assert flow.request.host == "datadoghq.com", "host should not be rewritten"
|
|
print("ALL_PASSED")
|
|
"""],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
self.assertIn("ALL_PASSED", result.stdout, f"stderr: {result.stderr}")
|
|
|
|
|
|
class TestCredentialProxyUpstreamProxy(unittest.TestCase):
|
|
"""Tests that AI_HTTPS_PROXY routes upstream connections through a CONNECT proxy."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
# Start a mock CONNECT proxy that records the tunnel target
|
|
# and forwards to our echo server.
|
|
cls.upstream_server, cls.upstream_port = _start_mock_upstream(EchoHandler)
|
|
cls.connect_proxy_server, cls.connect_proxy_port = _start_connect_proxy(
|
|
cls.upstream_port)
|
|
|
|
cls.rules = [
|
|
{"domain": "tunneled.example.com", "headers": {"Authorization": "Bearer tunneled"},
|
|
"use_proxy": True},
|
|
]
|
|
# Start credential proxy with AI_HTTPS_PROXY pointing at our mock CONNECT proxy
|
|
# We use X-Original-Scheme: http in tests to avoid real TLS, but the proxy
|
|
# code path for HTTPS+AI_HTTPS_PROXY uses set_tunnel which requires the target
|
|
# to actually do TLS. So we test via a subprocess that patches the connection.
|
|
cls.proxy_proc, cls.proxy_port = _start_credential_proxy(
|
|
cls.rules,
|
|
env_extra={
|
|
"AI_HTTPS_PROXY": f"http://proxyuser:proxypass@127.0.0.1:{cls.connect_proxy_port}",
|
|
},
|
|
)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
_stop_proxy(cls.proxy_proc)
|
|
cls.connect_proxy_server.shutdown()
|
|
cls.upstream_server.shutdown()
|
|
|
|
def test_https_routed_through_connect_proxy(self):
|
|
"""HTTPS upstream requests go through the CONNECT proxy."""
|
|
# We can't do real TLS to our mock upstream, so we test the HTTP path
|
|
# (HTTPS_PROXY only applies to scheme=https). Instead, verify the proxy
|
|
# parsed correctly by hitting an HTTP upstream (bypasses HTTPS_PROXY)
|
|
# and separately test CONNECT proxy via a dedicated subprocess test.
|
|
headers = {
|
|
"X-Original-Host": "127.0.0.1",
|
|
"X-Original-Port": str(self.upstream_port),
|
|
"X-Original-Scheme": "http",
|
|
}
|
|
status, _, data = _proxy_request(self.proxy_port, "GET", "/test", headers)
|
|
self.assertEqual(status, 200)
|
|
# HTTP scheme should bypass the HTTPS_PROXY and go direct
|
|
echo = json.loads(data)
|
|
self.assertEqual(echo["path"], "/test")
|
|
|
|
|
|
class _ConnectProxyHandler(http.server.BaseHTTPRequestHandler):
|
|
"""Mock HTTP CONNECT proxy. Records tunnel targets and relays to upstream."""
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_CONNECT(self):
|
|
# Record that a CONNECT was requested
|
|
self.server.last_connect_target = self.path
|
|
self.server.last_proxy_auth = self.headers.get("Proxy-Authorization", "")
|
|
|
|
# Parse target host:port
|
|
host, _, port = self.path.partition(":")
|
|
port = int(port) if port else 443
|
|
|
|
# For testing, we just relay to our local echo server
|
|
import socket
|
|
try:
|
|
upstream_sock = socket.create_connection(("127.0.0.1", self.server.echo_port), timeout=5)
|
|
except Exception as e:
|
|
self.send_error(502, f"Cannot connect: {e}")
|
|
return
|
|
|
|
self.send_response(200, "Connection Established")
|
|
self.end_headers()
|
|
|
|
# Relay bytes between client and upstream
|
|
client_sock = self.connection
|
|
client_sock.setblocking(False)
|
|
upstream_sock.setblocking(False)
|
|
|
|
import select
|
|
while True:
|
|
readable, _, _ = select.select([client_sock, upstream_sock], [], [], 5)
|
|
if not readable:
|
|
break
|
|
done = False
|
|
for sock in readable:
|
|
try:
|
|
data = sock.recv(8192)
|
|
if not data:
|
|
done = True
|
|
break
|
|
if sock is client_sock:
|
|
upstream_sock.sendall(data)
|
|
else:
|
|
client_sock.sendall(data)
|
|
except (BlockingIOError, ConnectionResetError, BrokenPipeError):
|
|
done = True
|
|
break
|
|
if done:
|
|
break
|
|
upstream_sock.close()
|
|
|
|
|
|
def _start_connect_proxy(echo_port):
|
|
"""Start a mock CONNECT proxy that relays to the given echo port."""
|
|
server = http.server.HTTPServer(("127.0.0.1", 0), _ConnectProxyHandler)
|
|
server.daemon_threads = True
|
|
server.echo_port = echo_port
|
|
server.last_connect_target = None
|
|
server.last_proxy_auth = None
|
|
t = threading.Thread(target=server.serve_forever, daemon=True)
|
|
t.start()
|
|
return server, server.server_address[1]
|
|
|
|
|
|
class TestCredentialProxyConnectTunnel(unittest.TestCase):
|
|
"""End-to-end test: credential proxy tunnels HTTPS through a CONNECT proxy."""
|
|
|
|
def test_connect_tunnel_with_auth(self):
|
|
"""Credential proxy sends CONNECT with Proxy-Authorization to upstream proxy."""
|
|
upstream_server, upstream_port = _start_mock_upstream(EchoHandler)
|
|
connect_server, connect_port = _start_connect_proxy(upstream_port)
|
|
|
|
# Run a short-lived credential proxy subprocess that patches
|
|
# HTTPSConnection.connect to skip TLS (our mock doesn't do TLS).
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", f"""
|
|
import http.client, json, os, sys
|
|
from unittest.mock import patch
|
|
|
|
os.environ["CREDENTIAL_PROXY_RULES"] = json.dumps([
|
|
{{"domain": "target.example.com", "headers": {{"Authorization": "Bearer test"}},
|
|
"use_proxy": True}}
|
|
])
|
|
os.environ["AI_HTTPS_PROXY"] = "http://testuser:testpass@127.0.0.1:{connect_port}"
|
|
os.environ.pop("CREDENTIAL_PROXY_SECRET", None)
|
|
|
|
# Patch HTTPSConnection.connect to do TCP + CONNECT tunnel but skip TLS wrap.
|
|
# HTTPConnection.connect() already handles both TCP and _tunnel().
|
|
def _patched_connect(self):
|
|
http.client.HTTPConnection.connect(self)
|
|
|
|
with patch.object(http.client.HTTPSConnection, 'connect', _patched_connect):
|
|
sys.path.insert(0, ".")
|
|
import importlib
|
|
spec = importlib.util.spec_from_file_location("credential_proxy", "credential-proxy.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
|
|
server = mod.QuietServer(("127.0.0.1", 0), mod.CredentialProxyHandler)
|
|
port = server.server_address[1]
|
|
|
|
import threading
|
|
t = threading.Thread(target=server.handle_request, daemon=True)
|
|
t.start()
|
|
|
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
|
conn.request("GET", "/test", headers={{
|
|
"X-Original-Host": "target.example.com",
|
|
"X-Original-Port": "{upstream_port}",
|
|
"X-Original-Scheme": "https",
|
|
}})
|
|
resp = conn.getresponse()
|
|
body = resp.read()
|
|
conn.close()
|
|
|
|
echo = json.loads(body)
|
|
assert resp.status == 200, f"status={{resp.status}} body={{body}}"
|
|
assert echo["headers"]["Authorization"] == "Bearer test"
|
|
assert echo["headers"]["Host"] == "target.example.com"
|
|
print("TUNNEL_PASSED")
|
|
"""],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
upstream_server.shutdown()
|
|
connect_server.shutdown()
|
|
|
|
self.assertIn("TUNNEL_PASSED", result.stdout,
|
|
f"stdout: {result.stdout}\nstderr: {result.stderr}")
|
|
|
|
# Verify the CONNECT proxy saw the right target and auth
|
|
self.assertEqual(connect_server.last_connect_target,
|
|
f"target.example.com:{upstream_port}")
|
|
expected_auth = "Basic " + base64.b64encode(b"testuser:testpass").decode()
|
|
self.assertEqual(connect_server.last_proxy_auth, expected_auth)
|
|
|
|
|
|
class TestCredentialProxyExtraCACerts(unittest.TestCase):
|
|
"""Tests for SSL_CERT_FILE env var."""
|
|
|
|
def test_extra_ca_certs_loaded(self):
|
|
"""Proxy starts successfully with SSL_CERT_FILE pointing to a valid PEM."""
|
|
import tempfile
|
|
# Create a dummy CA cert (self-signed) to verify it loads without error
|
|
result = subprocess.run(
|
|
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-keyout", "/dev/null",
|
|
"-out", "/dev/stdout", "-days", "1", "-nodes",
|
|
"-subj", "/CN=test-ca"],
|
|
capture_output=True, timeout=10,
|
|
)
|
|
if result.returncode != 0:
|
|
self.skipTest("openssl not available")
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f:
|
|
f.write(result.stdout)
|
|
ca_path = f.name
|
|
|
|
try:
|
|
rules = [{"domain": "test.example", "headers": {}}]
|
|
proc, port = _start_credential_proxy(
|
|
rules, env_extra={"AI_SSL_CERT_FILE": ca_path})
|
|
# If we get here, the proxy started successfully with the extra CA
|
|
_stop_proxy(proc)
|
|
finally:
|
|
os.unlink(ca_path)
|
|
|
|
def test_invalid_ca_cert_fails(self):
|
|
"""Proxy fails to start with invalid SSL_CERT_FILE path."""
|
|
env = os.environ.copy()
|
|
env["CREDENTIAL_PROXY_RULES"] = json.dumps([{"domain": "t", "headers": {}}])
|
|
env["AI_SSL_CERT_FILE"] = "/nonexistent/ca.pem"
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "credential-proxy.py"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
proc.wait(timeout=5)
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|