Skip to content

fix: trust gRPC metadata over payload session vars; mask internal gRPC errors#79

Merged
patrickleet merged 1 commit into
mainfrom
hardening/grpc-session-precedence
Jun 11, 2026
Merged

fix: trust gRPC metadata over payload session vars; mask internal gRPC errors#79
patrickleet merged 1 commit into
mainfrom
hardening/grpc-session-precedence

Conversation

@patrickleet

Copy link
Copy Markdown
Collaborator

Security summary

The gRPC transport built the request Session by letting the payload session_variables override transport metadata. Behind a trusted gateway that injects authenticated identity as gRPC metadata, a client could spoof identity via the request body.

Exploit (before)

// gRPC metadata (injected by trusted proxy, authenticated): x-hasura-user-id: alice
GrpcRequest {
  command: "order.cancel",
  input: "{...}",
  session_variables: {           // client-controlled body — silently WON
    "x-hasura-user-id": "victim",
    "x-hasura-role": "admin"
  }
}
// Handler saw user=victim, role=admin. Identity spoofed.

Behavior (after)

Payload vars are applied first, then trusted metadata overwrites colliding keys — metadata wins. A client can no longer override a proxy-injected x-hasura-user-id/x-hasura-role from the body. Payload-only keys (no metadata collision) still pass through, preserving the Hasura-action path where Hasura forwards verified claims in the payload and no metadata is injected.

Error masking (consistency fix)

gRPC previously returned raw e.to_string() to clients, which can leak SQL/driver detail; HTTP already masked internal errors. Both transports now route response bodies through a single shared HandlerError::client_facing_message():

  • 5xx (Repository/Other) → generic "Internal server error", original logged server-side via eprintln! (the existing sink HTTP already used).
  • 4xx (client-fault) → descriptive message preserved.

No duplicated logic: the old http::error_message_for_response was removed and folded into the shared method on HandlerError.

Trust boundary docs

The framework does not authenticate. A trusted proxy must strip client-supplied x-hasura-* headers/metadata and inject only authenticated ones. Documented in:

  • Rustdoc on Session (framework-wide trust model + source precedence).
  • Rustdoc on gRPC build_session and HTTP session_from_headers.
  • README: HTTP/gRPC transport notes + a dedicated Security / Trust Boundary section, and corrected the stale "payload takes precedence" line.

Testing

  • cargo fmt --all
  • cargo build --features grpc / cargo build --features http — clean
  • cargo test --features grpc — 24 microsvc integration + 250 lib unit, all pass
  • cargo test --features http — 23 microsvc integration + 261 lib unit, all pass

New tests:

  • grpc_payload_session_variables_do_not_override_metadata — metadata trusted-user wins over payload attacker.
  • grpc_payload_session_variables_apply_when_metadata_absent — payload-only identity still flows.
  • client_supplied_identity_header_is_trusted_verbatim (HTTP) — documents that headers are trusted as-is, which is why the proxy is required.
  • Updated HTTP unit tests for the renamed shared masking method.

🤖 Generated with Claude Code

The gRPC transport let the request payload `session_variables` override
transport metadata when building the `Session`. Behind a trusted gateway
that injects authenticated identity headers as gRPC metadata, a client
could spoof identity by putting `x-hasura-user-id` / `x-hasura-role` in
the request body (e.g. claim role `admin` or impersonate another user) —
the payload silently won. This is an identity-spoofing hole.

Trust model (now documented loudly on `Session`, the HTTP/gRPC entry
points, and the README "Security / Trust Boundary" section): the
framework does NOT authenticate. A trusted proxy must strip
client-supplied `x-hasura-*` headers and inject only authenticated ones.
Transport metadata/headers are trusted; the request payload is not.

Changes:
- gRPC `build_session`: apply payload vars first, then let trusted
  metadata overwrite colliding keys. Metadata now wins. Payload-only
  keys still pass through (preserves the Hasura-action path where
  verified claims arrive in the payload with no metadata injected).
- gRPC errors: route the response body through a shared
  `HandlerError::client_facing_message()`, masking internal (5xx)
  detail to "Internal server error" and logging the original
  server-side. Previously gRPC returned raw `e.to_string()`, which
  could leak SQL/driver detail — HTTP already masked. The masking
  policy now lives in one place (error.rs) and both transports reuse
  it (no duplicated logic).
- Docs: rustdoc trust-boundary notes on `Session`,
  `session_from_headers`, and `build_session`; README HTTP/gRPC
  transport notes plus a dedicated "Security / Trust Boundary" section.
- Tests: gRPC metadata-wins-over-payload (anti-spoof) and
  payload-applies-when-metadata-absent; HTTP client-supplied identity
  header trusted verbatim (documents why the proxy is required).

Implements [[tasks/grpc-session-metadata-precedence]]
Also covers the gRPC error-masking item from
[[tasks/transport-ingress-security-hardening]]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@patrickleet, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 56 minutes and 48 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b33a3214-a703-43f5-8d7a-cbafb8ebc1de

📥 Commits

Reviewing files that changed from the base of the PR and between da838d7 and 8626ada.

📒 Files selected for processing (7)
  • README.md
  • src/microsvc/error.rs
  • src/microsvc/grpc.rs
  • src/microsvc/http.rs
  • src/microsvc/session.rs
  • tests/microsvc/transport_grpc.rs
  • tests/microsvc/transport_http.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hardening/grpc-session-precedence

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@patrickleet patrickleet merged commit 01d3d50 into main Jun 11, 2026
7 checks passed
patrickleet added a commit that referenced this pull request Jun 11, 2026
Resolve the collision between #84's transport/ingress hardening and #79
(already on main), which independently added an internal-error masking
helper and the README Security / Trust Boundary section.

- src/microsvc/error.rs: drop #84's duplicate `redacted_message()` and its
  `is_internal()` helper; keep #79's canonical `client_facing_message()` as
  the single masking method (mask 5xx -> "Internal server error", keep 4xx
  descriptive). Behaviour is identical: `status_code() >= 500` covers exactly
  Repository/Other. Doc updated to note the Knative ingress also routes
  through it.
- src/microsvc/http.rs: take #79's `client_facing_message()` call site and
  test names (the only difference from #84 was the method name).
- src/microsvc/knative_ingress.rs: #84's masking now uses the shared
  `client_facing_message()`, and the 5xx log gate uses `status_code() >= 500`
  in place of the removed `is_internal()`. Message-name validation and the
  body limit are preserved.
- README.md: #84 made no README edits on this branch, so the merge keeps
  #79's Security / Trust Boundary section verbatim; no duplication.

#84's transport/ingress hardening (S3-S6) remains intact: message-name
validation, HTTP/Knative body limits, inbox purge/retention, DDL allowlist,
identity validation, and the payload_bitcode caveat. Verified: cargo fmt;
build default + http + sqlite + postgres; test --features sqlite and
--features http (knative_cloudevents + microsvc suites green with the single
shared masking helper).

Refines [[tasks/transport-ingress-security-hardening]]
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.

1 participant