diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f738d6..d930b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to FoundryGate should be documented here. The format is intentionally lightweight and human-readable. Group entries by release and focus on user-visible behavior, operational changes, and compatibility notes. +## Unreleased + +### Added + +- Added conservative response-security headers plus a dashboard CSP for the no-build operator UI +- Added explicit `security` config controls for JSON body size, upload size, and bounded routing-header values +- Added functional API coverage for dashboard headers, JSON request limits, upload limits, and sanitized routing-header behavior + ## v0.8.0 - 2026-03-15 ### Added diff --git a/README.md b/README.md index 996b587..30f07ad 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ FoundryGate is a local OpenAI-compatible router/proxy for OpenClaw and other cli - Robust fallback behavior: provider errors, timeouts, and connection failures fall through the configured fallback chain. - Useful observability: `/health` reports provider status, capability coverage, consecutive failures, last error, and average latency. - Hardened extension seam: request hooks are sanitized, can fail closed, and expose hook errors in dry-run and completion responses. +- Pre-1.0 hardening baseline: response headers are conservative, request headers are sanitized, and JSON/uploads are bounded by explicit security limits. - Safe database path handling: metrics use `FOUNDRYGATE_DB_PATH`, so the SQLite database does not need to live in the repo checkout. ## Who Is This For? @@ -199,6 +200,7 @@ OpenAI-compatible chat completions endpoint. - `model: "auto"` routes through FoundryGate - `model: ""` routes directly to that loaded provider +- request body size is bounded by `security.max_json_body_bytes` For non-streaming responses, FoundryGate also adds these response headers: @@ -246,6 +248,7 @@ OpenAI-compatible image editing endpoint. - `model: "auto"` selects the best loaded provider with `capabilities.image_editing: true` - `model: ""` routes directly to a loaded image-edit-capable provider - validates scalar fields such as `prompt`, `n`, and `size` before any provider call +- rejects uploads larger than `security.max_upload_bytes` - optional image-policy hints can be passed via form field `image_policy`, `metadata.image_policy`, or `X-FoundryGate-Image-Policy` ```bash @@ -491,6 +494,37 @@ The ranking logic intentionally prefers providers that both fit the request and | `GEMINI_BASE_URL` | Overrides the Gemini base URL | Optional | | `OPENROUTER_BASE_URL` | Overrides the OpenRouter base URL | Optional | +### Security Settings + +The runtime also exposes a small `security` block in `config.yaml` for conservative pre-`v1.0` hardening defaults. + +Supported fields: + +- `response_headers` +- `cache_control` +- `max_json_body_bytes` +- `max_upload_bytes` +- `max_header_value_chars` + +Example: + +```yaml +security: + response_headers: true + cache_control: "no-store" + max_json_body_bytes: 1048576 + max_upload_bytes: 10485760 + max_header_value_chars: 160 +``` + +What the current runtime does with it: + +- adds conservative response headers such as `X-Content-Type-Options`, `X-Frame-Options`, and `Referrer-Policy` +- sends a dashboard CSP that keeps the no-build UI self-contained +- rejects oversized JSON requests before route resolution +- rejects oversized image uploads before any provider call +- bounds operator- and routing-related header values before they reach metrics, traces, or policy surfaces + ### Additional Provider Key Variables Referenced In The Stock Config The stock `config.yaml` ships many commented provider stanzas. If you uncomment one, its `api_key` field expects one of these variables: diff --git a/RELEASES.md b/RELEASES.md index 354254c..9c1c53a 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -15,6 +15,7 @@ This repo does not require a heavy release process. Use lightweight tags plus Gi 7. Use the changelog entry as the release notes, then add any short upgrade notes if needed. 8. Confirm that README plus the relevant docs pages still match the shipped runtime behavior. 9. If packaging or Docker changed shortly before the release, run the publish dry run first. +10. For hardening-heavy releases, keep the API functional tests green alongside unit and config coverage. ## Example @@ -57,6 +58,7 @@ The repo also includes [publish-dry-run](./.github/workflows/publish-dry-run.yml - `v0.6.0` establishes the modality-expansion baseline: image route previews, provider capability coverage, shared image request validation, and image policy presets. - `v0.7.0` establishes the operations-polish baseline: update alerts, operator events, rollout guardrails, scoped update checks, maintenance windows, and post-update verification hints. - `v0.8.0` establishes the onboarding baseline: repeatable provider/client rollout helpers, starter templates, delegated-traffic examples, env validation, and shareable onboarding reports. +- `v0.9.0` is the pre-`v1.0` hardening baseline: conservative response headers, bounded request surfaces, stronger functional API coverage, and a full documentation pass over operator-facing behavior. ## Planned Publishing Path diff --git a/SECURITY.md b/SECURITY.md index a313c7b..25a9a31 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,3 +45,6 @@ To reduce risk in deployments: - avoid committing `.env`, database files, SQLite files, logs, or SSH material - run with the provided `systemd` hardening or an equivalent container/runtime policy - keep provider API keys scoped to the minimum set of enabled providers +- keep the default response-security headers enabled unless you have an explicit reverse-proxy reason not to +- tune `security.max_json_body_bytes` and `security.max_upload_bytes` to the smallest values that still fit your workloads +- treat `x-foundrygate-*` and `x-openclaw-*` headers as trusted only at the edge you control diff --git a/config.yaml b/config.yaml index 3caf9ea..c219840 100644 --- a/config.yaml +++ b/config.yaml @@ -12,6 +12,13 @@ server: port: 8090 log_level: "info" +security: + response_headers: true + cache_control: "no-store" + max_json_body_bytes: 1048576 + max_upload_bytes: 10485760 + max_header_value_chars: 160 + # ── Provider Definitions ─────────────────────────────────────────────────── # # Fields: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index afaa2ce..95ccc52 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,6 +29,7 @@ The core handles: - route selection - fallback order - timeout and failure handling +- request-size and upload-size guardrails - response metadata - metrics and traces @@ -86,6 +87,8 @@ This is enough to support: Request hooks sit beside these caller-aware signals as a narrow extension seam. They can add sanitized request-level hints or profile overrides without giving arbitrary code the ability to mutate the full routing surface. +The pre-`v1.0` hardening baseline also treats caller-controlled headers as bounded inputs. Relevant routing and operator headers are normalized before they influence traces, client tags, or rollout decisions. + ## Operational surface The main operational endpoints are: @@ -109,6 +112,8 @@ The main operational endpoints are: `/api/stats`, `/api/recent`, and `/api/traces` can now be filtered by provider, client profile, client tag, layer, and success state. `/api/operator-events` captures operator-side update checks and helper-driven apply attempts. The dashboard is a thin UI over those same filtered endpoints and persists its active filters in the URL so operators can share one filtered view. +The operational surface now also applies conservative response headers by default. The no-build dashboard ships with a restrictive CSP and frame denial, while JSON and multipart request paths use bounded payload limits so obvious oversize failures are rejected before provider calls. + ## Design target The longer-term design target is to outperform simpler router designs by making routing multi-dimensional instead of mostly keyword- or model-name-driven. diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md index 6baab70..e5e157d 100644 --- a/docs/FOUNDRYGATE-ROADMAP.md +++ b/docs/FOUNDRYGATE-ROADMAP.md @@ -19,7 +19,7 @@ The foundation that used to be the near-term buildout is largely in place: This roadmap now shifts from "rename and foundation" to "deepen the gateway plane without bloating it". -`v0.8.x` is the current release line: many-provider and many-client onboarding is being tightened with validation helpers, starter templates, delegated-traffic examples, and shareable onboarding output on top of the already-shipped routing, modality, and ops foundation. +`v0.9.x` is the current release line: the focus now shifts to pre-`v1.0` hardening across request boundaries, functional API coverage, and a full documentation pass on the already-shipped routing, modality, onboarding, and ops foundation. ## Big Picture @@ -253,6 +253,14 @@ Primary goals: This release line should leave `v1.0.0` focused on stability and security gates, not backlog cleanup. +Current `v0.9.x` baseline is aimed at: + +- conservative response headers and dashboard CSP defaults +- explicit JSON and multipart size guardrails +- bounded routing and operator header handling +- broader functional API tests around dashboard, routing, and upload surfaces +- documentation updates that make the hardened defaults visible to operators + ### `v1.0.0`: stable gateway baseline Primary goals: diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 81bcd69..cf9ff2e 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -36,6 +36,8 @@ For a smaller starter snippet without the full alias block, use [examples/opencl For delegated or many-agent traffic, start from [examples/openclaw-delegated-request.json](./examples/openclaw-delegated-request.json) and keep `x-openclaw-source` stable across sub-agents so traces stay attributable. +Keep delegated/client headers short and stable. The runtime now bounds routing-header values before they reach traces, metrics, and rollout logic. + ## n8n n8n can use FoundryGate as a stable local model gateway. @@ -93,6 +95,8 @@ export OPENAI_API_KEY=local For a reusable shell starter, use [examples/cli-foundrygate-env.sh](./examples/cli-foundrygate-env.sh). +As with other clients, prefer token-like client tags over long free-form values so the bounded header surface remains readable in traces and operator views. + ## AI-native app clients For future app-specific clients, keep the same OpenAI-compatible base URL and add one stable app header before creating multiple custom profiles. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index a861aba..a1bea5c 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -99,6 +99,8 @@ Examples: - `X-FoundryGate-Client: n8n` - `X-FoundryGate-Client: codex` +Keep these tags short and stable. The runtime now bounds routing-header values before they reach traces, client matrices, and rollout decisions. + ### 3. Apply a preset or custom profile Start with: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 517fab1..f3ad392 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -51,6 +51,28 @@ request_hooks: on_error: fail ``` +## Request is rejected as too large + +FoundryGate now enforces explicit request-size limits before provider calls. + +Check: + +- `security.max_json_body_bytes` for JSON endpoints such as `/api/route` and `/v1/chat/completions` +- `security.max_upload_bytes` for multipart uploads such as `/v1/images/edits` + +Typical symptoms: + +- HTTP `413` +- response type `payload_too_large` + +Example: + +```yaml +security: + max_json_body_bytes: 1048576 + max_upload_bytes: 10485760 +``` + ## Local worker stays unhealthy For `contract: local-worker`, FoundryGate probes `GET /models`. diff --git a/foundrygate/config.py b/foundrygate/config.py index d16c307..c83acf5 100644 --- a/foundrygate/config.py +++ b/foundrygate/config.py @@ -1055,6 +1055,40 @@ def _normalize_auto_update(data: dict[str, Any]) -> dict[str, Any]: return normalized +def _normalize_security(data: dict[str, Any]) -> dict[str, Any]: + """Validate and normalize runtime security settings.""" + raw = data.get("security") or {} + if raw in (None, ""): + raw = {} + if not isinstance(raw, dict): + raise ConfigError("'security' must be a mapping") + + normalized = dict(data) + normalized["security"] = { + "response_headers": bool(raw.get("response_headers", True)), + "cache_control": str(raw.get("cache_control", "no-store")).strip() or "no-store", + "max_json_body_bytes": _normalize_positive_int( + raw.get("max_json_body_bytes", 1_048_576), + field_name="security.max_json_body_bytes", + provider_name="runtime", + ) + or 1_048_576, + "max_upload_bytes": _normalize_positive_int( + raw.get("max_upload_bytes", 10_485_760), + field_name="security.max_upload_bytes", + provider_name="runtime", + ) + or 10_485_760, + "max_header_value_chars": _normalize_positive_int( + raw.get("max_header_value_chars", 160), + field_name="security.max_header_value_chars", + provider_name="runtime", + ) + or 160, + } + return normalized + + class Config: """Holds the parsed and expanded configuration.""" @@ -1165,6 +1199,19 @@ def auto_update(self) -> dict: }, ) + @property + def security(self) -> dict: + return self._data.get( + "security", + { + "response_headers": True, + "cache_control": "no-store", + "max_json_body_bytes": 1_048_576, + "max_upload_bytes": 10_485_760, + "max_header_value_chars": 160, + }, + ) + def provider(self, name: str) -> dict | None: return self.providers.get(name) @@ -1190,11 +1237,13 @@ def load_config(path: str | Path | None = None) -> Config: with path.open() as f: raw = yaml.safe_load(f) - expanded = _normalize_auto_update( - _normalize_update_check( - _normalize_request_hooks( - _normalize_client_profiles( - _normalize_routing_policies(_normalize_providers(_walk_expand(raw))) + expanded = _normalize_security( + _normalize_auto_update( + _normalize_update_check( + _normalize_request_hooks( + _normalize_client_profiles( + _normalize_routing_policies(_normalize_providers(_walk_expand(raw))) + ) ) ) ) diff --git a/foundrygate/main.py b/foundrygate/main.py index 2fcadc2..8535348 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -8,7 +8,9 @@ from __future__ import annotations +import json import logging +import re import time from contextlib import asynccontextmanager from typing import Any @@ -30,6 +32,7 @@ ) logger = logging.getLogger("foundrygate") +_SAFE_TOKEN_RE = re.compile(r"[^a-z0-9._-]+") # ── Globals (initialized in lifespan) ────────────────────────── _config: Config @@ -39,6 +42,10 @@ _update_checker: UpdateChecker +class PayloadTooLargeError(ValueError): + """Raised when one request or upload exceeds configured size limits.""" + + def _client_error_response(message: str, *, error_type: str, status_code: int) -> JSONResponse: """Return a client-facing JSON error without exposing internal exception details.""" return JSONResponse({"error": message, "type": error_type}, status_code=status_code) @@ -61,6 +68,31 @@ def _invalid_request_response(message: str, *, exc: Exception | None = None) -> return _client_error_response(message, error_type="invalid_request_error", status_code=400) +def _payload_too_large_response(message: str, *, exc: Exception | None = None) -> JSONResponse: + """Return a sanitized payload-too-large response.""" + if exc is not None: + logger.info("Payload rejected as too large: %s", exc) + return _client_error_response(message, error_type="payload_too_large", status_code=413) + + +def _sanitize_header_value(value: Any, *, max_chars: int | None = None) -> str: + """Normalize a user-controlled header value to a bounded printable string.""" + text = str(value or "").strip() + cleaned = "".join(ch for ch in text if ch.isprintable() and ch not in "\r\n") + if max_chars and len(cleaned) > max_chars: + cleaned = cleaned[:max_chars] + return cleaned + + +def _sanitize_token(value: Any, *, default: str, max_chars: int | None = None) -> str: + """Normalize one token-like value for metrics, tracing, and policy surfaces.""" + cleaned = _sanitize_header_value(value, max_chars=max_chars).lower() + if not cleaned: + return default + normalized = _SAFE_TOKEN_RE.sub("-", cleaned).strip("-") + return normalized or default + + async def _refresh_local_worker_probes(force: bool = False) -> None: """Refresh local-worker health state when probes are due.""" timeout_seconds = float(_config.health.get("timeout_seconds", 10)) @@ -92,13 +124,27 @@ async def _refresh_local_worker_probes(force: bool = False) -> None: def _collect_routing_headers(request: Request) -> dict[str, str]: """Return the request headers that are relevant for routing decisions.""" prefixes = ("x-openclaw", "x-foundrygate") - return {k.lower(): v for k, v in request.headers.items() if k.lower().startswith(prefixes)} + max_chars = int((_config.security or {}).get("max_header_value_chars", 160)) + return { + k.lower(): _sanitize_header_value(v, max_chars=max_chars) + for k, v in request.headers.items() + if k.lower().startswith(prefixes) + } def _collect_operator_context(headers: dict[str, str]) -> tuple[str, str]: """Return operator action and client tag hints from request headers.""" - action = headers.get("x-foundrygate-operator-action", "update-check").strip().lower() - client_tag = headers.get("x-foundrygate-client", "operator").strip().lower() or "operator" + max_chars = int((_config.security or {}).get("max_header_value_chars", 160)) + action = _sanitize_token( + headers.get("x-foundrygate-operator-action", "update-check"), + default="update-check", + max_chars=max_chars, + ) + client_tag = _sanitize_token( + headers.get("x-foundrygate-client", "operator"), + default="operator", + max_chars=max_chars, + ) return action, client_tag @@ -147,7 +193,11 @@ def _resolve_client_profile( def _resolve_client_tag(headers: dict[str, str], client_profile: str) -> str: """Return a stable client tag for metrics and trace grouping.""" if headers.get("x-foundrygate-client"): - return headers["x-foundrygate-client"].strip().lower() + return _sanitize_token( + headers["x-foundrygate-client"], + default=client_profile, + max_chars=int((_config.security or {}).get("max_header_value_chars", 160)), + ) if headers.get("x-openclaw-source"): return "openclaw" return client_profile @@ -434,6 +484,23 @@ def _collect_image_request_fields(body: dict[str, Any]) -> dict[str, Any]: return fields +async def _read_json_body(request: Request, *, operation: str) -> dict[str, Any]: + """Read and size-check one JSON request body.""" + raw = await request.body() + max_bytes = int((_config.security or {}).get("max_json_body_bytes", 1_048_576)) + if len(raw) > max_bytes: + raise PayloadTooLargeError( + f"{operation} body exceeded security.max_json_body_bytes ({len(raw)} > {max_bytes})" + ) + try: + parsed = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("Invalid JSON body") from exc + if not isinstance(parsed, dict): + raise ValueError("JSON body must be an object") + return parsed + + def _parse_optional_positive_int(value: Any, *, field_name: str) -> int | None: """Return one optional positive integer field from request data.""" if value in (None, ""): @@ -543,7 +610,7 @@ def _extract_image_edit_request_fields(form_data: dict[str, Any]) -> dict[str, A async def _read_uploaded_file( - value: Any, *, field_name: str, required: bool + value: Any, *, field_name: str, required: bool, max_bytes: int ) -> dict[str, Any] | None: """Read one uploaded file into a normalized payload.""" if value is None: @@ -557,6 +624,10 @@ async def _read_uploaded_file( content = await value.read() if not content: raise ValueError(f"Uploaded file '{field_name}' must not be empty") + if len(content) > max_bytes: + raise PayloadTooLargeError( + f"Uploaded file '{field_name}' exceeded security.max_upload_bytes" + ) return { "filename": value.filename or field_name, @@ -691,6 +762,30 @@ async def lifespan(app: FastAPI): ) +@app.middleware("http") +async def apply_security_headers(request: Request, call_next): + """Attach conservative security headers to API and dashboard responses.""" + response = await call_next(request) + security = _config.security if "_config" in globals() else {} + if not security.get("response_headers", True): + return response + + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + response.headers.setdefault("Cache-Control", str(security.get("cache_control", "no-store"))) + if request.url.path == "/dashboard": + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; style-src 'self' 'unsafe-inline'; " + "script-src 'self' 'unsafe-inline'; img-src 'self' data:; " + "connect-src 'self'; object-src 'none'; base-uri 'none'; " + "frame-ancestors 'none'; form-action 'self'", + ) + return response + + # ── Health / Info endpoints ──────────────────────────────────── @@ -910,9 +1005,11 @@ async def operator_events( async def preview_route(request: Request): """Dry-run one routing decision without sending a provider request.""" try: - body = await request.json() - except Exception: - return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + body = await _read_json_body(request, operation="Route preview") + except PayloadTooLargeError as exc: + return _payload_too_large_response("Route preview request is too large", exc=exc) + except ValueError as exc: + return _invalid_request_response("Invalid route preview request", exc=exc) headers = _collect_routing_headers(request) try: @@ -952,9 +1049,11 @@ async def preview_route(request: Request): async def preview_image_route(request: Request): """Dry-run one image routing decision without sending a provider request.""" try: - body = await request.json() - except Exception: - return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + body = await _read_json_body(request, operation="Image route preview") + except PayloadTooLargeError as exc: + return _payload_too_large_response("Image route preview request is too large", exc=exc) + except ValueError as exc: + return _invalid_request_response("Invalid image route preview request", exc=exc) capability = str(body.get("capability") or "image_generation").strip().lower() if capability not in {"image_generation", "image_editing"}: @@ -1004,9 +1103,11 @@ async def preview_image_route(request: Request): async def image_generations(request: Request): """OpenAI-compatible image generation endpoint.""" try: - body = await request.json() - except Exception: - return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + body = await _read_json_body(request, operation="Image generation") + except PayloadTooLargeError as exc: + return _payload_too_large_response("Image generation request is too large", exc=exc) + except ValueError as exc: + return _invalid_request_response("Invalid image generation request", exc=exc) try: body = _normalize_image_request_body(body, capability="image_generation") except ValueError as exc: @@ -1111,8 +1212,21 @@ async def image_edits(request: Request): form = await request.form() form_data = dict(form.multi_items()) body = _extract_image_edit_request_fields(form_data) - image = await _read_uploaded_file(form_data.get("image"), field_name="image", required=True) - mask = await _read_uploaded_file(form_data.get("mask"), field_name="mask", required=False) + max_upload_bytes = int((_config.security or {}).get("max_upload_bytes", 10_485_760)) + image = await _read_uploaded_file( + form_data.get("image"), + field_name="image", + required=True, + max_bytes=max_upload_bytes, + ) + mask = await _read_uploaded_file( + form_data.get("mask"), + field_name="mask", + required=False, + max_bytes=max_upload_bytes, + ) + except PayloadTooLargeError as exc: + return _payload_too_large_response("Image editing upload is too large", exc=exc) except ValueError as exc: return _invalid_request_response("Invalid image editing request", exc=exc) except Exception as exc: @@ -1233,9 +1347,11 @@ async def chat_completions(request: Request): If model matches a provider name: routes directly to that provider. """ try: - body = await request.json() - except Exception: - return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + body = await _read_json_body(request, operation="Chat completions") + except PayloadTooLargeError as exc: + return _payload_too_large_response("Chat completion request is too large", exc=exc) + except ValueError as exc: + return _invalid_request_response("Invalid chat completion request", exc=exc) headers = _collect_routing_headers(request) try: diff --git a/tests/test_api_hardening.py b/tests/test_api_hardening.py new file mode 100644 index 0000000..a7e3b21 --- /dev/null +++ b/tests/test_api_hardening.py @@ -0,0 +1,226 @@ +"""Functional API tests for v0.9 hardening surfaces.""" + +from __future__ import annotations + +import importlib +import sys +import types +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest + +sys.modules.pop("httpx", None) +import httpx # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +sys.modules["httpx"] = httpx + +sys.modules.pop("foundrygate.providers", None) +sys.modules.pop("foundrygate.updates", None) +sys.modules.pop("foundrygate.main", None) + +import foundrygate.main as main_module # noqa: E402 +from foundrygate.config import load_config # noqa: E402 +from foundrygate.router import Router # noqa: E402 + +importlib.reload(main_module) + + +def _write_config(tmp_path: Path, body: str) -> Path: + path = tmp_path / "config.yaml" + path.write_text(body) + return path + + +class _ProviderStub: + def __init__(self): + self.name = "cloud-default" + self.model = "chat-model" + self.backend_type = "openai-compat" + self.contract = "generic" + self.tier = "default" + self.capabilities = {"chat": True, "local": False, "cloud": True, "network_zone": "public"} + self.context_window = 128000 + self.limits = {"max_input_tokens": 128000, "max_output_tokens": 4096} + self.cache = {"mode": "none", "read_discount": False} + self.image = {} + self.health = types.SimpleNamespace( + healthy=True, + last_check=1.0, + avg_latency_ms=12.0, + last_error="", + to_dict=lambda: { + "name": "cloud-default", + "healthy": True, + "consecutive_failures": 0, + "avg_latency_ms": 12.0, + "last_error": "", + }, + ) + + async def close(self): + return None + + async def complete(self, *_args, **_kwargs): + return { + "id": "chatcmpl-123", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "ok"}, + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, + "_foundrygate": {"latency_ms": 12}, + } + + +class _MetricsStub: + def log_request(self, **_kwargs): + return None + + def get_totals(self, **_kwargs): + return {} + + def get_provider_summary(self, **_kwargs): + return [] + + def get_modality_breakdown(self, **_kwargs): + return [] + + def get_routing_breakdown(self, **_kwargs): + return [] + + def get_client_breakdown(self, **_kwargs): + return [] + + def get_operator_breakdown(self, **_kwargs): + return [] + + def get_hourly_series(self, *_args, **_kwargs): + return [] + + def get_daily_totals(self, *_args, **_kwargs): + return [] + + def get_recent(self, *_args, **_kwargs): + return [] + + def get_operator_events(self, *_args, **_kwargs): + return [] + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + cfg = load_config( + _write_config( + tmp_path, + """ +server: + host: "127.0.0.1" + port: 8090 + log_level: "info" +security: + max_json_body_bytes: 256 + max_upload_bytes: 8 + max_header_value_chars: 12 +providers: + cloud-default: + backend: openai-compat + base_url: "https://api.example.com/v1" + api_key: "secret" + model: "chat-model" +fallback_chain: + - cloud-default +metrics: + enabled: false +""", + ) + ) + + @asynccontextmanager + async def _noop_lifespan(_app): + yield + + monkeypatch.setattr(main_module, "_config", cfg, raising=False) + monkeypatch.setattr(main_module, "_router", Router(cfg), raising=False) + monkeypatch.setattr( + main_module, + "_providers", + {"cloud-default": _ProviderStub()}, + raising=False, + ) + monkeypatch.setattr(main_module, "_metrics", _MetricsStub(), raising=False) + monkeypatch.setattr(main_module.app.router, "lifespan_context", _noop_lifespan, raising=False) + + with TestClient(main_module.app) as client: + yield client + + +def test_dashboard_sets_security_headers(api_client): + response = api_client.get("/dashboard") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["referrer-policy"] == "no-referrer" + assert "frame-ancestors 'none'" in response.headers["content-security-policy"] + + +def test_route_preview_rejects_large_json_payload(api_client): + response = api_client.post( + "/api/route", + json={ + "model": "auto", + "messages": [{"role": "user", "content": "x" * 400}], + }, + ) + + assert response.status_code == 413 + assert response.json()["type"] == "payload_too_large" + + +def test_route_preview_sanitizes_header_values(api_client): + response = api_client.post( + "/api/route", + headers={"X-FoundryGate-Client": "CLI-AGENT-WITH-VERY-LONG-NAME"}, + json={ + "model": "auto", + "messages": [{"role": "user", "content": "route this safely"}], + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["routing_headers"]["x-foundrygate-client"] == "CLI-AGENT-WI" + assert body["client_tag"] == "cli-agent-wi" + + +def test_image_edit_rejects_large_upload(api_client): + response = api_client.post( + "/v1/images/edits", + data={"model": "auto", "prompt": "remove background"}, + files={"image": ("input.png", b"0123456789", "image/png")}, + ) + + assert response.status_code == 413 + assert response.json()["type"] == "payload_too_large" + + +def test_chat_completions_returns_security_headers(api_client): + response = api_client.post( + "/v1/chat/completions", + json={ + "model": "auto", + "messages": [{"role": "user", "content": "say hi"}], + }, + ) + + assert response.status_code == 200 + assert response.headers["x-foundrygate-provider"] == "cloud-default" + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-content-type-options"] == "nosniff" diff --git a/tests/test_config.py b/tests/test_config.py index 5382067..ddcf254 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,9 @@ from pathlib import Path -from foundrygate.config import _safe_db_path, load_config +import pytest + +from foundrygate.config import ConfigError, _safe_db_path, load_config # ── _safe_db_path unit tests ────────────────────────────────────────────────── @@ -112,3 +114,39 @@ def test_auto_update_defaults_are_exposed(): def test_update_check_defaults_include_stable_release_channel(): cfg = load_config(Path(__file__).parent.parent / "config.yaml") assert cfg.update_check["release_channel"] == "stable" + + +def test_security_defaults_are_exposed(): + cfg = load_config(Path(__file__).parent.parent / "config.yaml") + assert cfg.security == { + "response_headers": True, + "cache_control": "no-store", + "max_json_body_bytes": 1048576, + "max_upload_bytes": 10485760, + "max_header_value_chars": 160, + } + + +def test_security_rejects_invalid_limit_values(tmp_path): + path = tmp_path / "config.yaml" + path.write_text( + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + cloud-default: + backend: openai-compat + base_url: "https://api.example.com/v1" + api_key: "secret" + model: "chat-model" +security: + max_json_body_bytes: 0 +fallback_chain: [] +metrics: + enabled: false +""" + ) + + with pytest.raises(ConfigError, match="security.max_json_body_bytes"): + load_config(path)