Skip to content

MITM relay drops the body of a 401 from a substitution-bearing service — Content-Length kept, zero body bytes, empty error_code #362

Description

@jandockx

Summary

When a proxied upstream answers 401 on a host that matches a service carrying header substitutions,
the MITM relay forwards the status line and headers — including the upstream's Content-Length — and then
closes the connection without sending a single body byte. The vault's own request log records the exchange
with an empty error_code, so the truncation is silent. The same service relays 200s and 500s (with
larger bodies) intact, and 401s from hosts with no configured service relay intact, so the trigger is
specifically 401 + matched substitution service.

Environment

  • infisical/agent-vault:0.39.0 (latest release at the time of writing; main shows no response-relay
    changes since), running as a Compose service, in the context of
    toryt/scp-173 — a reusable development-container sandbox built
    around this vault — MITM ingress, clients trusting the generated CA.
  • One service configured for the affected host: match dev.example.com — a bare hostname, no scheme, no
    port, matching the whole host. The bare form is forced by the vault itself: it accepts only four host
    forms and has no path field, so composing host + '/' for a whole-host match yields
    dev.example.com/, which is none of them and is refused with a bare 400. auth: passthrough, three
    credential substitutions with in: ['header'] (sentinel placeholders in the Authorization header,
    substituted for real Basic values; in declared because the surface defaults to path and query).
  • Upstream: an AWS CloudFront distribution fronting an IIS backend.

Reproduction

Through the proxy, from a client container, no Authorization header:

curl -v https://dev.example.com/server/0/app/userInfo

Result:

< HTTP/1.1 401 Unauthorized
< Content-Type: text/html
< Content-Length: 1293
< WWW-Authenticate: Basic
< Server: Microsoft-IIS/10.0
< Via: 1.1 …cloudfront.net (CloudFront)
< X-Amz-Cf-Id: ADXRDxMnpBQkdgKUTpO8ooS5TMrSU75tECFQRCJmCK1bF6u-48Ymxg==
…
curl: (18) transfer closed with 1293 bytes remaining to read

The X-Amz-Cf-Id above is from a controlled single-request window (2026-08-03T16:55:22Z–16:55:23Z, nothing
else crossing the proxy): it identifies this exact response on the CloudFront side, should the origin's
behavior ever need confirming from there. Note the closure is immediate — status, headers, and close within
about a second — so the proxy is actively ending the connection, not stalling on it.

Control, the identical request from a host without the proxy, forced --http1.1 and default HTTP/2
both: all 1293 bytes received, exit 0, twice. The upstream delivers the complete body to anyone not behind
the vault.

What narrows the trigger

All rows measured through the same proxy instance with plain curl, minutes apart:

host service configured status WWW-Authenticate body delimiter outcome
dev.example.com yes 401 Basic Content-Length 1293 0 bytes, curl: (18)
dev.example.com yes 500 Content-Length 2501 complete
dev.example.com yes 500 Content-Length 2513 complete
dev.example.com yes 200 Content-Length 855 complete
api.github.com no 401 absent Content-Length 120 complete
the-internet.herokuapp.com no 401 Basic realm=… Content-Length 15 complete
authenticationtest.com no 401 Basic realm=… chunked, 3261 bytes complete

Hypotheses this kills:

  • Not the status alone: unconfigured hosts relay 401 bodies intact.
  • Not the host alone: the same configured service relays 500 bodies intact.
  • Not WWW-Authenticate: an unconfigured host's 401 carrying that header with a
    Content-Length-delimited body
    — the same delimiter as the truncated response — relays intact (the
    herokuapp row; the chunked row widens the evidence across the other relay path).
  • Not body size or a cap: the surviving 500 bodies (2501, 2513) are larger than the dropped 1293.

The vault's own log at the moment of truncation

The management API request log for the exact event (gateway-logs.js, i.e. /logs):

{
    "ingress": "mitm",
    "method": "GET",
    "host": "dev.example.com:443",
    "path": "/server/0/app/userInfo",
    "matched_service": "dev-backend",
    "credential_keys": ["USER_A", "USER_B", "USER_C"],
    "status": 401,
    "latency_ms": 1244,
    "error_code": ""
}

No error is recorded, in this entry or anywhere near it. From the vault's own telemetry the relay looks
successful. This entry is the single-request window above — the request log carries no field correlating to
X-Amz-Cf-Id, so the match is by timestamp on an otherwise quiet proxy. The latency_ms is a second,
independent symptom of the same blindness: the vault believes it spent 1.24 s successfully relaying a body
of which it delivered nothing.

Impact

  • A direct client gets curl: (18) — annoying but visible.
  • A client behind any Node http-proxy-family reverse proxy (Vite dev-server proxies, http-server)
    hangs forever: those proxies pipe the upstream response and only end the client response on end,
    which never comes; the 401 headers are never even flushed. A 30 s client timeout expiring with no status
    is the observable symptom, far from the cause.
  • Any workflow that must fail authentication on purpose — negative tests, credential probes — breaks
    precisely on the service the vault brokers.

Notes on mechanism (speculation, labelled as such)

The trigger shape suggests the response path for a 401 from a service the vault holds credentials for is
special-cased (credential-invalidity detection?) and consumes or discards the body instead of relaying it.
If dropping the body is ever intentional, the honest behaviors would be rewriting Content-Length, or
answering an explicit 502 with an X-Agent-Vault-Proxy-Error before streaming — the pattern #227
established for the body-cap case — and recording a non-empty error_code either way.

Provenance

This report was researched and written by Claude (Claude Code, Anthropic) — an agent running inside a
sandbox this vault brokers, which makes it one of the product's intended users. The measurements were taken
by two Claude Code sessions on either side of the gateway and by the human operator on the host; the
operator reviewed the report and filed it.

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