Skip to content

Give proxies a supported way to know which request fields are client-controlled (and reject them) in the built-in API server #7575

Description

@Javviviii2

🔴 Required Information

Is your feature request related to a specific problem?

The ApiServer docstring says the served endpoints are unauthenticated and must
be put behind the user's own authentication layer. That is what we did: a
gateway authenticates the caller and then forwards to the ADK endpoints.

The gap is what that layer is supposed to do with the request body. Several
request fields are written by the client straight into the run or the session:

  • RunAgentRequest.state_delta → applied to session state (api_server.py,
    forwarded to run_async).
  • RunAgentRequest.service_tier → forwarded to the model calls.
  • CreateSessionRequest.state, .events, .options → session initialization.

In an application that derives trusted values (user identity, groups, an
approval token) from session state, a client that can write state_delta can
forge them, so the gateway has to refuse these fields. There is no supported way
to do that, and a hand-written denylist is fragile for two reasons:

  1. Two spellings per field. The request models inherit
    alias_generator=to_camel and populate_by_name=True
    (cli/utils/common.py), so state_delta and stateDelta are both accepted.
    Filtering only the snake_case or only the camelCase name is a bypass. This is
    not mentioned where the docstring recommends adding an auth layer.
  2. The set of fields grows every minor. service_tier and
    CreateSessionRequest.options were added in recent releases, both
    client-controlled. A denylist written against an earlier version silently
    stops covering the request model after an upgrade.

Our workaround is to derive the field names from the pydantic models at import
time and to fail a unit test whenever a new field appears in
RunAgentRequest / CreateSessionRequest that we have not classified. That test
failed on both upgrades above, which is how we noticed, but it is code every
deployment behind a proxy has to rediscover and maintain.

Describe the Solution You'd Like

In increasing order of effort, whichever the maintainers prefer:

  1. Docs: in the ApiServer security note, state that request models accept
    both snake_case and camelCase names, and list the fields that write into
    session state or run configuration.
  2. A stable, exported source of truth: e.g. a module-level constant (or a
    classmethod on the request models) naming the client-controlled fields, kept
    next to the models so a new field has to be classified when it is added.
  3. Opt-out in the server: an option on get_fast_api_app (for example
    trust_client_state=False) that rejects state_delta, CreateSessionRequest.state
    / events with a 4xx, for deployments where state must come only from the
    server side.

Impact on your work

We run ADK behind an authenticating gateway and need to guarantee that trusted
session state can only be written server-side. Today that guarantee depends on a
private denylist plus a test that must be re-checked on every ADK upgrade.

Willingness to contribute

Yes. Option 1 and 2 are small and I can open a PR with unit tests once the
preferred shape is agreed. Option 3 needs a design decision first.


🟡 Recommended Information

Describe Alternatives You've Considered

  • Hand-maintained denylist of field names: bypassable via the alias spelling and
    goes stale when fields are added.
  • Deriving the names from the models (what we do): robust, but relies on the
    models' internals and on re-running a test after each upgrade.
  • Sub-classing the server and overriding request handling: rejected, it forks
    handler logic that changes between releases.

Proposed API / Implementation

# google/adk/cli/api_server.py (illustrative)
CLIENT_CONTROLLED_RUN_FIELDS: frozenset[str] = frozenset(
    {"state_delta", "service_tier"}
)
CLIENT_CONTROLLED_SESSION_FIELDS: frozenset[str] = frozenset(
    {"state", "events", "options"}
)

app = get_fast_api_app(..., trust_client_state=False)  # option 3

Additional Context

This is not a vulnerability report: the documented model is "unauthenticated,
put your own layer in front". The request is for that layer to have a supported
and complete description of what the body can carry.

