Skip to content
Merged
77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ The hostname/SNI targeting is what makes **one** tool work for both internal
- **Inspected path:** when a rule targets by hostname, the proxy reads only the
first TLS record to extract the SNI (cleartext — **no MITM, no certificates**),
replays those bytes to the upstream, then splices the remainder.
- **Interception path (opt-in):** only when a CA is supplied via
`--tls-ca-cert`/`--tls-ca-key` *and* a matching rule carries `httpStatus`, an
HTTPS connection is terminated so the response can be synthesized. See
[HTTPS response injection](#https-response-injection). Without a CA the proxy
never decrypts anything.

## Fault rules

Expand All @@ -82,9 +87,79 @@ selectors match — an empty selector means "any".
- `hosts` — match the TLS SNI, exact or subdomain (external targeting).
- `latency` — Go duration string, added before the upstream connect.
- `abort` — reset (RST) the connection.
- `httpStatus` — synthesize this HTTP status (L7, cleartext HTTP).
- `httpStatus` — synthesize this HTTP status (L7). Cleartext HTTP always; HTTPS
only with an interception CA (see below).
- `probability` — `[0,1]` chance to apply the fault per connection (`0`/unset = always).

## HTTPS response injection

By default the proxy never decrypts TLS: it reads the SNI in cleartext and
splices the bytes through. Supplying a CA opts in to terminating **matched**
HTTPS connections so an `httpStatus` fault can be synthesized inside TLS:

```bash
transparent-proxy \
--tls-ca-cert /etc/steadybit/intercept-ca.crt \
--tls-ca-key /etc/steadybit/intercept-ca.key \
--fault-hosts api.stripe.com --fault-http-status 503
```

`--tls-ca-stdin` reads the same CA as **one PEM stream on stdin** (certificate
and key, either order) instead of from files. This is what an orchestrator
should use: it keeps the key off the command line, off any disk the target could
reach, and it is the only channel that works when the proxy runs inside an
overlay of the orchestrator's filesystem — an overlay does not carry the
orchestrator's submounts, so a key mounted there (a Kubernetes Secret, say) is
not visible by path.

```bash
cat intercept-ca.crt intercept-ca.key |
transparent-proxy --tls-ca-stdin \
--fault-hosts api.stripe.com --fault-http-status 503
```

**The caller must close stdin.** The read is capped at 1 MiB and bounded by a
30s deadline, so a writer that never closes fails loudly rather than hanging the
proxy before it installs any rules. The key must not be passphrase-protected.

The proxy mints a short-lived certificate for the connection's SNI, signed by
that CA, and answers the request itself. **HTTP/1.1 and HTTP/2 are both
supported** — the response is delivered over whichever the client negotiates
via ALPN.

**The CA is yours, and it need not be a root.** An intermediate issued by your
own PKI works and is the better choice: you keep the root key offline, the
workloads already trust the root, and the proxy presents the intermediate so
the chain still builds. Constrain it further with `nameConstraints` if you want
it usable only for the dependencies under test.

You generate it, choose how long it lives, and install the trust anchor in the
truststores of the workloads you want to fault. The proxy only signs with
it; it never creates, rotates, or renews a CA. A CA already outside its validity
window is rejected at startup rather than failing every handshake later.

This is deliberately **one-sided**: the real dependency is never contacted. The
proxy makes no trust decision about the origin's certificate, and a dependency
behind mutual TLS is unaffected. The trade-off is that the response is
fabricated rather than a modified real one.

**When it does not apply** — the connection is spliced through untouched:

- no CA configured, or the client sent no SNI;
- the rule carries no `httpStatus`;
- the connection lost the `probability` roll.

**When the client refuses** — if the workload does not trust the CA (or pins
certificates) it either fails the handshake, or, under TLS 1.3, completes it and
then walks away without sending a request. Both are counted as
`tls_intercept_rejected` and deliberately *not* as a fault, so a non-zero value
is the signal that the CA is missing from the target's truststore rather than a
silent no-op. Only a response actually written counts as an injected fault.

> Interception requires a key that can impersonate any HTTPS endpoint to
> anything trusting the CA. Treat it as a test/staging capability and keep the
> key restricted.

## Build & test

```bash
Expand Down
10 changes: 7 additions & 3 deletions internal/fault/fault.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,13 @@ type Rule struct {
Abort bool

// HTTPStatus, if non-zero, makes the proxy synthesize an HTTP response with
// this status code instead of forwarding — an L7 fault that applies only to
// cleartext HTTP (it is ignored on TLS/opaque connections). Selected by the
// Host header, matched with the same semantics as Hosts.
// this status code instead of forwarding — an L7 fault selected by the Host
// header or TLS SNI, matched with the same semantics as Hosts.
//
// It applies to cleartext HTTP always, and to HTTPS only when the proxy was
// given an interception CA (--tls-ca-cert/--tls-ca-key): the connection is
// then terminated with a certificate minted for its SNI. Without a CA, TLS
// connections are spliced through untouched. Opaque L4 is never affected.
HTTPStatus int

// HTTPBody, if set, replaces the default synthesized response body.
Expand Down
21 changes: 20 additions & 1 deletion internal/interception/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ type Config struct {
// reset so they re-establish through the proxy and immediately feel the
// fault; set it when only new connections should be affected.
SkipFlush bool
// FlushDestinations narrows the connection-pool flush to these destinations
// instead of the whole capture filter.
//
// The capture filter is deliberately broad — typically 0.0.0.0/0 on ports
// 80 and 443 — because the proxy decides what to fault by hostname once it
// has seen the request. The flush cannot do that: it is a stateless iptables
// REJECT and knows only addresses. Applying it to the capture filter would
// therefore reset every established HTTP/HTTPS connection in the target,
// including ones to dependencies the attack never names. Resolving the
// targeted hostnames up front and flushing only those addresses keeps the
// collateral to the dependency actually under test.
//
// Empty keeps the old behaviour and flushes the whole capture filter, which
// is what a CIDR-targeted attack (no hostnames) actually wants.
FlushDestinations []netip.Prefix
}

func (c Config) mark() uint32 {
Expand Down Expand Up @@ -129,7 +144,11 @@ func (c Config) AddScript() []string {
for _, ex := range excludes {
s = append(s, fmt.Sprintf("-A %s -d %s -j RETURN", flush, ex))
}
for _, in := range includes {
flushDsts := includes
if len(c.FlushDestinations) > 0 {
flushDsts = includeV4(c.FlushDestinations)
}
for _, in := range flushDsts {
for _, p := range c.Filter.Ports {
s = append(s, fmt.Sprintf("-A %s -p tcp -d %s --dport %d -m conntrack --ctstate ESTABLISHED -j REJECT --reject-with tcp-reset", flush, in, p))
}
Expand Down
50 changes: 50 additions & 0 deletions internal/interception/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,53 @@ func TestRevert_FailsVerificationWhenRulesRemain(t *testing.T) {
t.Fatal("Revert must fail verification while a chain still contains rules")
}
}

// The capture filter is deliberately broad (0.0.0.0/0 on 80/443) because the
// proxy picks its victims by hostname. The flush cannot — it is a stateless
// REJECT that knows only addresses — so scoping it to the capture filter would
// reset every established HTTP/HTTPS connection in the target, not just the
// dependency under test.
func Test_flushIsScopedToResolvedDestinations(t *testing.T) {
base := Config{
ExecutionID: "exec",
ProxyPort: 3128,
Filter: Filter{
Include: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
Ports: []uint16{80, 443},
},
}

// Without resolved destinations the flush covers the whole capture filter —
// what a CIDR-targeted attack wants.
broad := strings.Join(base.AddScript(), "\n")
if !strings.Contains(broad, "-d 0.0.0.0/0 --dport 443 -m conntrack --ctstate ESTABLISHED -j REJECT") {
t.Fatalf("expected a filter-wide flush without resolved hosts:\n%s", broad)
}

scoped := base
scoped.FlushDestinations = []netip.Prefix{
netip.MustParsePrefix("93.184.216.34/32"),
netip.MustParsePrefix("1.2.3.4/32"),
}
got := strings.Join(scoped.AddScript(), "\n")

for _, want := range []string{
"-d 93.184.216.34/32 --dport 80 -m conntrack --ctstate ESTABLISHED -j REJECT",
"-d 93.184.216.34/32 --dport 443 -m conntrack --ctstate ESTABLISHED -j REJECT",
"-d 1.2.3.4/32 --dport 443 -m conntrack --ctstate ESTABLISHED -j REJECT",
} {
if !strings.Contains(got, want) {
t.Fatalf("missing scoped flush rule %q in:\n%s", want, got)
}
}
// The whole point: nothing else on those ports is reset.
if strings.Contains(got, "-d 0.0.0.0/0 --dport 443 -m conntrack --ctstate ESTABLISHED -j REJECT") {
t.Fatalf("flush still resets the entire capture filter:\n%s", got)
}
// Capture itself must stay broad — the proxy still needs to see everything
// so it can match by hostname.
if !strings.Contains(got, "-d 0.0.0.0/0 -p tcp -m tcp --dport 443 -j REDIRECT") &&
!strings.Contains(got, "0.0.0.0/0") {
t.Fatalf("capture filter was narrowed too:\n%s", got)
}
}
16 changes: 16 additions & 0 deletions internal/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type Metrics struct {
ConnectionsFaulted atomic.Int64 // connections a fault was actually applied to (once each)
LatencyApplied atomic.Int64 // connections a latency fault delayed
HTTPResponsesInjected atomic.Int64 // connections given a synthesized HTTP response
TLSInterceptRejected atomic.Int64 // HTTPS interception rejected by the client (CA not trusted / pinning)
UpstreamErrors atomic.Int64 // dial failures
BytesToUpstream atomic.Int64
BytesToClient atomic.Int64
Expand Down Expand Up @@ -63,6 +64,7 @@ type Snapshot struct {
ConnectionsFaulted int64 `json:"connections_faulted"`
LatencyApplied int64 `json:"latency_applied"`
HTTPResponsesInjected int64 `json:"http_responses_injected"`
TLSInterceptRejected int64 `json:"tls_intercept_rejected"`
UpstreamErrors int64 `json:"upstream_errors"`
BytesToUpstream int64 `json:"bytes_to_upstream"`
BytesToClient int64 `json:"bytes_to_client"`
Expand Down Expand Up @@ -93,6 +95,7 @@ func (m *Metrics) Snapshot() Snapshot {
ConnectionsFaulted: m.ConnectionsFaulted.Load(),
LatencyApplied: m.LatencyApplied.Load(),
HTTPResponsesInjected: m.HTTPResponsesInjected.Load(),
TLSInterceptRejected: m.TLSInterceptRejected.Load(),
UpstreamErrors: m.UpstreamErrors.Load(),
BytesToUpstream: m.BytesToUpstream.Load(),
BytesToClient: m.BytesToClient.Load(),
Expand Down Expand Up @@ -177,6 +180,19 @@ func (m *Metrics) HTTPInjected() {
}
}

// TLSRejected records an HTTPS connection on which the client refused the
// injected certificate — either by failing the handshake, or (under TLS 1.3,
// where the server's handshake completes before the client's verdict arrives)
// by abandoning the connection without ever sending a request. A non-zero count
// is the canonical "our CA is not trusted by the target, or the client pins
// certificates" signal. In both cases no response was delivered, so it is
// deliberately not counted as faulted.
func (m *Metrics) TLSRejected() {
if m != nil {
m.TLSInterceptRejected.Add(1)
}
}

// MatchedHost records that a connection carrying the given dependency hostname
// matched a rule. FaultedHost records that a fault was actually applied to it
// (i.e. it passed the probability roll). Empty hosts are ignored.
Expand Down
16 changes: 14 additions & 2 deletions internal/proxy/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,20 +133,32 @@ func isHTTPMethodStart(b byte) bool {
// Content-Type overrides the default); Content-Length and Connection are always
// set by the proxy so they stay correct and the connection closes cleanly.
func writeHTTPResponse(c net.Conn, status int, headers map[string]string, body string) error {
// Normalised the same way as the HTTPS path, so one rule produces the same
// response whether the matched dependency happened to be HTTP or HTTPS. 1xx
// is informational and cannot carry a fault; 204/304 must not carry a body.
if status < 200 || status > 599 {
status = http.StatusServiceUnavailable
}
noBody := status == http.StatusNoContent || status == http.StatusNotModified
reason := http.StatusText(status)
if reason == "" {
reason = "Fault Injected"
}
if body == "" {
if body == "" && !noBody {
body = fmt.Sprintf("%d %s (injected by steadybit transparent-proxy)\n", status, reason)
}
if noBody {
body = ""
}

h := map[string]string{"Content-Type": "text/plain; charset=utf-8"}
for k, v := range headers {
h[textproto.CanonicalMIMEHeaderKey(k)] = v
}
// Proxy-owned headers: keep the framing correct regardless of caller input.
h["Content-Length"] = strconv.Itoa(len(body))
if !noBody {
h["Content-Length"] = strconv.Itoa(len(body))
}
h["Connection"] = "close"

var sb strings.Builder
Expand Down
6 changes: 3 additions & 3 deletions internal/proxy/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ func Test_writeHTTPResponse_defaults(t *testing.T) {
func Test_writeHTTPResponse_customBodyAndHeaders(t *testing.T) {
body := `{"error":"nope"}`
resp := readSynthesized(t, 429, map[string]string{
"content-type": "application/json", // lower-case, should be canonicalized + override default
"Retry-After": "30",
"X-Fault": "injected",
"content-type": "application/json", // lower-case, should be canonicalized + override default
"Retry-After": "30",
"X-Fault": "injected",
}, body)
defer resp.Body.Close()

Expand Down
78 changes: 70 additions & 8 deletions internal/proxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package proxy

import (
"context"
"errors"
"log/slog"
"net"
"net/netip"
Expand All @@ -18,6 +19,7 @@ import (

"github.com/steadybit/transparent-proxy/internal/fault"
"github.com/steadybit/transparent-proxy/internal/metrics"
"github.com/steadybit/transparent-proxy/internal/tlsinject"
)

const keepAlivePeriod = 30 * time.Second
Expand Down Expand Up @@ -63,6 +65,13 @@ type Server struct {
// ConnectionsMatched under load is the canonical silent-no-op signal.
Metrics *metrics.Metrics

// TLSInject, when non-nil, enables HTTPS response injection: a matched TLS
// connection carrying an L7 status fault is terminated with a certificate
// minted for its SNI, and the synthesized response is written inside TLS.
// Nil (the default) means TLS is never decrypted — HTTPS connections are
// spliced through untouched exactly as before.
TLSInject *tlsinject.CA

// listenPort and localAddrs are captured at Serve time for the self-loop
// guard, so a redirected flow resolving back to this proxy (on loopback or
// any local interface address) is refused rather than dialed in a storm.
Expand Down Expand Up @@ -249,15 +258,68 @@ func (s *Server) handle(ctx context.Context, client *net.TCPConn) {
}

// L7: synthesize an HTTP status response without contacting the upstream.
// Only valid for cleartext HTTP; ignored otherwise.
if proto == protoHTTP && action.HTTPStatus != 0 {
markFaulted()
if err := writeHTTPResponse(client, action.HTTPStatus, action.HTTPHeaders, action.HTTPBody); err != nil {
log.Debug("failed to write injected status", slog.Any("err", err))
// Cleartext HTTP is written directly. HTTPS is only decrypted when a CA is
// configured and the client offered an SNI to mint a certificate for;
// otherwise the connection falls through and is spliced untouched, which is
// the pre-CA behaviour.
if action.HTTPStatus != 0 {
switch {
case proto == protoHTTP:
markFaulted()
if err := writeHTTPResponse(client, action.HTTPStatus, action.HTTPHeaders, action.HTTPBody); err != nil {
log.Debug("failed to write injected status", slog.Any("err", err))
}
s.Metrics.HTTPInjected()
log.Info("injected http status", slog.Int("status", action.HTTPStatus))
return

case proto == protoTLS && s.TLSInject != nil && identity != "":
// Counted from the delivery callback, not after ServeForged returns: an
// HTTP/2 client keeps the connection pooled for the whole attack, so
// counting on return would report a working fault as "matched but never
// faulted" — the proxy's own silent-no-op signature.
err := s.TLSInject.ServeForged(ctx, client, prefix, tlsinject.Request{
Response: tlsinject.Response{
Status: action.HTTPStatus,
Body: action.HTTPBody,
Headers: action.HTTPHeaders,
},
HandshakeTimeout: s.peekTimeout(),
OnDelivered: func() {
markFaulted()
s.Metrics.HTTPInjected()
log.Info("injected https status", slog.Int("status", action.HTTPStatus))
},
})

var rejErr *tlsinject.RejectedError
if errors.As(err, &rejErr) {
// The client rejected our certificate, so the fault never applied —
// counted separately from faults, never as one. This is the signal
// that the CA is missing from the workload's truststore.
s.Metrics.TLSRejected()
log.Warn("client rejected the injected certificate; is the CA trusted by the target?",
slog.String("stage", rejErr.Stage),
slog.Any("err", err))
return
}
if err != nil {
// The connection was taken over and cannot be forwarded now, so it
// ends here either way. Count it so matched still reconciles with the
// outcome counters instead of silently losing a connection.
s.Metrics.Dropped()
if errors.Is(err, tlsinject.ErrNotDelivered) {
// Teardown while the connection was open: expected, not a failure.
log.Debug("interception ended without delivering a response", slog.Any("err", err))
} else {
log.Warn("failed to serve injected https response", slog.Any("err", err))
}
return
}
// Success is reported by OnDelivered above, which fires when the
// response is written rather than when the connection ends.
return
}
s.Metrics.HTTPInjected()
log.Info("injected http status", slog.Int("status", action.HTTPStatus))
return
}

s.forward(ctx, log, client, dst, prefix)
Expand Down
Loading
Loading