Skip to content

feat(proxyfault): pass an interception CA so HTTP faults reach HTTPS dependencies - #502

Merged
achoimet merged 5 commits into
mainfrom
feat/proxyfault-tls-intercept-ca
Sep 4, 2026
Merged

feat(proxyfault): pass an interception CA so HTTP faults reach HTTPS dependencies#502
achoimet merged 5 commits into
mainfrom
feat/proxyfault-tls-intercept-ca

Conversation

@achoimet

@achoimet achoimet commented Sep 2, 2026

Copy link
Copy Markdown
Member

Bridge half of HTTPS response injection. Depends on steadybit/transparent-proxy#4.

What

Opts.TLSInterceptCA{CertPEM, KeyPEM} hands the proxy a certificate authority so an HTTPStatus fault can apply to an HTTPS dependency instead of only a cleartext one. Nil keeps today's behaviour: TLS is never decrypted and HTTPS is spliced through untouched.

Snapshot also gains tls_intercept_rejected — connections whose client refused the minted certificate. That is the canonical "the CA is not in the target's truststore (or the client pins certificates)" signal, deliberately not counted as faulted, so a failed interception is diagnosable rather than looking like a silent no-op.

Why stdin, not a file path

I first passed --tls-ca-cert/--tls-ca-key, assuming the sidecar could read a path in the extension's filesystem. That is wrong for the runc backend, and I verified it rather than shipping it:

The bundle rootfs is created as an overlay with lowerdir=/ — the extension's root. An overlay does not carry the lower filesystem's submounts. A Kubernetes Secret is a separate mount, so inside the sidecar it appears as an empty directory. Reproduced with the exact mount options the real bundle uses:

Inside the sidecar overlay Result
Image-layer file (how /transparent-proxy resolves) visible
Mounted Secret at /etc/steadybit/tls-ca/ empty dir — CA unreadable

Left as-is this would have failed only at runtime, and failed misleadingly: every handshake aborting, reported as "the client rejected our certificate", sending the operator after their truststore instead of a proxy that never had a usable CA. It would also have worked for extension-host (ip netns exec shares the extension's mount namespace) and silently failed for extension-container — the worst kind of asymmetry.

So the PEM now goes over the process's stdin, with only --tls-ca-stdin on the command line. That works identically for both backends, keeps the key out of argv (visible in /proc), and never writes it to a disk the target could reach.

Notes

  • startAndMonitor takes the stdin payload so both backends share one path; it is written and the pipe closed in the background, as the proxy reads stdin to EOF at startup.
  • The flag is start-only: --revert reconstructs interception rules, which do not depend on the CA. Asserted against.
  • The CA is the customer's: they generate it, choose its validity, install it in their truststores. Nothing here manages its lifecycle.

Tests

12 pass under -race. Covers: flags absent when unset, --tls-ca-stdin present and the PEM absent from argv when set, both halves present in the stdin payload, absent from revert args, the rejected counter surviving the stdout round-trip without inflating faulted, and startAndMonitor actually writing stdin and closing it.

Verified end to end under real iptables in minikube: CA loaded from stdin, trusted client gets the forged 503 over HTTP/2, untrusted client counted as rejected (not faulted), untargeted traffic passes through, and teardown leaves no rules.

🤖 Generated with Claude Code

https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA

…deps

An HTTPStatus fault could only be applied to cleartext HTTP, because the proxy
never decrypted TLS. transparent-proxy can now terminate a matched HTTPS
connection when given a CA, so thread that through: Opts.TLSInterceptCA emits
--tls-ca-cert/--tls-ca-key on start.

The paths are read inside the proxy's sidecar, whose rootfs is an overlay of
the extension's own filesystem, so they refer to files in the extension (a
mounted Secret) and the key is never written into the target. The flags are
start-only: --revert reconstructs interception rules, which do not depend on
the CA. Nil keeps the previous behaviour of never decrypting.

Snapshot also carries tls_intercept_rejected, the count of connections whose
client refused the minted certificate — the signal that the CA is missing from
the target's truststore, and deliberately not counted as faulted.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Claude finished @achoimet's task in 2m 20s —— View job


Claude's Review

  • Check for previously unresolved review threads authored by Claude (none exist on this PR)
  • Gather context: read diff origin/main...HEAD and changed files
  • Review code quality, bugs, security, performance, race conditions
  • Post findings

