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.
Summary
Credential Vault decides which binding applies — and therefore which secret to inject — from
request.pretty_host, which is theHost(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'spretty_hostprefers theHost/:authorityheader and only falls back to the connection target._binding_matches()(L394) matchesmatch.hostsagainst that value.requestheaders()(L702) injectsbinding.headersand 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
substitutionssurface (path/query/header/body).Preconditions
The chosen destination must be reachable under the sandbox's effective egress policy.
defaultActionparses todenyby 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: allowNote 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(SNIH, sotls_clienthellodoes not pass it through and interception proceeds normally), the workload sends a request whoseHostheader is the bound hostBand whose path is insideB's binding scope._binding_matchesseesB, injectsB's credential, and the request is forwarded on the session established withH.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):The client's ClientHello SNI becomes the hostname the upstream certificate is verified against. A workload cannot claim SNI
api.github.comtoward a server it controls and complete the upstream handshake. TheHostheader has no such anchor.The same file already relies on this asymmetry elsewhere: the
ignore_hostsre-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.hostsscope, and reject otherwise via the existing_reject_requestpath. KeepHost-header matching for the binding itself — it is the right key for per-request granularity (HTTP/2 multiplexing, keep-alive), forhttpbindings that have no SNI, and because transparent-moderequest.hostcan be a bare IP.Flows with no SNI keep current behavior: plaintext HTTP has none, and no-SNI TLS never reaches the hook (
tls_clienthellopasses it through). Forhttpbindings 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.mdspecifiesmatch.hostsnormalization 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.