Skip to content

Commit dae62f5

Browse files
committed
review: drip-196 openai/codex#20319 + BerriAI/litellm#26856, #26840
Codex allow_managed_hooks_only requirement (drops user/project/session hooks at discovery) plus two litellm cross-trust fixes: /v1/models access-group string leak (6-marker resolvability filter) and MCP OAuth root endpoint resolution tightening (client_ip threading + parse_qsl/urlunparse callback).
1 parent b84ddc9 commit dae62f5

3 files changed

Lines changed: 77 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# BerriAI/litellm PR #26840 — chore(mcp): tighten OAuth root endpoint resolution
2+
3+
- PR: https://github.com/BerriAI/litellm/pull/26840
4+
- Head SHA: `8af0544ef0276ea273b4b48b406576f11be4bb2b`
5+
- Files touched: `litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py` (+26/-11), `tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py` (+259/-5) (+285/-16, 2 files)
6+
7+
## Specific citations
8+
9+
- The bug class: `_resolve_oauth2_server_for_root_endpoints()` was being called *without* `client_ip` at five sites (root `/authorize` `:587`, root `/token` `:649`, `/.well-known/oauth-protected-resource` `:737`, `/.well-known/oauth-authorization-server` `:855`, root `/register` `:1011`). Without `client_ip`, the helper bypasses the same visibility/IP-allowlist gate that explicit-server-name lookups already apply via `global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)`. Result: an external client could trigger the root fallback and have their request resolved to an MCP server that's marked `available_on_public_internet=False`.
10+
- The fix at all five call sites is `_resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)` — minimal, and it threads through the existing `IPAddressUtils.get_mcp_client_ip(request)` that's already computed elsewhere on those code paths. Note `:737` and `:855` had to *hoist* the `client_ip = IPAddressUtils.get_mcp_client_ip(request)` computation up out of the inner `if mcp_server_name:` block so it's available at the resolve site — this is a small, correct refactor.
11+
- The companion fix is the callback-redirect tightening at `:670-680`. Old code used `state_data["base_url"]` directly: `validate_loopback_redirect_uri(base_url)` then `f"{base_url}?{urlencode(params)}"`. New code uses a new helper `_get_validated_client_redirect_uri(state_data)` at `:134-140` that prefers `state_data.get("client_redirect_uri")` over the legacy `base_url`, and `_append_query_params(url, params)` at `:143-147` that uses `urlparse → parse_qsl(keep_blank_values=True) → urlencode → urlunparse` instead of f-string concatenation. The latter fixes a real bug: if the client's redirect URI already had a query string (e.g. `http://localhost:62646/callback?source=mcp`), the f-string would produce `http://localhost:62646/callback?source=mcp?code=...&state=...` (two `?`), which most OAuth clients would reject or misparse.
12+
- New regression coverage adds three parallel "private server is not auto-resolved for external client" tests at `:1354-1393, :1460-1503, :1541-1582` covering `/authorize`, `/token`, and `/register` respectively. Each test pattern is identical: register an OAuth2 server with `available_on_public_internet=False`, mock `IPAddressUtils.get_mcp_client_ip` to return `"198.51.100.10"` (a TEST-NET-2 documentation IP — correct choice), call the endpoint with `mcp_server_name=None`, expect `HTTPException` with `status_code=404`. The 404 (not 401/403) is the right response — leaking "this resource exists but you can't access it" via 403 would itself be a small leak; 404 is correct.
13+
- The `_create_oauth2_server` test helper at `:1240` gains an `available_on_public_internet=True` default parameter — needed because the existing fixture didn't expose the field that the new tests need to flip to `False`.
14+
- Two pre-existing tests at `:633` and `:934` had `response = await token_endpoint(...)` lines refactored to drop the unused `response` binding — small cleanup, unrelated to the security fix but in-scope as the same file is being touched.
15+
16+
## Verdict: merge-as-is
17+
18+
## Rationale
19+
20+
Two real defects fixed, both with proper regression coverage. The IP-visibility threading at the five resolver call sites closes a real cross-trust-boundary hole on MCP root endpoints (the same class as drip-194 #26849's SSRF guard on OAuth metadata-discovery follow-up fetches and drip-194 #26851's env-callback-ref blocklist — this is the third in a series of MCP-surface trust-boundary tightenings from `stuxf`). The redirect-URI fix at `:670-680` correctly prefers the explicit `client_redirect_uri` field over the legacy `base_url` and properly merges query params via `parse_qsl`/`urlencode`/`urlunparse` instead of f-string concatenation, fixing the second-order bug where any client that initiates OAuth from a URL with an existing query string would get a malformed callback.
21+
22+
The 404-not-403 choice is correct (don't confirm the existence of a hidden server). The use of TEST-NET-2 (`198.51.100.0/24`) for the mock external client IP in the regression is the right RFC 5737 choice for "this is documentation/test traffic". The pre-existing visibility-aware path through `get_mcp_server_by_name(..., client_ip=client_ip)` is what was being bypassed; the fix simply threads the same `client_ip` through the root-fallback resolver, so the security model is now uniform across the explicit-name and root-fallback paths.
23+
24+
One small observation worth a follow-up but not blocking: the `_get_validated_client_redirect_uri` helper at `:134-140` falls back from `client_redirect_uri` to `base_url` for backward compat with states minted before the field was added. This is correct for migration but should have a deprecation comment explaining when the `base_url` fallback can be removed (e.g. "after state TTL of N days post-deploy"). As-is it'll live forever.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# BerriAI/litellm PR #26856 — fix models access group leak into /models - issue #25550
2+
3+
- PR: https://github.com/BerriAI/litellm/pull/26856
4+
- Head SHA: `0b3de67edb7b2767a7a8c5dbec102d63bdbfd362`
5+
- Files touched: `litellm/proxy/auth/model_checks.py` (+50/-0), `tests/test_litellm/proxy/auth/test_model_checks.py` (+171/-0) (+221/-0, 2 files)
6+
7+
## Specific citations
8+
9+
- The bug class: a virtual key created with `models=["foo"]` against an access group "foo" that is later removed (or never defined) keeps the literal string `"foo"` in `key.models`. The current `_get_models_from_access_groups` only strips entries that ARE in the active `model_access_groups` dict, so the stale string falls through and `/v1/models` returns it as if it were a model id. The PR's reproducer in the description (curl `/v1/models` returning `{"id": "team-sales-api", "object": "model", "created": 1677610602, "owned_by": "openai"}`) is a real production-affecting leak.
10+
- New helper `_is_unresolvable_model_identifier` at `model_checks.py:67-99` is the right shape for the gate. Six positive markers (`proxy_model_list` membership, `model_access_groups` key, `litellm.model_list_set` membership, contains `/`, contains `*`, `ft:` prefix) and the default is "unresolvable → drop". The `proxy_model_list`-membership branch is documented as the most-common preservation path for custom enterprise aliases (per nit-locked test `test_get_complete_model_list_keeps_custom_proxy_alias`).
11+
- The filter is gated to `if key_models or team_models:` at `model_checks.py:248` — the proxy-admin path (no key/team models, sourced from authoritative `proxy_model_list`) is *not* filtered. This is the right scope: don't rewrite the admin view, only the per-key/team view where the leak originates.
12+
- Test coverage at `tests/test_litellm/proxy/auth/test_model_checks.py:230-403` is parameterized across the eight relevant axes:
13+
- `:230` `_drops_stale_access_group_string` (key_models path) — the headline regression
14+
- `:251` `_drops_stale_access_group_string_team` (team_models path) — locks both entry points
15+
- `:267` `_keeps_active_access_group_expansion` — negative regression: active groups must still expand
16+
- `:300` `_keeps_provider_qualified_string``bedrock/very-new-model` survives
17+
- `:321` `_keeps_known_base_model` — known LiteLLM model id survives even when not configured on the proxy
18+
- `:339` `_keeps_custom_proxy_alias` — custom proxy alias `internal-assistant` (in `proxy_model_list` but NOT in `litellm.model_list_set`) survives — explicitly locks the proxy_model_list-membership branch
19+
- `:362` `_keeps_finetune_id``ft:gpt-4o:my-org:custom-suffix:abc123` survives
20+
- `:380` `_does_not_filter_proxy_admin_path` — when both key/team are empty, `arbitrary-named-model` from `proxy_model_list` passes through untouched (locks the gating predicate at `:248`)
21+
- The PR body shows BEFORE / AFTER curl output against `ghcr.io/berriai/litellm:v1.82.3` vs from-source build, with the BEFORE response containing the leaked `team-sales-api` and AFTER returning `{"data": [], "object": "list"}`. This is exactly the right-shaped proof-of-fix for an information-leak class bug.
22+
23+
## Verdict: merge-as-is
24+
25+
## Rationale
26+
27+
Minimal-blast-radius patch (+50 LOC of source, +171 LOC of regression covering all 8 axes including the proxy-admin gating predicate). The helper is well-documented with the per-branch rationale inline in the docstring, the filter site is narrowly gated to only the key/team path where the leak originates, and the regression locks both the "drop stale" path and the five "keep legitimate" paths plus the "don't filter admin" path. The custom-proxy-alias test at `:339` is particularly well-chosen — it locks the `proxy_model_list`-membership branch which would otherwise be the most likely false-positive class (enterprise deployments routinely add aliases not in `litellm.model_list_set`).
28+
29+
The only stylistic observation (not blocking): the helper inverts the natural reading direction — `_is_unresolvable_model_identifier` returns `True` for "drop me", and the filter at `:251` is `if not _is_unresolvable...`. The negation chain is correct but a sibling-named `_is_resolvable_model_identifier` returning the inverse would let the filter read as `if _is_resolvable_model_identifier(m)` (no `not`). Pure cosmetics; the current shape is the standard way "filter predicate" helpers are written and the inline docstring at `:67-87` makes the semantics obvious.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# openai/codex PR #20319 — Add managed-hooks-only hook requirement
2+
3+
- PR: https://github.com/openai/codex/pull/20319
4+
- Head SHA: `7216b2514dd7dfba0c4a80795ae8872ea0034e90`
5+
- Files touched: 17 files across `app-server-protocol/schema/{json,typescript}` (regenerated), `app-server-protocol/src/protocol/v2.rs`, `app-server/{README.md,src/config_api.rs}`, `cloud-requirements/src/lib.rs` (test fixtures), `config/src/{config_requirements.rs,state.rs}`, `core/src/config/{config_loader_tests.rs,config_tests.rs,mod.rs}`, `hooks/src/engine/{discovery.rs,mod_tests.rs}`, `tui/src/debug_config.rs`, `docs/config.md` (+547/-4, 17 files)
6+
7+
## Specific citations
8+
9+
- New TOML field `allow_managed_hooks_only: Option<bool>` lands on `ConfigRequirementsToml` at `config/src/config_requirements.rs:647`, on `ConfigRequirements` at `:89`, and on `ConfigRequirementsWithSources` at `:696` — and is wire-exposed as nullable `allowManagedHooksOnly: boolean | null` on the v2 `ConfigRequirements` payload at `app-server-protocol/src/protocol/v2.rs:955` and TS `ConfigRequirements.ts:9`. JSON schema additions at `codex_app_server_protocol.schemas.json:7390-7395` and the v2 mirror confirm the nullable boolean shape.
10+
- The `is_empty()` discipline at `config/src/config_requirements.rs:892` correctly adds `&& self.allow_managed_hooks_only.is_none()` so a `requirements.toml` containing only `allow_managed_hooks_only = false` still counts as configured. The dedicated regression at `:1280-1290` (`deserialize_allow_managed_hooks_only` and `allow_managed_hooks_only_false_is_still_configured`) locks both the `Some(true)` and the implicit `Some(false)` truthiness asserting `!requirements.is_empty()` — this is the canonical "explicit-false-is-not-default" pattern and matches how other allow-list fields here are handled.
11+
- The mapping at `app-server/src/config_api.rs:287` (`allow_managed_hooks_only: requirements.allow_managed_hooks_only`) and the round-trip test at `:522, :606` (`assert_eq!(mapped.allow_managed_hooks_only, Some(true))`) lock the `requirements.toml → API` projection.
12+
- The 12+ test-fixture call sites in `cloud-requirements/src/lib.rs` (lines `:1206, :1289, :1323, :1374, :1491, :1572, :1651, :1858, :1899, :1960, :2017, :2076, :2136, :2196, :2286, :2318` — 16 sites in this file alone) all add `allow_managed_hooks_only: None` to existing fixtures. This is the cost of adding a non-`Default`-derivable field to a struct that test fixtures construct with explicit `Field: None` everywhere. Worth flagging as cosmetic friction (see nit #2).
13+
- `hooks/src/engine/discovery.rs` adds 71 lines (the actual gating logic) and `hooks/src/engine/mod_tests.rs` adds 361 lines (allow_managed_hooks_only behavioral coverage). Per the PR body: when `allow_managed_hooks_only = true`, discovery keeps managed-requirements hooks + managed-config-layer hooks, drops user/project/session `hooks.json` and `[hooks]` entries, drops current unmanaged plugin hooks, and emits "concise startup warnings" for the dropped entries. The 361-line test suite is the right size for the ~5 source/scope axes (managed-requirements, managed-config, user, project, plugin) crossed against (skip / keep) outcomes.
14+
- `docs/config.md` at `+6 lines` and `app-server/README.md:225-226` (`configRequirements/read` section now lists `allowManagedHooksOnly`) correctly document the requirements-only enforcement: this is policy that *can only* be set in `requirements.toml` or via MDM, not in `config.toml`. The `discovery.rs` change "ignores any `allow_managed_hooks_only` key placed in ordinary `config.toml` layers" enforces that anchoring at the loader.
15+
16+
## Verdict: merge-after-nits
17+
18+
## Concerns / nits
19+
20+
1. **No regression that asserts `config.toml`-placed `allow_managed_hooks_only` is *ignored*** in the diff slice. The PR body says "ignores any `allow_managed_hooks_only` key placed in ordinary `config.toml` layers" — that's the security-relevant property of "policy can only be set in `requirements.toml`/MDM, never escalated by user config". A test that puts `allow_managed_hooks_only = true` in user `config.toml` (no `requirements.toml`) and asserts the loaded `ConfigRequirements.allow_managed_hooks_only.is_none()` would lock the loader-side anchoring. Without it, a future loader refactor that accidentally honors the user-config key silently breaks the threat model.
21+
2. **16 test-fixture sites needing `allow_managed_hooks_only: None`** is a sign that `ConfigRequirementsToml` has accumulated enough Optional fields to warrant `#[derive(Default)]` plus `..Default::default()` in fixtures. The PR adds `allow_managed_hooks_only: None` to ~16 sites in `cloud-requirements/src/lib.rs` alone; the next field-addition PR will pay the same tax. A small precursor PR adding `Default` and migrating fixtures to spread syntax would amortize this cost across all future requirements-field additions. (Out of scope here, but worth a follow-up issue.)
22+
3. **The "concise startup warnings" claim in the PR body** is not verifiable from the diff slice — the text doesn't show the warning copy itself. Would benefit from a doc/inline comment in `discovery.rs` showing the exact warning text emitted (so users grepping logs for "managed hooks only" can find it) and a regression that asserts a specific warning is emitted when a user-config hook is dropped.
23+
4. **Plugin-hooks treated as unmanaged for now** is a forward-compat hazard worth a comment in `discovery.rs`. The PR body says "Current plugin hooks are treated as unmanaged; future managed plugin hooks can opt in through the existing `is_managed` bit". A future plugin author who reads only the source code may not realize their hook is being dropped under `allow_managed_hooks_only = true` until they read the engine code. A `// SAFETY: when plugins gain an is_managed bit, this branch ...` comment would help.
24+
5. **MDM and "managed config" layer marking** changed in `core/src/config/mod.rs` (+1 line) — the diff slice shows just the +1 line but doesn't include the `state.rs` or layer-marking surface. Worth confirming in the merge that the same predicate (`is_managed`) is now consistently true across MDM, system, and legacy managed layers, not just the loader-side check. A round-trip test that loads an MDM layer and asserts `layer.is_managed_for_hooks() == true` would lock this — the PR mentions it but the assertion is not visible in this diff slice.

0 commit comments

Comments
 (0)