Expose a runnable backend without giving the ingress layer secrets
Some checks failed
verify / verify (push) Has been cancelled

The backend can now be inspected through a loopback-only gateway while the database-aware API remains on the internal data network. A governed synthetic demo proves readiness, pgvector retrieval, reranking, and citation output through real HTTP without invoking cloud models.

Constraint: The previously exposed Bailian key is compromised and cannot be used for live validation

Constraint: The API must be locally reachable while retaining no internet egress

Rejected: Attach the API directly to the ingress network | a real socket test proved that configuration still had egress

Rejected: Publish a port from the internal-only network | Docker Desktop did not expose the host port

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep model and database credentials out of the gateway; do not relax the fixed demo identity/profile filters

Tested: make verify; 63 pytest tests; strict mypy; Ruff; Secret scan; Compose config; three backend image builds; API/DB/gateway healthy; migration exit 0; Swagger browser check; live/ready/meta/status/search HTTP; 20/20/20 index; API egress ENETUNREACH; empty gateway mounts and business environment

Not-tested: Live Bailian calls require a newly rotated key; full generated-answer flow and React UI are not implemented
This commit is contained in:
2026-07-12 16:37:02 +08:00
parent e89cca2b55
commit cfd6d4cbad
16 changed files with 1207 additions and 37 deletions

View File

@@ -0,0 +1,163 @@
import json
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
import httpx
import pytest
from fastapi import FastAPI
from app.gateway import MAX_REQUEST_BODY_BYTES, create_gateway_app
type Handler = Callable[[httpx.Request], httpx.Response]
@asynccontextmanager
async def _gateway_client(handler: Handler) -> AsyncIterator[httpx.AsyncClient]:
gateway = create_gateway_app(httpx.MockTransport(handler))
async with gateway.router.lifespan_context(gateway):
transport = httpx.ASGITransport(app=gateway)
async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client:
yield client
@pytest.mark.asyncio
async def test_gateway_live_and_docs_are_disabled() -> None:
gateway = create_gateway_app(httpx.MockTransport(lambda _: httpx.Response(500)))
try:
assert gateway.docs_url is None
assert gateway.redoc_url is None
assert gateway.openapi_url is None
transport = httpx.ASGITransport(app=gateway)
async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client:
response = await client.get("/gateway/live")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
finally:
await _close_gateway(gateway)
async def _close_gateway(gateway: FastAPI) -> None:
async with gateway.router.lifespan_context(gateway):
pass
@pytest.mark.asyncio
async def test_successful_get_preserves_path_query_and_allowed_header() -> None:
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(200, json={"result": "ok"}, headers={"x-request-id": "req-1"})
async with _gateway_client(handler) as client:
response = await client.get(
"/api/v1/search?q=granite%20deposit&limit=5",
headers={"x-request-id": "req-1", "x-forwarded-host": "evil.invalid"},
)
assert response.status_code == 200
assert response.json() == {"result": "ok"}
assert response.headers["x-request-id"] == "req-1"
assert seen[0].url == "http://api:8000/api/v1/search?q=granite%20deposit&limit=5"
assert seen[0].headers["host"] == "api:8000"
assert "x-forwarded-host" not in seen[0].headers
@pytest.mark.asyncio
async def test_post_json_body_and_content_type_are_forwarded() -> None:
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(201, json={"accepted": True})
async with _gateway_client(handler) as client:
response = await client.post("/api/v1/query", json={"question": "Where is gold?"})
assert response.status_code == 201
assert response.json() == {"accepted": True}
assert seen[0].method == "POST"
assert seen[0].headers["content-type"] == "application/json"
assert json.loads(seen[0].content) == {"question": "Where is gold?"}
@pytest.mark.asyncio
async def test_upstream_origin_is_fixed_against_host_and_path_ssrf_attempts() -> None:
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(200)
async with _gateway_client(handler) as client:
response = await client.get(
"//evil.invalid/http://metadata.internal/latest?target=http://attacker.invalid",
headers={"host": "attacker.invalid", "forwarded": "host=attacker.invalid"},
)
assert response.status_code == 200
assert seen[0].url.scheme == "http"
assert seen[0].url.host == "api"
assert seen[0].url.port == 8000
assert seen[0].headers["host"] == "api:8000"
assert "forwarded" not in seen[0].headers
@pytest.mark.asyncio
async def test_request_larger_than_one_mib_is_rejected_before_upstream() -> None:
upstream_calls = 0
def handler(_: httpx.Request) -> httpx.Response:
nonlocal upstream_calls
upstream_calls += 1
return httpx.Response(200)
async with _gateway_client(handler) as client:
response = await client.post("/upload", content=b"x" * (MAX_REQUEST_BODY_BYTES + 1))
assert response.status_code == 413
assert response.json() == {"detail": "request body too large"}
assert upstream_calls == 0
@pytest.mark.asyncio
async def test_upstream_transport_error_returns_redacted_502() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError(
"api:8000 refused connection with internal-token-value",
request=request,
)
async with _gateway_client(handler) as client:
response = await client.get("/api/v1/health/ready")
assert response.status_code == 502
assert response.json() == {"detail": "upstream unavailable"}
assert "api:8000" not in response.text
assert "internal-token-value" not in response.text
@pytest.mark.asyncio
async def test_sensitive_upstream_response_headers_are_not_forwarded() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
content=b"ok",
headers={
"content-type": "text/plain",
"cache-control": "no-store",
"set-cookie": "session=private",
"server": "internal-api",
"x-internal-token": "private",
},
)
async with _gateway_client(handler) as client:
response = await client.get("/status")
assert response.headers["content-type"] == "text/plain"
assert response.headers["cache-control"] == "no-store"
assert "set-cookie" not in response.headers
assert "server" not in response.headers
assert "x-internal-token" not in response.headers