Intercept / Redirect Proxy Mode
Mock an external HTTPS dependency whose target host the system-under-test (SUT) hard-codes — for example a feature-flag SDK that always fetches its config from https://cdn.example.com/config.json. You can’t point such a client at a mock port, so Rift can sit in the request path as a forward proxy, terminate the TLS call, match it with the ordinary predicate engine, and either serve a stub inline or forward it to one of your imposters.
This replaces the usual mitmproxy sidecar (a container, a Python redirect addon, and checked-in CA/key/truststore files) with a couple of Rift admin calls and no committed crypto.
This is the Rift-native mechanism. The
zio-bddMockControlInterceptcapability (EtaCassiopeia/zio-bdd#219) wraps it for BDD tests viaEmbeddedRift.
Looking for the other direction? The CA on this page is Rift’s own, so a system under test can trust Rift. If instead Rift is failing to reach a real origin with
invalid peer certificate: UnknownIssuer— typically a corporate API behind a private CA — that is outbound trust, and you want--upstream-ca-file. See TLS/HTTPS → Which certificate are you configuring?.
How it works
- The SUT is pointed at Rift’s intercept listener as its HTTPS proxy (
https.proxyHost/https.proxyPort). - The SUT issues
CONNECT cdn.example.com:443; Rift answers200 Connection Establishedand TLS-terminates the tunnel, minting a per-host leaf certificate on the fly, signed by the intercept CA (generated when the listener starts, unless you supply one). - The decrypted request is matched against intercept rules using the same predicate engine as imposters.
- A matching rule either serves an inline stub or forwards the request to one of your imposters on
127.0.0.1:<port>.
The one constraint TLS-MITM cannot remove: the SUT must trust the intercept CA. Rift automates provisioning that trust (it emits a CA cert and a ready-to-use truststore) — see Trusting the CA.
A few consequences of the design:
- Rules match the
CONNECThost, not the SNI or theHostheader of the decrypted request. - The leaf certificate is minted for the SNI the client sends, and cached (up to 1,024 hosts, least-recently-used evicted). A client that sends no SNI — typically one connecting to an IP literal — fails the handshake, because there is no name to mint a certificate for.
- The leaf chains to the intercept CA and is served together with it. A client verifies it exactly as it would the real origin’s, so the only thing it needs is the CA in its trust store.
Quick start with curl
Everything below runs against a stock rift on localhost:2525; cdn.example.com never has to resolve, because curl hands the name to the proxy in the CONNECT.
# 1. Start the listener on a fixed port, and install one rule.
curl -s -X POST http://localhost:2525/intercept -d '{"port": 8888}'
# {"interceptPort":8888,"interceptUrl":"http://127.0.0.1:8888"}
curl -s -X POST http://localhost:2525/intercept/rules -d '{
"host": "cdn.example.com",
"predicates": [{ "equals": { "path": "/config.json" } }],
"action": { "serve": { "statusCode": 200,
"headers": { "content-type": "application/json" },
"body": { "featureX": "ON" } } }
}'
# 2. Export the CA the SUT has to trust.
curl -s http://localhost:2525/intercept/ca.pem -o rift-ca.pem
# 3. Call the "real" host through the proxy.
curl --proxy http://127.0.0.1:8888 --cacert rift-ca.pem https://cdn.example.com/config.json
# {"featureX":"ON"}
# A path no rule matches still gets an answer — the fixed fall-through:
curl --proxy http://127.0.0.1:8888 --cacert rift-ca.pem https://cdn.example.com/other
# rift intercepted GET /other for cdn.example.com
# Without the CA the client refuses the forged certificate, as it should:
curl --proxy http://127.0.0.1:8888 https://cdn.example.com/config.json
# curl: (60) SSL certificate problem: self-signed certificate in certificate chain
Add --http2 to the calls above to see the tunnel negotiate HTTP/2 (curl -v prints ALPN: server accepted h2). With a proxy credential set, add --proxy-user ci:s3cr3t; without it the CONNECT is answered 407. DELETE /intercept turns it all off again.
The intercept CA
The CA comes from one of three places, chosen when the listener starts:
| Source | How | Notes |
|---|---|---|
| Generated (default) | nothing to configure | An ECDSA P-256 CA named Rift Intercept CA, held in memory. A new one every time the listener starts, so re-export it after a restart — or bootstrap it once with returnCaKey and supply it back. |
| PEM files | --intercept-ca-cert/--intercept-ca-key, or caCertPath/caKeyPath | Paths on the engine’s filesystem. |
| Inline PEM | RIFT_INTERCEPT_CA_CERT_PEM/RIFT_INTERCEPT_CA_KEY_PEM, or caCertPem/caKeyPem | No file or volume needed. |
Each pair is both-or-neither, and the file pair and the PEM pair cannot be combined. A PEM holding several certificates pins the first as the CA (and logs a warning). Nothing ever hands out a private key you supplied: GET /intercept, the CA export and the truststores carry the certificate only.
To bring your own CA, create a proper CA certificate — CA:TRUE, allowed to sign certificates:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3650 \
-keyout rift-ca-key.pem -out rift-ca-cert.pem -subj "/CN=My Test Intercept CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign,digitalSignature"
rift --intercept-port 8888 --intercept-ca-cert rift-ca-cert.pem --intercept-ca-key rift-ca-key.pem
A CA is private-key material that lets its holder forge a certificate for any host your clients trust it for. Keep it out of real trust stores, and never reuse a CA that signs anything real.
Enabling the listener
The intercept listener is an opt-in, embedder-facing API — nothing runs until you start it, so the default imposter-on-a-port model is unchanged. A minimal, complete, runnable example lives in crates/rift-http-proxy/tests/intercept_config_cdn_example.rs (run it with cargo test -p rift-http-proxy --test intercept_config_cdn_example). In outline:
use std::sync::Arc;
use rift_mock_core::proxy::intercept_ca::{CertificateAuthority, SniCertResolver};
use rift_http_proxy::intercept::InterceptListener;
use rift_http_proxy::intercept_rules::InterceptRules;
use rift_mock_core::proxy::OutboundTls;
let ca = Arc::new(CertificateAuthority::generate()?); // or CertificateAuthority::load_pem(cert, key)
let rules = InterceptRules::new();
let resolver = Arc::new(SniCertResolver::new(ca.clone())); // mints one leaf per SNI host
// `None` = no proxy credential (open). Pass `Some(InterceptAuth { .. })` to require
// `Proxy-Authorization: Basic …` on every CONNECT (issue #878).
// The last argument is the trust policy for the one outbound leg the listener has, the
// WebSocket passthrough; `OutboundTls::default()` is the OS trust store.
let listener = InterceptListener::bind(
"127.0.0.1:0".parse()?, resolver, rules.clone(), None, OutboundTls::default(),
).await?;
// Point the SUT at `listener.local_addr()` as its HTTPS proxy, trusting `ca`.
Authenticating the proxy
The listener is open unless you give it a credential. That is deliberate — it is off until you start it, and most uses are a loopback test rig — but be clear about what it is: a TLS-MITM proxy that serves certificates forged by a CA you have asked your clients to trust. On loopback that is fine. Anywhere reachable by others it is not, because reaching the port is the only thing standing between someone and a trusted forged certificate for any host they name.
Set a credential with --intercept-auth user:pass (RIFT_INTERCEPT_AUTH), the auth key on the config-file block, POST /intercept’s body, or rift_start_intercept’s options:
{ "port": 8888, "auth": { "username": "ci", "password": "s3cr3t" } }
Every CONNECT must then carry Proxy-Authorization: Basic <base64(user:pass)>; anything else is answered 407 Proxy Authentication Required with a Proxy-Authenticate challenge — before the TLS handshake, so an unauthenticated caller never causes a certificate to be minted. Standard clients handle this: HTTPS_PROXY=http://ci:s3cr3t@host:8888, curl -x, a JVM Authenticator.
This is a separate credential from the admin --api-key, on purpose. Proxy-Authorization is hop-by-hop and is consumed by the proxy; Authorization is end-to-end and would be forwarded to every intercepted origin, handing your admin key to the very servers you are intercepting.
--require-admin-auth covers this listener too, at every door — the flag, the config block, and a listener started at runtime over POST /intercept (which answers 403 rather than starting an exposed keyless one). A non-loopback bind with no credential warns by default and refuses under the flag. --intercept-auth without --intercept-port is a startup error: the credential would guard nothing, and a listener started later over POST /intercept would be open (pass auth in that body instead).
Embedded hosts get the refusal too, from rift_serve_admin’s requireAdminAuth (issue #1149). The policy lives on the process’s InterceptControl and is shared across its clones, so the C-ABI can state it after the control already exists. Three consequences worth knowing:
- Order matters: call
rift_serve_adminbeforerift_start_intercept. The policy is whatever the most recentrift_serve_adminstated, so a listener started before the first serve is judged under the default (warn). - A serve is refused if an already-running listener is exposed. If a listener came up under the default and you then serve with
requireAdminAuth,rift_serve_adminreturnsNULLnaming the listener rather than reporting a strictness it is not delivering. It does not stop that listener — stop it yourself, or give itauth, and serve again. - A serve that omits
requireAdminAuthreturns the policy to warn. It is the current configuration, not a high-water mark.
A handle that never calls rift_serve_admin has no way to state the policy, and that is fine: with no admin plane and no config file, the only caller that can start a listener is the embedder’s own code, which can pass auth.
To expose the /intercept routes over the admin API, build the admin server with_intercept(control) where control: InterceptControl is the shared lifecycle slot (see Runtime lifecycle below). The standalone rift binary always wires one in, so those routes are available on every server.
Standalone binary
The rift binary starts an intercept listener when --intercept-port is set; its rule store and CA are automatically shared with the admin API, so the /intercept/* routes below configure it:
# Generate a CA in-memory:
rift --intercept-port 8443
# Or load an existing CA from files:
rift --intercept-port 8443 --intercept-ca-cert ca.pem --intercept-ca-key ca.key
# Or pass the CA inline as PEM (issue #593) — env is the intended vehicle:
RIFT_INTERCEPT_CA_CERT_PEM="$(cat ca.pem)" RIFT_INTERCEPT_CA_KEY_PEM="$(cat ca.key)" \
rift --intercept-port 8443
The flag binds the listener on the admin server’s --host, which defaults to 0.0.0.0 — so a bare --intercept-port is reachable off-host, and warns unless --intercept-auth is set. Pass --host 127.0.0.1 for a loopback-only rig, or use the config-file block, which names its own host.
(Equivalently RIFT_INTERCEPT_PORT / RIFT_INTERCEPT_CA_CERT / RIFT_INTERCEPT_CA_KEY, or the inline RIFT_INTERCEPT_CA_CERT_PEM / RIFT_INTERCEPT_CA_KEY_PEM. The file pair and the PEM pair are mutually exclusive — passing both is a startup error. There is no returnCaKey at launch; bootstrap a CA over the admin API instead, so the key is never printed to logs.)
--intercept-port is just an eager start of the same listener the admin API can start at runtime — it is no longer the only way to enable intercept. A server started without the flag still serves the lifecycle endpoints below, so a client can turn intercept on later.
Declare it in the config file
The flags above start a listener with no rules, so a container has to curl the admin API after boot to install them — a bootstrap sidecar that exits 0 (and so crash-loops under Kubernetes’ restartPolicy: Always), and a window where the SUT’s first calls race the rule that isn’t there yet. Instead, put an intercept block next to your imposters in --configfile: the listener binds with its rules already installed, so the server is correct the moment it is ready.
{
"imposters": [
{ "port": 4545, "protocol": "http", "name": "Optimizely datafile",
"stubs": [{ "responses": [{ "is": { "statusCode": 200, "body": "{\"featureX\":\"ON\"}" } }] }] }
],
"intercept": {
"host": "0.0.0.0",
"port": 8080,
"caCertPath": "/certs/rift-ca-cert.pem",
"caKeyPath": "/certs/rift-ca-key.pem",
"rules": [
{ "host": "cdn.example.com", "action": { "forward": { "port": 4545 } } }
]
}
}
rift --configfile /config/optimizely.json # listener up + rules installed; no admin call, no sidecar
The block is the same shape as the POST /intercept body — host, port, the CA fields (caCertPath/caKeyPath or inline caCertPem/caKeyPem), plus rules[] using the rule schema verbatim. Notes:
- Optional and additive. A config file without an
interceptblock behaves exactly as before. - It lives in the wrapper form. Only the
{ "imposters": [...] }object can carry aninterceptblock. Putting one in a single-imposter document ({"port": 4545, ...}) is a startup error naming the fix, never a silently ignored block — add"imposters": [ ... ]around the imposter, using[]if the file declares none. A bare top-level array, and a YAML config (which is the array form), have nowhere to put a block at all. - One source of truth. Supplying the block and any
--intercept-*flag is a startup error rather than a silent precedence guess. Use one or the other. - Runtime rules still layer on top.
POST /intercept/rulesadds to the config-seeded set andDELETE /intercept/rulesclears it;GETlists both. rulesworks over the admin API and FFI too.POST /interceptandrift_start_interceptaccept the same optionalrulesarray, so any surface can start-and-seed in one call.- Boot-only.
POST /admin/reloadre-applies imposters only. When the reloaded file carries aninterceptblock, the response body carries awarningsentry saying it was not re-applied (and the server logs a warning), so an edit to the block never looks applied. Change rules at runtime over the admin API, or restart to re-read the block. - Injection is gated. A rule whose predicates use
injectneeds--allowInjection, exactly as a config-file imposter’s scripting surface does — the file crossed the same trust boundary.
An intercept block also gets EJS preprocessing like the rest of the file, so "host": "<%= process.env.CDN_HOST %>" works.
Runtime lifecycle (admin API)
Start, inspect, and stop the intercept listener at runtime over the admin API — no restart, and no --intercept-port required (issue #493). This is what lets an SDK enable intercept against a server it merely connected to.
POST /intercept body (optional): { "host"?: "127.0.0.1", "port"?: 0,
"caCertPath"?: "...", "caKeyPath"?: "...",
"caCertPem"?: "...", "caKeyPem"?: "...",
"returnCaKey"?: false,
"auth"?: { "username": "...", "password": "..." },
"rules"?: [ ... ] }
→ 201 { "interceptPort": N, "interceptUrl": "http://127.0.0.1:N" }
→ 400 bad host / CA / auth / body (see below)
→ 403 off-host bind with no `auth` under --require-admin-auth
→ 409 if a listener is already running (flag, FFI, or a prior POST)
→ 429 if `rules` exceeds the rule cap (nothing is started)
GET /intercept → 200 { "interceptPort": N, "interceptUrl": "..." } | 404 when not running
DELETE /intercept → 204 always (idempotent); stops the listener and drops its rules + CA
- The body is optional: absent, empty, or
{}all mean defaults (127.0.0.1:0, a fresh in-memory CA). Port0is OS-assigned; read the real port back from the response. - Stopping drains, it does not cut.
DELETE /interceptstops accepting new connections and signals every tunnel already open: a request in flight runs to completion, and an idle tunnel closes immediately rather than lingering until its header-read timeout. The call returns once the accept loop has stopped; it does not block on the last in-flight request. hostis an IP literal: IPv4, or IPv6 written bare ("::1") or bracketed ("[::1]"). A DNS name is refused with a400.- The default bind host is
127.0.0.1— not the admin server’s host. A containerized, connect-transport caller that needs the proxy reachable off-box must pass"host": "0.0.0.0"explicitly. - Supplying a CA. Three mutually-exclusive options: none (a fresh CA is generated),
caCertPath/caKeyPath(PEM files on the engine’s filesystem), orcaCertPem/caKeyPem(inline PEM bytes in the request body — issue #593). Inline PEM lets an SDK hand a containerized engine its CA over the admin API with no volume mount. Each pair is both-or-neither, and the path pair and PEM pair cannot be combined. A half-supplied pair, both pairs together, an unknown field, a bad CA, or an occupied port is a400with the standard error envelope; a supplied private key is never echoed back. - Bootstrapping a CA (
returnCaKey). Set"returnCaKey": true(only when no CA source is supplied) to have Rift mint a fresh CA and return both its cert and key in the201response, so you can persist and redistribute a shareable anchor instead of pre-making one withopenssl:{ "interceptPort": N, "interceptUrl": "...", "caCertPem": "-----BEGIN CERTIFICATE-----…", "caKeyPem": "-----BEGIN PRIVATE KEY-----…" }The key is returned once, in this response only —
GET /interceptnever carries it. CombiningreturnCaKeywith any suppliedcaCert*/caKey*is a400(it would otherwise let a caller echo back an arbitrary keypair from the engine’s filesystem). AbsentreturnCaKey, the response carries no CA fields, exactly as before. Security:caKeyPemis CA private-key material — treat the response as a secret, transport it over the--apikey-gated admin plane only, and prefer a pre-provisioned CA where policy requires the key never transit the API. DELETEdiscards the CA along with the listener, so a laterPOSTwithout a CA source mints a fresh CA — re-export/intercept/ca.pem(below), or bootstrap withreturnCaKeyand supply the pair back viacaCertPem/caKeyPem, after any restart.- All three verbs are gated by
--apikeylike every other admin route. - The rule and CA routes below (
/intercept/rules,/intercept/ca.pem,/intercept/truststore.*) answer404withintercept listener not runningwhen no listener is up.
# Enable intercept on an already-running server, then read back the proxy port:
curl -sX POST http://localhost:2525/intercept
# {"interceptPort":49711,"interceptUrl":"http://127.0.0.1:49711"}
# ...configure rules / export the CA (see below), point the SUT at the proxy...
# Tear it down when done (safe to call unconditionally):
curl -sX DELETE http://localhost:2525/intercept
Embedding over the C-ABI (non-Rust)
A non-Rust host (JVM, Node, Go, Python, …) can start and drive the intercept listener with no loopback HTTP and no Rust code — see FFI (C-ABI).
rift_start_interceptstarts the listener,rift_stop_interceptstops it, and therift_intercept_*control-plane functions —rift_intercept_add_rules,rift_intercept_list_rules,rift_intercept_clear_rules,rift_intercept_export_truststore, andrift_intercept_ca_pem— add rules, list them, export a truststore, and fetch the CA PEM, all over C-ABI. The listener started this way is the same onerift_serve_admin’s/interceptroutes see:rift_start_interceptthenGET /interceptreports it, and a double-start across the two surfaces conflicts consistently (409 /-1).
Configuring rules (admin API)
A rule matches an intercepted request by host (exact, case-insensitive; omit for any) and predicates (the usual Mountebank predicate JSON, AND-ed), and carries one action.
Body predicates see a request body that is not valid UTF-8 (protobuf, gzip, an image upload) as its standard base64 encoding (with padding) — the same convention as binary recorded requests and binary responses. Write the predicate against the base64 string, e.g. { "equals": { "body": "H4sIAAAAAAAA/w==" } }. A valid-UTF-8 (text or JSON) body is matched as-is, unchanged. Forwarding always relays the raw bytes regardless of classification.
A header predicate matches by name, and a repeated request header (the client sent the same name more than once) is one name with several values: the predicate matches if any of those values satisfies it, not only the first or the last. Forwarding to an imposter (action.forward) carries every value of a repeated header along, in the order the client sent them — none are dropped or comma-joined. A header value that is not valid UTF-8 is dropped rather than matched or forwarded, the same as an invalid body byte sequence would be if it broke UTF-8 classification.
Two consequences worth knowing:
deepEqualscompares header names exactly but is permissive about a repeated name’s values — it matches if any one of them satisfies the expectation. There is currently no way to say “exactly these two values and no others” for a single header name.notfollows from the above:{"not":{"equals":{"headers":{"x-test":"first"}}}}matches only when no value for that name isfirst.
One exception, because the scripting boundary is a fixed shape: the request.headers object handed to an inject predicate carries a single value per name, so a script sees only the first value of a repeated header. Declarative predicates (equals, contains, matches, …) see all of them.
injectpredicates require--allowInjection. A rule’s predicates are evaluated on every intercepted request, so aninjectpredicate is executable JavaScript — the same surface--allowInjectiongates on imposter stubs. Without the flag, a rule carrying one (however deeply nested undernot/or/and) is refused with400and the whole request is rejected: a batch containing one such rule stores none of it. This holds on every door that admits a rule —POST /intercept/rules, therulesarray onPOST /intercept, and the--configfileinterceptblock.serveandforwardactions carry no script and are never gated.
Serve an inline stub
curl -X POST http://localhost:2525/intercept/rules -d '{
"host": "cdn.example.com",
"predicates": [{ "equals": { "path": "/config.json" } }],
"action": { "serve": {
"statusCode": 200,
"headers": { "content-type": "application/json" },
"body": { "featureX": "ON" }
}}
}'
body takes any JSON value, exactly like is.body on an imposter stub. A string is served verbatim — byte for byte, no re-quoting — so the equivalent "body": "{\"featureX\":\"ON\"}" above puts the same bytes on the wire. Any other value (object, array, number, boolean) is rendered as compact JSON, once when the rule is stored rather than on each intercepted request. Omitting body, or sending null, serves an empty body with content-length: 0. Note that body does not set content-type for you: an object body still needs "content-type": "application/json" in headers if the SUT checks it.
statusCode takes a number or a numeric string ("418"), and headers takes one value or many per name — the same forms an imposter stub’s is.statusCode / is.headers accept:
curl -X POST http://localhost:2525/intercept/rules -d '{
"host": "cdn.example.com",
"action": { "serve": {
"statusCode": "418",
"headers": { "set-cookie": ["a=1", "b=2"], "content-type": "application/json" },
"body": { "featureX": "ON" }
}}
}'
Each value of a multi-value header becomes its own header line on the intercepted response — they are never comma-joined, which is what set-cookie requires. A non-string scalar header value ("x-retry": 3) is coerced to its string form, matching what Mountebank recorders emit.
GET /intercept/rules returns the body in the shape you posted — an object stays an object, not its rendered string. A single-value header lists back as a plain string rather than a one-element array, and a statusCode given as a string lists back as a number, since that is what the rule store holds.
Forward to one of your imposters
# The SUT's HTTPS call is decrypted and forwarded to the imposter on 127.0.0.1:4545,
# which does its own predicate/space matching and returns the response.
curl -X POST http://localhost:2525/intercept/rules -d '{
"host": "cdn.example.com",
"action": { "forward": { "port": 4545 } }
}'
| Verb & path | Effect |
|---|---|
POST /intercept/rules | Add one rule (object) or many (array). Rejected with 429 Too Many Requests once the store holds 10,000 rules — DELETE rules before adding more. |
GET /intercept/rules | List all rules |
DELETE /intercept/rules | Remove all rules |
The rule store is capped at 10,000 rules to bound both memory and the per-request match scan; a batch POST that would exceed the cap is rejected in full (no partial add).
When no rule matches, the request falls through to a default 200 with a text/plain body rift intercepted <METHOD> <path> for <host>, so an unconfigured host is answered rather than hanging. The exception is a WebSocket handshake, which is relayed to the real origin instead (see WebSocket passthrough).
Other answers the tunnel itself can give, before or instead of a rule:
| Status | When |
|---|---|
413 | The request body exceeds 1 MiB (see Limitations). |
408 | The request body did not arrive within 30 seconds. |
400 | The request body could not be read to the end (e.g. broken chunked framing). |
502 | A forward rule’s imposter could not be reached, or a WebSocket relay failed. |
Connection reuse
This section describes HTTP/1.1. Over HTTP/2 the equivalents are per-stream rather than per-connection: a refused or reset request affects its own stream and leaves the others running, so none of the leftover-body framing below applies. A tunnel serves at most 32 concurrent h2 streams, which is what keeps the 1 MiB body cap a per-tunnel bound of 32 MiB rather than an unbounded multiple of it.
A CONNECT tunnel is keep-alive: once it is established, a client may send any number of requests over it, and a pooling HTTP client will do so by default. Each request is matched against the rules independently — reuse changes nothing about which rule fires. This matters mostly for cost: the TCP connect, the CONNECT round-trip, the TLS handshake and the per-SNI leaf certificate are paid once per tunnel rather than once per request.
The pre-tunnel refusals are the exception: 405 for a non-CONNECT request and 407 when proxy auth fails are answered before any tunnel exists, so there is nothing to reuse and they still close the connection.
A request refused with 413 for exceeding the body cap is the interesting case, because the rest of its body was never read off the socket. Those leftover bytes are never framed as the following request, but which way that is achieved depends on how many of them there are: the server makes one attempt to discard the remainder, and if it does not all go at once it closes the connection’s read side instead, so nothing further is served on that tunnel. A small overshoot therefore usually keeps the tunnel usable and a large upload usually ends it — either way the leftover bytes are unreachable.
This is why the cap had to become a refusal (rather than a silent truncation) before keep-alive could be enabled: a truncating reader that left the tail in the socket would have handed the client control of what the server parsed next.
Connection limits and idle tunnels
The listener honours RIFT_MAX_CONNECTIONS, the same per-listener cap every other Rift listener applies. At the cap it stops accepting, so excess connections wait in the kernel backlog rather than being accepted and then failed.
A tunnel that completes its TLS handshake and then sends no request is closed after RIFT_HTTP_HEADER_TIMEOUT rather than being held open indefinitely — the protocol-detection window at the start of a connection is bounded, not just the request head that follows it.
That holds for an HTTP/2 tunnel too. A peer that sends the HTTP/2 preface — completing detection — and then goes quiet has no request head for a timer to bound, so the tunnel is closed by an HTTP/2 keep-alive ping instead, within two RIFT_HTTP_HEADER_TIMEOUT intervals. Idle h2 tunnels are therefore pinged at that interval; a live client answers and is unaffected.
Connections dropped before that HTTP/1-or-HTTP/2 decision is made are counted in rift_preface_failures_total{listener="intercept", kind="timeout"|"eof"|"io"}, present at zero from the moment the listener starts. timeout and eof are ordinary client behaviour; a rising io count is the one worth alerting on.
Trusting the CA from the SUT
Export the CA and a ready-to-use truststore — nothing crypto needs to live in your repo:
curl http://localhost:2525/intercept/ca.pem -o rift-ca.pem # PEM
curl "http://localhost:2525/intercept/truststore.p12?password=changeit" -o ts.p12 # PKCS#12
curl "http://localhost:2525/intercept/truststore.jks?password=changeit" -o ts.jks # JKS (JVM)
The truststore endpoints return the store bytes plus an x-truststore-password response header echoing the password used (default changeit, override with ?password=).
JVM SUT — one-line wiring (trust the CA and route HTTPS through the intercept listener):
-Djavax.net.ssl.trustStore=ts.jks -Djavax.net.ssl.trustStorePassword=changeit \
-Dhttps.proxyHost=<rift-host> -Dhttps.proxyPort=<intercept-port>
The JKS store above is -Djavax.net.ssl.trustStoreType=JKS (the JVM default); a PKCS#12 store also loads as a JVM trust anchor by adding the type:
-Djavax.net.ssl.trustStore=ts.p12 -Djavax.net.ssl.trustStoreType=PKCS12 \
-Djavax.net.ssl.trustStorePassword=changeit \
-Dhttps.proxyHost=<rift-host> -Dhttps.proxyPort=<intercept-port>
Other runtimes take the PEM. Most honour HTTPS_PROXY for the route. The trust setting differs per runtime, and some add the CA to the default roots while others replace them:
export HTTPS_PROXY=http://<rift-host>:<intercept-port> # http://user:pass@… with a credential
export NODE_EXTRA_CA_CERTS=rift-ca.pem # Node: adds to the bundled roots
export REQUESTS_CA_BUNDLE=rift-ca.pem # Python requests: replaces them
export SSL_CERT_FILE=rift-ca.pem # OpenSSL-based clients, Go: replace them
With a replacing variable, that client trusts only Rift’s CA. Anything it reaches without the proxy (a NO_PROXY host, say) then fails verification.
What this replaces
The classic mitmproxy setup — a mitmproxy container, a Python request() redirect addon, and committed CA cert + private key + dhparams + JKS truststore — collapses to: generate a CA (or load one), post a rule, and hand the SUT the emitted truststore. Fewer moving parts, no committed private keys, and it works identically for the container and embedded adapters.
WebSocket passthrough
A Connection: Upgrade / Upgrade: websocket request through the tunnel is relayed to the real origin, and the upgraded connection is then pumped in both directions until either side closes (#997). This is a transparency property, not a mocking feature: the tunnel’s contract is that traffic no rule claims reaches the origin unchanged, and a system under test whose traffic includes a WebSocket — socket.io, GraphQL subscriptions, a dev-server’s live reload — would otherwise have that connection silently broken by being routed through the proxy.
- A matching rule still wins. Rules are matched against the handshake as the ordinary HTTP request it is (method, path, headers). If one matches, its response is served and no upgrade happens — which is how you simulate the WebSocket endpoint being down, refusing, or returning a
403. Only when no rule matches is the handshake relayed. - Frames are never inspected, matched or recorded. Rules apply to the handshake only; there is no frame grammar and no frame capture.
- The origin connection uses the same outbound TLS trust as everything else — on the standalone binary, the process-wide policy, so
--upstream-ca-filecovers an origin behind a private CA (see TLS/HTTPS → Trusting a private CA). It offershttp/1.1only, since the upgrade is an HTTP/1.1 mechanism. If the origin declines the upgrade, its response (status, headers and body, minus hop-by-hop headers) is passed back to the client. This is the only outbound connection the intercept listener makes; if it fails, the client gets a502rather than a hang. - Only
websockettakes this path. Any otherUpgradevalue,h2cincluded, behaves exactly as before.
Limitations
- The SUT must trust the intercept CA — this is inherent to HTTPS MITM; Rift only automates provisioning it.
- Not a general mitmproxy replacement — no WebSocket mocking or flow scripting. WebSocket traffic does pass through (see WebSocket passthrough); what is out of scope is terminating an upgrade and scripting the frames, which would be a new imposter protocol with its own frame grammar.
- HTTP/2 is negotiated over TLS via ALPN, the same as the imposter listeners, so a system under test that would speak h2 to the real origin still does through the proxy. Set
RIFT_DISABLE_HTTP2=1to force HTTP/1.1 everywhere if a client misbehaves over h2. Prior-knowledge h2c (cleartext, no ALPN) through the tunnel is not supported. - Request bodies are capped at 1 MiB — a request whose body exceeds the cap is refused with
413 Payload Too Large, and is neither matched against rules nor forwarded. The cap bounds memory use for a misbehaving or malicious upload. BothContent-Length-framed andchunked/streamed request bodies are decoded. - Forward-proxy (
CONNECT) only — transparent interception is not implemented. - The listener now makes outbound connections. WebSocket passthrough is the first and only feature that does: relaying a handshake means rift TCP-connects and TLS-handshakes to the
host:portthe client named in itsCONNECT. That is inherent to a forward proxy, but it is a change in posture — before it, every request was answered locally. In a shared environment bind the listener to loopback and/or setProxy-Authorization(see Authenticating the proxy), or a caller that can reach the proxy can use it to probe rift’s network position. - RFC 8441 extended CONNECT (WebSocket over HTTP/2) is not detected. HTTP/2 forbids the
Connectionheader, so an h2 client using:protocol = websocketfalls through to the ordinary response rather than being relayed. Passthrough covers the HTTP/1.1 upgrade only. - The listener is started by an embedder (or the zio-bdd adapter), from the standalone
riftbinary via--intercept-port(see Standalone binary), or at runtime over the admin API (see Runtime lifecycle) — one listener at a time either way.