Note: I wasn't able to run go build/go test/go vet in this sandbox (the tool-approval step declined those commands), so this review is based on manual reading only. The PR description states 12 tests pass under -race, which I have no reason to doubt from reading the code.

Summary

This is a well-reasoned, carefully-scoped change. The stdin-handover design and the rationale in the PR description (overlay rootfs not carrying submounts) both check out against the code. I traced the pipe lifecycle in startAndMonitor (proxyfault.go:407-456) closely since that's the highest-risk part of this diff (process control + secret material):

  • cmd.StdinPipe() is called before cmd.Start(), as required.
  • If cmd.StdoutPipe() fails, the already-opened stdin pipe is explicitly closed (proxyfault.go:424-426) — correct, since cmd.Start() was never reached and Go's os/exec only auto-closes pipe descriptors from within Start() itself.
  • If cmd.Start() fails, no explicit cleanup is needed: exec.Cmd.Start() internally closes both closeAfterStart and closeAfterWait descriptors (which includes the caller's stdin pipe handle) on failure, so nothing leaks there.
  • The write-then-close goroutine only starts after a successful Start(), so it can't race with the failure paths above.
  • startArgs() never includes the PEM (confirmed by TestStartArgs_tlsInterceptCA), and the CA flag/leaf-validity flag are gated on the same "both halves present" check, avoiding the half-configured state that would otherwise produce a confusing proxy-side failure.
  • revertArgs() correctly excludes all TLS options, matching the stated invariant that revert doesn't depend on the CA.

No bugs, race conditions, or security issues found in the reviewed diff. One minor, non-blocking observation: the CA's raw key bytes (stdin in startAndMonitor, and TLSInterceptCA.KeyPEM) aren't zeroed after being written to the pipe, so they can linger in memory until GC. Given the deliberate effort elsewhere in this PR to keep the key out of argv/disk, that's a reasonable defense-in-depth follow-up, but not something I'd block on.

The .gitignore, test, and lifecycle changes are straightforward and consistent with the implementation.
(branch: feat/proxyfault-tls-intercept-ca)

achoimet and others added 4 commits September 2, 2026 15:44
The previous commit passed --tls-ca-cert/--tls-ca-key, on the assumption that
the sidecar could read a path in the extension's filesystem. That is wrong for
the runc backend: the bundle rootfs is an overlay of the extension's "/", and
an overlay does not carry the extension's submounts, so a CA mounted from a
Kubernetes Secret is not visible by path inside the sidecar. Verified against
the real mount options — the mount point appears as an empty directory.

TLSInterceptCA now carries the PEM itself and it is written to the proxy's
stdin, with only --tls-ca-stdin on the command line. That works identically for
both backends, keeps the key out of argv, and never writes it to a disk the
target could reach.

startAndMonitor takes the stdin payload so both backends share one path; it is
written and the pipe closed in the background, since the proxy reads stdin to
EOF at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA
Three review findings.

The CA key was written from a deferred goroutine, which also ran on the
StdoutPipe error path where cmd.Start had never been called. os/exec does not
close the child end of the pipe on that path, so a failed start leaked a
descriptor and left a copy of the private key resident for a process that never
existed. The write now happens only after Start succeeds.

--tls-ca-stdin was gated on the CA pointer being non-nil while the stdin write
was gated on the payload being non-empty, so a half-populated CA told the proxy
to read a stream that was never written — it would have hung reading an empty
stdin and failed in a way that reads like a certificate problem rather than a
configuration one. Both now share interceptCAPayload, which treats a missing
half as no CA at all.

Finally, an errant `git add` had committed go/.DS_Store; removed, and .DS_Store
is now ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA
Flagged by the PR review as unreachable in practice — StdoutPipe only fails
when cmd.Stdout is already set, and cmd is always freshly constructed — but
returning there after StdinPipe succeeded would strand its write end. Cheap to
close, and the ordering is easy to change later without noticing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA
TLSInterceptCA.LeafValidity emits --tls-leaf-validity, so an extension can
expose how long minted per-SNI certificates live. Zero omits the flag and keeps
the proxy's own default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wB5XrsrNJjU9MH6yegmTA
@sonarqubecloud

sonarqubecloud Bot commented Sep 4, 2026

Copy link
Copy Markdown

@achoimet
achoimet merged commit d43f93c into main Sep 4, 2026
11 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 4, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant