Skip to content

fix: apply trusted-host, body-size and optional auth checks to the privacy proxy - #4196

Open
pardeep-singh wants to merge 1 commit into
maziyarpanahi:masterfrom
pardeep-singh:pardeep/issue3898-privacy-proxy-inbound
Open

pardeep-singh wants to merge 1 commit into
maziyarpanahi:masterfrom
pardeep-singh:pardeep/issue3898-privacy-proxy-inbound

Conversation

@pardeep-singh

Copy link
Copy Markdown
Contributor

Description

Fixes #3898. openmed.service.privacy_proxy.create_app built a FastAPI app with no inbound checks. There was no trusted-host allowlist, no body-size bound and no authentication. With a synthetic transport that counts calls, master did this:

Request Master This PR
Host: rebound.attacker.example.test 200, transport called 400 bad_request, no call
20 MB message 200, transport called 413 request_size_exceeded, no call
5,000 messages 200, transport called 413 privacy_proxy_limit_exceeded, no call

So the documented loopback deployment (uvicorn --host 127.0.0.1) could be reached through DNS rebinding and could spend the operator's upstream model or credentials.

The proxy now reuses the REST service controls instead of adding its own:

  • ErrorEnvelopeTrustedHostMiddleware with the loopback default.
  • ServiceAuth, for opt-in API-key or JWT checks.
  • BoundedRequestBodyMiddleware with OperationalLimits.

Message count, total text characters and streaming duration are now bounded too.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Test addition/improvement

Changes Made

  • openmed/service/privacy_proxy/app.py:
    • create_app adds the shared middlewares in this order: trusted host, then auth (only when enabled), then the body bound. The two header checks reject a request before any body bytes are read.
    • New keyword arguments trusted_hosts, operational_limits, limits and auth_config. Each one left as None is read from the environment with a safe default.
    • PrivacyProxy.prepare checks the message count and the total characters of every text value it would redact. This runs before the extractor and the transport. The count walk is iterative, so deep nesting cannot exhaust the stack.
    • Streaming responses run under a wall-clock bound. An async transport is cancelled at the deadline and its generator is closed. A sync iterator is checked after each chunk, and a late chunk is dropped.
    • New PrivacyProxyLimitError (413 privacy_proxy_limit_exceeded, reasons message_count_exceeded and text_length_exceeded). New PrivacyProxyTimeoutError (privacy_proxy_timeout, reason stream_duration_exceeded), sent as an SSE error event with no [DONE] marker.
    • The resolved hosts, byte limit and auth settings are kept on app.state, so a deployment check can read them without sending a request.
  • New openmed/service/privacy_proxy/settings.py: the PrivacyProxyLimits dataclass, the OPENMED_PRIVACY_PROXY_* settings, and the proxy route scopes. Both completion routes need pii:write, the same scope as POST /privacy-gateway/complete.
  • openmed/service/privacy_proxy/__init__.py: exports PrivacyProxyLimits.
  • openmed/service/security_headers.py: parse_trusted_hosts takes an optional env_var so its error names the right setting. The default behavior is unchanged.
  • openmed/service/request_limits.py: request_limits_from_env takes an optional env_var, for the same reason. The default behavior is unchanged.
  • openmed/service/api_compatibility.py: registers the two new stable error codes.
  • docs/service/privacy-proxy.md: inbound protections, a settings table, auth setup, streaming timeout behavior and an error-code table.
  • CHANGELOG.md: Unreleased / Security entry.

Testing

New tests/unit/service/privacy_proxy/test_app_inbound_protections.py has 38 tests. Each one uses a synthetic extractor and a transport that records calls:

  • Untrusted hosts, including rebinding-style 127.0.0.1.<attacker> and localhost.<attacker> names, are rejected before parsing and before any extractor or transport call. The body is invalid JSON, so a 400 bad_request (not invalid_json) shows which check ran first.
  • Loopback hosts (127.0.0.1, localhost, [::1], with and without a port) still work. Env and argument overrides work. * and an empty allowlist are refused.
  • A 20 MB body is rejected by the default limit before parsing. A chunked body over the env limit is rejected as it arrives.
  • With auth configured, missing, wrong, malformed-JWT, wrong-scheme and under-scoped credentials are rejected on both routes before parsing. A valid API key and a valid HS256 JWT pass, and /health stays open. The shared OPENMED_SERVICE_AUTH_* settings turn it on. Proxy routes still need credentials with deny-by-default off. Auth is off by default.
  • Message-count and total-character bounds return privacy_proxy_limit_exceeded with no transport call. A request exactly at the limit passes.
  • Invalid settings fail app creation, and the error names the setting without echoing the value.
  • Streaming bound: a stalled async transport is cancelled and closed. A slow sync iterator stops between chunks. Neither emits [DONE].
  • Streaming restore of a placeholder split across chunks still works with auth and limits on.
  • Negative controls: a sentinel string is planted in the message text on every rejection path. Each test checks that the sentinel, the synthetic name, OPENMED_PHI and mapping appear in neither the response nor the captured logs (caplog at DEBUG).

Before the fix, the new module had 33 failed and 5 passed. The 5 that passed were the loopback and auth-off controls. After the fix, all 38 pass, and the existing proxy tests are unchanged.

PYTHONPATH=$PWD python -m pytest tests/unit/service/privacy_proxy -q
# before: 33 failed, 38 passed; after: 71 passed
PYTHONPATH=$PWD python -m pytest tests/unit/service -q
# 971 passed, 5 skipped
PYTHONPATH=$PWD python -m pytest tests/unit/release/test_changelog.py tests/unit/test_docs_publication.py tests/unit/test_pages_manifest.py tests/unit/test_docs_language_coherence.py tests/unit/test_no_telemetry.py tests/unit/test_docs_cookbook_links.py tests/unit/test_no_raw_text_logging.py tests/unit/test_troubleshooting_docs.py -q
# all passed
ruff check . ; ruff format --check .            # 0.15.22, clean
python scripts/security/check_effect_paths.py --check   # effect_paths_verified
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Documentation

  • I have updated the documentation accordingly
  • I have added docstrings to new functions/classes
  • I have updated the CHANGELOG.md

Code Quality

  • I ran ruff format and ruff check (0.15.22) across the repository
  • I have performed a self-review of my own code
  • My changes generate no new warnings

Dependencies

  • I have not added any new dependencies

Notes

  • Auth reuses the REST settings. The proxy reads OPENMED_SERVICE_AUTH_*, so one credential contract covers both listeners. It stays off unless OPENMED_SERVICE_AUTH_ENABLED=true. As a result, a shell that enables REST auth also enables it for the proxy. That fails closed. To use other scopes, pass an auth_config with your own route_scopes. OPENMED_SERVICE_AUTH_ROUTE_SCOPES cannot name proxy routes, because it only accepts REST routes.
  • Listener settings are proxy-specific. The trusted hosts and the byte limit use OPENMED_PRIVACY_PROXY_*, not the REST names. A REST allowlist for a public hostname should not lock the loopback proxy out.
  • Middleware order. Trusted host and auth run before the body bound, so an unauthenticated caller cannot make the proxy buffer a large body. The REST app puts its operational body bound outside auth. redaction_app puts trusted host outside its body bound. The proxy follows the "trusted host, auth, then body" order.
  • testserver is not trusted. The REST service does not trust it either. The existing proxy tests already use http://127.0.0.1.
  • Stream timeout status. The timeout fires after a 200 and some chunks may already be sent, so it arrives as an SSE error event. The exception's 504 status is never sent.
  • Non-streaming transport time is not bounded. The issue asks for a streaming bound only. A sync transport cannot be interrupted from the event loop anyway.
  • Production self-check. The issue asks to list the proxy as a declared surface for the production self-check. That self-check (Add a fail-closed production service security-profile self-check #3552, openmed/service/security_profile.py) is not on master yet. This PR keeps the resolved settings on app.state and documents them, so Add a fail-closed production service security-profile self-check #3552 can read them. It does not add a registry of its own.
  • Out of scope. Outbound destination policy for transports stays with Enforce explicit egress policy for remote inference, callbacks, and connectors #3581. Cross-site simple POSTs from a browser to a loopback port are not addressed here. Turning on auth blocks them.

Related Issues

Closes #3898
Related to #3552, #3581, #2294, #658, #3543

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Apply trusted-host, body-size and optional auth checks to the local privacy proxy

1 participant