Activity

  1. sattyamjjain commented on Oct 17, 2026

    @sattyamjjain

    Two more state-write paths that a field list built from RunAgentRequest and CreateSessionRequest would miss (checked at 0e96818, api_server.py):

    1. PATCH /apps/{app_name}/users/{user_id}/sessions/{session_id} takes UpdateSessionRequest (L776-780) with state_delta: dict[str, Any] and appends it as a user event without running the agent (L1795-1835).
    2. The deprecated POST /apps/{app_name}/users/{user_id}/sessions/{session_id} (create_session_with_id, L1728-1749) takes state as a bare parameter, not through a request model.

    Also, RunAgentRequest carries user_id in the body (L654), so identity is client-supplied too unless the proxy pins it. Other client-controlled fields there: function_call_event_id, invocation_id, and custom_metadata (L660-663).

    Untested idea for the proxy: parse the body with ADK's own models and check model_fields_set against an allowlist. The models use camelCase aliases with populate_by_name=True, and model_fields_set holds the field names, whichever spelling was sent.
    https://github.com/google/adk-python/blob/0e968189596f93a0220f8f7bc63ebe3a569765a8/src/google/adk/cli/api_server.py

  2. Oliveryfaso commented on Oct 21, 2026

    @Oliveryfaso

    Would a documentation + transport-contract test slice be useful here, coordinated with @Javviviii2's proposed implementation? I checked the unmodified checkout at 77a74f0 before proposing a public helper or server option.

    Building on @sattyamjjain's PATCH/legacy-route findings, there are two additional boundaries to account for:

    • WebSocket /run_live: each text frame is parsed as LiveRequest, which has a top-level state_delta. Unlike the three HTTP request models, this model does not use the camelCase alias generator: state_delta reaches the live queue; top-level stateDelta is currently ignored. HTTP OpenAPI includes neither this route nor its message model. See frame parsing and LiveRequest. This is a contract observation, not a vulnerability claim.
    • Default-app shorthand: with ADK_DEFAULT_APP_NAME, /users/... is rewritten to /apps/{default_app}/users/... before routing. A deployment's path policy must account for these forms too; they are not separate OpenAPI paths. See rewrite middleware.

    The state/run input inventory is:

    Transport Client-supplied selectors Body / frame inputs
    POST /run, /run_sse app_name, user_id, session_id in body new_message, streaming, state_delta, function_call_event_id, invocation_id, custom_metadata, service_tier
    POST /apps/{app_name}/users/{user_id}/sessions app/user in path; session_id in body state, events, options
    PATCH .../sessions/{session_id} app/user/session in path state_delta
    Deprecated POST .../sessions/{session_id} app/user/session in path entire JSON object is state, not a {"state": ...} envelope
    WebSocket /run_live app/user/session in query LiveRequest, including state_delta; query also carries live-run configuration

    These inputs have different effects. For example, function_call_event_id is not forwarded as a runner argument, but affects SSE artifact-event grouping; streaming selects streaming mode on /run_sse, while /run remains nonstreaming. Session initialization events also have existing runtime-protocol validation. A field list should describe these contracts without declaring fields universally safe for every application's authorization policy.

    The model_fields_set idea does normalize recognized HTTP field names, including explicit null, {}, and false. Two caveats were reproducible:

    1. Default parsing drops unknown raw keys; they are absent from model_fields_set. Checking that set and then forwarding the original JSON would lose this information. Per-call model_validate(payload, extra="forbid") rejects unknown keys without changing ADK's model defaults in the Pydantic version tested.
    2. If both names are present, the camelCase alias wins under default parsing. A gateway recipe needs an explicit duplicate-spelling policy, a reviewed-field check after upgrades, and a normalized outgoing body with server-derived selectors applied afterwards. This does not decide authorization for nested message content or state keys.

    Minimal model-only reproduction in that checkout's environment:

    from google.adk.cli.api_server import RunAgentRequest
    from google.adk.live.live_request_queue import LiveRequest
    from pydantic import ValidationError
    
    base = {"user_id": "u", "session_id": "s"}
    for key in ("state_delta", "stateDelta"):
        req = RunAgentRequest.model_validate({**base, key: None})
        assert "state_delta" in req.model_fields_set
    assert "state_delta" not in RunAgentRequest.model_validate(base).model_fields_set
    
    raw = {**base, "futureControl": True}
    assert "futureControl" not in RunAgentRequest.model_validate(raw).model_fields_set
    try:
        RunAgentRequest.model_validate(raw, extra="forbid")
    except ValidationError:
        pass
    else:
        raise AssertionError("unknown key accepted")
    
    assert LiveRequest.model_validate({"state_delta": {"k": 1}}).state_delta == {"k": 1}
    assert LiveRequest.model_validate({"stateDelta": {"k": 1}}).state_delta is None

    Validation performed: a local characterization suite passed 70 tests on macOS arm64 / Python 3.12.13 / Pydantic 2.14.1. It uses TestClient, a recording fake runner, and an in-memory session service; outbound socket connections are blocked during the tests. It covers both HTTP spellings, presence versus null/empty/false, unknown and duplicate keys, both run routes, create/PATCH/legacy paths, live-frame parsing into the queue, and default-app shorthand. An inventory exported 22 production HTTP operations plus one WebSocket route. No model/provider E2E, full upstream tox matrix, or deployed gateway was tested; the suite is local research, not upstream CI or a complete authorization gateway.

    Proposed first slice: document the HTTP aliases, client-writable state/run inputs, bare legacy body, and explicit WebSocket scope; add focused transport tests in the existing API-server test module. A separate supported descriptor/helper or opt-out should follow whichever design maintainers prefer. This would preserve current endpoint behavior and stay separate from the caller-principal work in #7572/#7134 and authentication middleware in #5981.

    Is that first slice welcome, and is it already covered by the reporter or an internal change? Happy to coordinate tests/docs with the reporter instead of duplicating their implementation.

    AI assistance disclosure: Codex prepared this analysis and executed the local characterization tests. No claim of manual provider testing by the account owner.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions