Repository navigation
feat(config): forward caller identity to HTTP backends, opt-in per server - #110
luisaniceto-infra wants to merge 2 commits into
Conversation
…rver
The proxy authenticates the caller and enforces the ACL, then talks to every
backend with one credential resolved at startup. That is the right model when
the backend's data has no per-row owner — Sentry, Slack, Databricks: the shared
token IS the service account, and the proxy decides who may reach it.
It stops being the right model when the backend's data is owned per user: an
internal app catalogue, a notes service, anything that records an author. There
a shared credential makes every write land under the same name, and it does not
fail loudly — it records the wrong owner, silently.
New per-server option on HTTP backends:
"hub": {
"url": "http://hub.internal/mcp",
"headers": { "Authorization": "Bearer ${HUB_SERVICE_TOKEN}" },
"forward_identity": { "header": "X-MCP-Subject" }
}
The subject travels per request, from the identity the proxy already resolved in
dispatch. `roles_header` is optional and off by default — most backends only
need to know who, and sending roles a backend does not read is avoidable
exposure.
Scoped deliberately:
- HTTP backends only. A stdio/CLI backend is a process the proxy owns, and there
is no per-request channel to carry a header into a long-lived stdin pipe.
- Opt-in per server, never on by default: a backend that never asked for
identity must not start receiving one on upgrade.
- The forwarded header WINS over a backend's own `x-mcp-header` annotation with
the same name, and the argument-derived one is dropped rather than appended.
Otherwise a tool could declare an argument mapped to `X-MCP-Subject` and let
the caller pick their own subject — privilege escalation, tested.
- A subject or role containing a control character REFUSES the call instead of
falling back to the shared credential: falling back is the silent-wrong-owner
outcome this feature exists to remove.
- Header names are validated with the same `is_tchar` predicate the
`x-mcp-header` path already uses (now `pub(crate)`) — two copies of a
header-injection check is how one of them drifts.
The safety condition the proxy cannot verify is documented on the type, in the
config reference and in the code: the backend must not be reachable by anyone
who could set the header themselves, or the header is a complete authentication
bypass. That is why this is opt-in.
Tests: 8 unit tests on the header builder (no config, stdio backend, unknown
server, default and custom header names, roles, CRLF in subject, control char in
roles, four malformed header names) and 2 client tests (shadowing refused,
additive behaviour unchanged). Deny cases outnumber allow cases, per
CONTRIBUTING's checklist for trusted-header changes. Full suite: 822 passing.
There was a problem hiding this comment.
🟡 Changes recommended
There are correctness/security-related issues around header shadowing semantics (and corresponding tests) plus doc/attribute attachment problems that should be fixed before merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds an opt-in mechanism for HTTP backends to receive the authenticated caller’s identity as per-request HTTP headers, avoiding the “writes attributed to the shared service credential” problem for backends with per-user ownership semantics.
Changes:
- Introduces
forward_identityinServerConfig::Httpplus docs for configuration and safety constraints. - Computes per-request identity headers during dispatch (under the proxy lock) and forwards them on backend
tools/call. - Adds a new
McpClient::call_tool_raw_withpath to send additional per-request headers, along with new tests.
File summaries
| File | Description |
|---|---|
| src/serve/proxy.rs | Adds identity header construction/validation and unit tests; includes helper validators. |
| src/serve/dispatch.rs | Resolves identity headers in phase 1 and forwards them to backend calls. |
| src/client.rs | Adds call_tool_raw_with for extra per-request headers and tests for interaction with x-mcp-header. |
| src/config.rs | Adds ForwardIdentity type and forward_identity option on HTTP server configs. |
| src/output.rs | Updates HTTP ServerConfig test literals to include forward_identity: None. |
| docs/reference/config-file.md | Documents forward_identity, including security constraints and behavior notes. |
Review details
Suppressed comments (2)
src/client.rs:2311
- This test intends to ensure the argument-derived header is dropped, but it only inspects
X-MCP-Subjectand never asserts that the mirroredMcp-Param-X-MCP-Subjectheader is absent. As written, it can pass even if the tool argument is still being forwarded viaMcp-Param-*.
let headers = t.headers_for("tools/call");
let subjects: Vec<&String> = headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("x-mcp-subject"))
.map(|(_, value)| value)
src/serve/proxy.rs:886
resolve_tool_calllost its#[allow(clippy::result_large_err)]and its doc comment due to the newidentity_headersinsertion. If clippy is still warning on this signature, the allow should live onresolve_tool_callitself (and the doc comment should be restored here).
pub(crate) fn resolve_tool_call(
&self,
id: &Value,
params: Option<Value>,
identity: &AuthIdentity,
- Files reviewed: 6/6 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let reserved: Vec<String> = extra_headers | ||
| .iter() | ||
| .map(|(k, _)| k.to_ascii_lowercase()) | ||
| .collect(); | ||
| let mut headers = extra_headers.to_vec(); | ||
| headers.extend( | ||
| from_params | ||
| .into_iter() | ||
| .filter(|(k, _)| !reserved.contains(&k.to_ascii_lowercase())), | ||
| ); |
| if let Some(roles_header) = &cfg.roles_header { | ||
| out.push(( | ||
| valid_header_name(roles_header)?, | ||
| valid_header_value(&identity.roles.join(","), "roles")?, | ||
| )); | ||
| } |
| @@ -842,6 +842,43 @@ | |||
| /// `arguments`. Returns Err(JsonRpcResponse) if the call should be | |||
| /// rejected immediately. | |||
| #[allow(clippy::result_large_err)] | |||
| /// Headers carrying the caller's identity for one backend call, or empty | ||
| /// when the server did not opt in via `forwardIdentity`. | ||
| /// |
Four findings from review, all of them real. Two were mine to own.
**1. Displaced doc comments took `#[allow]` with them — this is what broke
CI.** Inserting `identity_headers` between `resolve_tool_call`'s doc block and
the function moved BOTH the doc and `#[allow(clippy::result_large_err)]` onto
the new function. `resolve_tool_call` lost its allow, so `cargo clippy --
-D warnings` failed on it.
The same mistake sits in client.rs and review did not catch it: the MRTR doc
block belonged to `call_tool_raw` and ended up on `call_tool_raw_with`, leaving
`call_tool_raw` undocumented. Both restored.
This also corrects the PR's claim that the two `result_large_err` errors
pre-existed on main. Only ONE does — main's CI has been red since 2026-08-26
on `dispatch.rs` `client_or_connect`. The `proxy.rs` one was mine. Verified
against main's own CI log, not a local run: the local run used `--all-targets`
without `-D warnings`, so it printed them as warnings and I read both as
inherited.
**2. `roles_header` equal to `header` is now refused.** Two same-named headers
with unrelated values leave the choice to the receiver, which could read the
roles value as the subject. Refused for the same reason as a control character.
Checked in `identity_headers` and not at load time because the existing config
validation only warns at boot, and a warning would let the ambiguous call out.
**3. The shadowing threat was overstated, and the test was vacuous.**
`x-mcp-header` annotations always emit `Mcp-Param-{Name}`, so a tool annotating
`X-MCP-Subject` produces `Mcp-Param-X-MCP-Subject` and cannot claim the
identity header by name — the prefix isolates it with no help from the filter.
There is no privilege escalation through that path, and the doc, the docs page
and the PR body all claimed there was.
`forwarded_identity_cannot_be_shadowed_by_x_mcp_header` asserted only that the
forwarded subject was the value under `x-mcp-subject`, which was true for that
structural reason — it passed with the filter deleted. Replaced by two tests:
one documenting the prefix guarantee (and saying plainly that it does not
exercise the filter), one exercising the only reachable collision, which is an
operator configuring `header` under the `Mcp-Param-` prefix.
**4. Doc said `forwardIdentity`; the field is `forward_identity`.** Fixed.
Also translated the Portuguese comments and assertion messages this branch
introduced — the rest of the codebase is in English and mixing the two is
noise for a reader.
Verified in a rust:1-slim container:
cargo fmt --all --check clean
cargo clippy -- -D warnings 1 error, the pre-existing dispatch.rs one
cargo test 825 passed; 0 failed
The three new tests were mutation-checked — removing the collision filter or
disabling the roles_header check fails exactly them, and nothing else. The
prefix-guarantee test keeps passing under those mutations, which is the point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Os quatro achados procedem. Corrigidos em 1. A doc deslocada é a causa da falha do CIO Copilot marcou como E o mesmo erro está no RetrataçãoEsta PR diz: "Os 2 warnings de result_large_err que o clippy mostra já existiam na main (conferi com a árvore limpa)". Isso está errado — só UM pré-existia. O outro era meu, causado pelo allow deslocado. Conferi agora contra o log do CI da própria O que me enganou: rodei Abri a #111 só pro erro pré-existente da 2.
|
cargo fmt --all --check |
limpo |
cargo clippy -- -D warnings |
1 erro, o pré-existente do dispatch.rs (#111) |
cargo test |
825 passed; 0 failed |
Os três testes novos foram verificados por mutação — já entreguei um teste vazio nesta PR, não vou repetir:
| mutação | falha |
|---|---|
| remover o filtro de colisão | extras_win_over_a_same_named_param_header |
desligar a checagem de roles_header |
roles_header_equal_to_the_subject_header_is_refused + ..._ignoring_case_... |
E o an_annotation_cannot_claim_the_identity_header_name continua passando sob as duas — que é o ponto dele.
Também traduzi pro inglês os comentários e mensagens em português que esta branch tinha introduzido; o resto do código é em inglês e misturar só atrapalha quem lê.
|
Obrigado por liberar o CI. O run de hoje ainda falha, mas por um erro que não é desta PR — vale registrar o que mudou:
O meu foi corrigido em O job morre no passo do clippy, então o A #111 conserta exatamente esse erro e passou verde hoje (run 34260019894) — uma linha, o mesmo Com a #111 na |
Closes #109.
O proxy autentica o caller e aplica a ACL, mas fala com o backend usando uma credencial de serviço resolvida no boot. Modelo certo pra Sentry/Slack/Databricks — o dado não tem dono por linha. Deixa de ser certo quando o dado é de alguém: aí toda escrita cai com o mesmo nome, e não falha alto — registra o dono errado, em silêncio.
O subject vai por request, a partir da identidade que o dispatch já resolveu.
roles_headeré opcional e desligado por padrão — a maioria dos backends só precisa saber quem, e mandar roles que o backend não lê é exposição evitável.Escopo, deliberado
x-mcp-headercom o mesmo nome, e a derivada do argumento é descartada, não anexada depois — header duplicado quem resolve é o receptor, e essa decisão não é nossa. Sem isso, uma tool declara um argumento mapeado praX-MCP-Subjecte o caller escolhe o próprio subject.is_tcharviroupub(crate)e o caminho novo usa o MESMO predicado dox-mcp-header. Duas cópias é como uma delas diverge.A condição que o proxy não consegue verificar
Na prática: o backend aceita só do endereço do proxy, por link privado. É por isso que a feature é opt-in por servidor em vez de ligada por padrão. Está escrito no doc do tipo, na referência de config e no código.
Testes
Seguindo o checklist do CONTRIBUTING pra mudança em header confiável — casos de negação em maior número que os de aceitação:
Construtor de headers (8): sem config → nada; backend stdio → nada; servidor desconhecido → nada; header default; nome custom + roles; CRLF no subject → recusa; caractere de controle em roles → recusa; quatro nomes de header malformados (
"", com espaço, com:, com CRLF) → recusa.Client (2): anotação
x-mcp-headertentando sombrearX-MCP-Subject→ a identidade encaminhada é a única que sai (escalação de privilégio); e sem identidade encaminhada o comportamento dox-mcp-headerfica idêntico ao de antes (é aditivo).Os 2 warnings de
result_large_errque o clippy mostra já existiam namain(conferi com a árvore limpa) — não mexi neles pra não misturar refactor com feature.Notas de revisão
identity_headersfica noProxyServere roda na fase 1 do dispatch, sob o mesmo lock que já resolve a rota: config e caller estão os dois em mão, e a fase 3 segue sem lock.call_tool_rawcontinua com a assinatura de antes; a variante nova écall_tool_raw_with. Nada de churn em call site existente.ServerConfig::Httpganhou campo, então as duas construções literais emsrc/output.rs(testes) ganharamforward_identity: None.Se preferir outro nome pro campo (
forwardIdentity,identity_forwarding) ou quiser o roles_header fora por enquanto, mudo — o desenho aguenta os dois.