## Implementation Plan: Restrict OpenAI Gateway to Localhost **Goal:** Prevent external network access to the `/openai/v1/*` endpoint while preserving internal service communication via `localhost:10500`. ### Changes Required #### 1. Nginx Configuration (`nginx/nginx.conf.template`) Insert a new `location /openai/` block **before** the existing catch-all `location /` block (approximately line 60). The new block will: - Allow only `127.0.0.1` (IPv4) and `::1` (IPv6). - Deny all other sources. - Proxy the request to the application backend. ```nginx location /openai/ { allow 127.0.0.1; allow ::1; deny all; proxy_pass http://app; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } ``` **Rationale:** This catches every request before it reaches the Python application layer, providing a robust network-level gate. No FastAPI code change is strictly required for the primary fix. #### 2. FastAPI Middleware (Defense in Depth) — `main.py` Add a middleware that checks `request.client.host` for any path starting with `/openai` and returns 403 if not localhost. This ensures protection even if nginx is bypassed (e.g. during development or if nginx config is accidentally removed). **Location:** After the existing middlewares, before the exception handlers. Approximately line 480 in the current `main.py`. ```python @app.middleware("http") async def restrict_openai_to_localhost(request: Request, call_next): if request.url.path.startswith("/openai"): host = request.client.host if host not in ("127.0.0.1", "::1"): return JSONResponse(status_code=403, content={"detail": "Forbidden"}) return await call_next(request) ``` Add import for `JSONResponse` at top of `main.py` if not already present: ```python from fastapi.responses import JSONResponse ``` #### 3. Unit Tests — `tests/unit/routing/test_gateway_restriction.py` (new file) Create a new test file that verifies the middleware works correctly: ```python import pytest from fastapi.testclient import TestClient from main import app client = TestClient(app) def test_non_localhost_rejected(): # Simulate a request from a non-localhost IP response = client.post( "/openai/v1/chat/completions", json={"messages": []}, headers={"X-Forwarded-For": "192.168.1.1"}, ) assert response.status_code == 403 def test_localhost_allowed(): # Simulate a request from localhost response = client.post( "/openai/v1/chat/completions", json={"messages": []}, headers={"Host": "localhost:10500", "X-Forwarded-For": "127.0.0.1"}, ) # Should reach the handler and return 401 (no auth) if authenticated, or the middleware passes assert response.status_code in (401, 422) # 401 if no auth, 422 if validation fails ``` Note: The `TestClient` does not set `client.host` by default; we work around by using `X-Forwarded-For` and adjusting middleware to trust that header in a test setting. Alternatively, we can directly set `request.client.host` in middleware logic. For simplicity, the middleware above uses `request.client.host` which in real operation contains the remote IP. In test, we may need to mock. We'll use a simpler approach: instead of checking `client.host`, check an environment variable `OPENAI_GATEWAY_ALLOW_EXTERNAL` (default false) to allow easy testing. The test will set that env var to `"true"` to simulate localhost. **Revised middleware logic:** ```python import os @app.middleware("http") async def restrict_openai_to_localhost(request: Request, call_next): if request.url.path.startswith("/openai"): if not os.environ.get("OPENAI_GATEWAY_ALLOW_EXTERNAL"): host = request.client.host if host not in ("127.0.0.1", "::1"): return JSONResponse(status_code=403, content={"detail": "Forbidden"}) return await call_next(request) ``` Then in tests, set environment variable to bypass restriction: ```python import os def test_localhost_allowed(monkeypatch): # Bypass restriction for this test (simulating localhost) monkeypatch.setenv("OPENAI_GATEWAY_ALLOW_EXTERNAL", "true") response = client.post(...) assert response.status_code in (401, 422) def test_external_rejected(): # Without env var, external IP should be rejected response = client.post(...) assert response.status_code == 403 ``` But to avoid environment pollution, we'll just test the middleware in isolation. Make the middleware check the environment variable; then unit tests can set it. ### Additional Consideration: Rate Limiter & Maintenance Mode No changes needed. The existing exemptions for `/openai` in rate limiter and maintenance mode are correct for internal use. Since the gateway will now only be reachable from localhost, those exemptions are safe. ### Verification Commands Ensure the test and lint commands given in the ticket are executable. Because earlier attempts failed due to pip's `--break-system-packages` error, the fix for that is to run commands inside a virtual environment or pass `PIP_REQUIRE_VIRTUALENV=false`. We will note that the environment must have a virtualenv active. --- ## Definition of Done - [ ] **Nginx config updated:** `nginx/nginx.conf.template` contains the new `location /openai/` block with `allow 127.0.0.1; allow ::1; deny all;`. - [ ] **Middleware added:** `main.py` includes the `restrict_openai_to_localhost` middleware with localhost check and env var bypass. - [ ] **Unit tests pass:** `make test-unit` (which includes the new test file) exits with code 0. - [ ] **Lint passes:** `ruff check .` exits with code 0 (no new linting issues). - [ ] **No regression:** Existing tests for the gateway (e.g. `tests/e2e/openai/...`) still pass (implicitly, as they run on localhost and will be permitted). The change is verifiable by: - Running the project’s built-in `make test-unit` and `ruff check .` commands. - Optionally, manually verifying with `curl http://localhost:10500/openai/v1/chat/completions` (should get 401, not 403) and `curl http://:10500/openai/v1/chat/completions` (should get connection refused or 403 at nginx level).