Skip to content

Credential Vault: binding match uses the Host header alone, letting a workload steer an injected credential to another TLS peer #1758

Description

@changren-wcr

Summary

Credential Vault decides which binding applies — and therefore which secret to inject — from request.pretty_host, which is the Host (HTTP/2 :authority) header. That value is unauthenticated input from the sandbox workload, and nothing later checks it against the identity of the peer the request is actually delivered to. A workload can therefore cause a vault credential to be injected into a request that is delivered to a host it controls.

components/egress/mitmscripts/system.py:

  • _request_host() (L259) → flow.request.pretty_host or flow.request.host; mitmproxy's pretty_host prefers the Host/:authority header and only falls back to the connection target.
  • _binding_matches() (L394) matches match.hosts against that value.
  • requestheaders() (L702) injects binding.headers and applies substitutions once a binding matches.

Impact

The credential leaves the sandbox boundary to a destination the workload picked, which is exactly what Credential Vault exists to prevent: the secret is never exposed to the workload as a value, but the workload can still decide who receives it. Affects header injection and every substitutions surface (path/query/header/body).

Preconditions

The chosen destination must be reachable under the sandbox's effective egress policy. defaultAction parses to deny by default (pkg/policy), so a tightly scoped allowlist that contains only the bound hosts is not exploitable. It becomes exploitable when the policy allows anything the workload can also control, which is common in practice:

  • defaultAction: allow
  • wildcard allows covering provider domains where anyone can obtain a subdomain (tunnel/preview/pages style hosts)
  • any allowed host whose request logs the workload can read

Note the guide already warns that default-allow "may allow credential destination bypass"; this report is that the same bypass exists for TLS destinations under a deny-default policy whenever the allowlist is wider than the set of bound hosts.

Mechanism

Over an intercepted TLS session to an allowed host H (SNI H, so tls_clienthello does not pass it through and interception proceeds normally), the workload sends a request whose Host header is the bound host B and whose path is inside B's binding scope. _binding_matches sees B, injects B's credential, and the request is forwarded on the session established with H.

Why SNI is different

The SNI is not just a second copy of the same claim — mitmproxy 11.0.2 turns it into a verified identity (mitmproxy/addons/tlsconfig.py):

if server.sni is None:
    server.sni = client.sni or server.address[0]
...
if server.sni:
    # We need to set SNI + enable hostname verification.
    X509_VERIFY_PARAM_set1_host(param, host_name, len(host_name))

The client's ClientHello SNI becomes the hostname the upstream certificate is verified against. A workload cannot claim SNI api.github.com toward a server it controls and complete the upstream handshake. The Host header has no such anchor.

The same file already relies on this asymmetry elsewhere: the ignore_hosts re-check (tls_clienthello, L142-171) is deliberately done against the SNI. Only the injection path uses the header.

Proposed fix

After binding selection and before injection, require the ClientHello SNI (when present) to fall inside the matched binding's match.hosts scope, and reject otherwise via the existing _reject_request path. Keep Host-header matching for the binding itself — it is the right key for per-request granularity (HTTP/2 multiplexing, keep-alive), for http bindings that have no SNI, and because transparent-mode request.host can be a bare IP.

Flows with no SNI keep current behavior: plaintext HTTP has none, and no-SNI TLS never reaches the hook (tls_clienthello passes it through). For http bindings the egress allow rules remain the only destination control, which is worth stating explicitly in the guide.

PR: #1759

Note on OSEP 0012

oseps/0012-credential-vault.md specifies match.hosts normalization and wildcard rules but never says which host of the request is matched, and has no SNI/Host consistency requirement. Worth pinning down in the OSEP so the invariant is not re-lost.

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions