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 four 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. |
| Env-named PEM | caCertPemEnv/caKeyPemEnv (config block, POST /intercept, FFI) |
The names of two environment variables holding the PEMs, read when the listener starts — for a secret store that delivers secrets only as environment variables (ECS/Fargate). An unset variable fails the start, naming it. In a config-file block, use names other than RIFT_INTERCEPT_*: those are the flags' own variables, and a block refuses to share the listener with a flag. |
Each pair is both-or-neither, and only one pair may be supplied. 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.
A CA that exists before anything starts. A SUT in its own container (ECS/Fargate task, compose service) reads its truststore once at TLS init, so the CA has to be in its image or task definition before either side boots — the generated per-listener CA arrives too late. Make one offline with the binary itself; no listener or admin API is involved:
rift intercept-ca generate --out-dir ./ca # ca/ca-cert.pem + ca/ca-key.pem (0600)
rift intercept-ca export --cert ./ca/ca-cert.pem --format jks --out ./truststore.jks \
--merge-system-cas /etc/ssl/certs/ca-certificates.crt # the CA plus the public roots
rift --intercept-port 8080 --intercept-ca-cert ./ca/ca-cert.pem --intercept-ca-key ./ca/ca-key.pem
Bake the certificate (or the exported truststore) into the SUT image, and give rift the pair — as
files, or as RIFT_INTERCEPT_CA_CERT_PEM/RIFT_INTERCEPT_CA_KEY_PEM from a secret store. Merge the
public roots unless the SUT talks to nothing real: a JVM pointed at a store holding the intercept CA
alone loses every public root. --merge-system-cas takes a PEM bundle; the JDK's own cacerts
is a keystore, not PEM. See rift intercept-ca.
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:
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 address the admin server bound: with no --host that is every
interface ([::] dual-stack, or 0.0.0.0 on a host without IPv6) — 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.
Under --local-only the flag listens on 127.0.0.1 and [::1], as the admin API and /metrics
do, and the proxy refuses every client that is not loopback (#1572). The refusal applies to every
listener the server starts, however it was started: one brought up at runtime with
POST /intercept {"host": "0.0.0.0"} binds that address but closes an off-host client at accept,
before the CONNECT is read, as an imposter naming its own host does. Refusals are counted in
rift_blocked_connections_total{listener="intercept"}.
(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.
the intercept demo runs this end to end: a
SUT container behind HTTPS_PROXY, a CA made with rift intercept-ca generate, and a datafile
served from disk that a reload swaps.
{
"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, inline caCertPem/caKeyPem, or env-named caCertPemEnv/caKeyPemEnv),
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. Each flag has aRIFT_INTERCEPT_*environment variable that counts as the flag, so a block that takes its CA from the environment names other variables withcaCertPemEnv/caKeyPemEnv. - No
returnCaKey. A config file has no response to return a generated CA key in, so"returnCaKey": truefails the load, naming the key (rift-lintE050);falseor absent loads. Bootstrap a CA over the admin API instead. - Runtime rules still layer on top.
POST /intercept/rulesadds to the config-seeded set,PUT /intercept/rulesreplaces both with its body, 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.- Rules reload; the listener does not.
POST /admin/reloadreplaces the rules this file installed with the block's currentrules, keeping rules added at runtime after them, provided the block still describes the running listener (host,port,auth, CA source). A change to any of those, a listener stopped withDELETE /intercept, or one started by the flags or at runtime leaves the rules alone and says why in the response'swarnings; restart to apply a listener change. See Hot reload. - 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"?: "...",
"caCertPemEnv"?: "VAR", "caKeyPemEnv"?: "VAR",
"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. Four mutually-exclusive options: none (a fresh CA is generated),
caCertPath/caKeyPath(PEM files on the engine's filesystem),caCertPem/caKeyPem(inline PEM bytes in the request body — issue #593), orcaCertPemEnv/caKeyPemEnv(the names of two environment variables that hold the PEMs). 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 only one pair may be given. 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:
The key is returned once, in this response only — GET /intercept never carries it. Combining
returnCaKey with any supplied caCert*/caKey* is a 400 (it would otherwise let a caller echo
back an arbitrary keypair from the engine's filesystem). Absent returnCaKey, the response carries
no CA fields, exactly as before. Security: caKeyPem is CA private-key material — treat the
response as a secret, transport it over the --api-key-gated admin plane only, and prefer a
pre-provisioned CA where policy requires the key never transit the API.
- DELETE discards the CA along with the listener, so a later POST without a CA source mints a
fresh CA — re-export /intercept/ca.pem (below), or bootstrap with returnCaKey and supply
the pair back via caCertPem/caKeyPem, after any restart.
- All three verbs are gated by --api-key like every other admin route.
- The rule and CA routes below (/intercept/rules, /intercept/ca.pem, /intercept/truststore.*)
answer 404 with intercept listener not running when 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_replace_rules,rift_intercept_clear_rules,rift_intercept_export_truststore, andrift_intercept_ca_pem— add, list, replace and clear rules, 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, and an array expectation needs each of its
elements among them, as for an imposter's repeated keys.
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 —POSTandPUT /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 } }
}'
The imposter receives the request with the Host the SUT sent (falling back to the CONNECT
authority when it sent none), so its stubs can match on the intercepted host and its recorded
requests show which host was dialed — one imposter can stand in for several hosts with a
{"equals": {"headers": {"Host": "cdn.example.com"}}} predicate per host. The proxy's own
Proxy-Authorization credential is not passed on. requestFrom is the proxy's loopback address.
The target defaults to a plain-HTTP imposter in the same process. To reach one elsewhere — another container on the compose network, a second container in the same ECS task, a TLS-only vendor mock — name it:
curl -X POST http://localhost:2525/intercept/rules -d '{
"host": "cdn.example.com",
"action": { "forward": { "host": "mock-svc", "port": 4600, "scheme": "https" } }
}'
forward key |
Meaning | Default |
|---|---|---|
port |
The imposter's port, 1–65535 | (required) |
host |
A hostname, an IPv4 literal or a bracketed IPv6 literal ([::1]) — the host only, no scheme, port or path |
127.0.0.1 |
scheme |
http or https |
http |
An https target is verified with the listener's outbound trust — the same --upstream-ca-file
(upstreamCaFile/upstreamCaPem) and --upstream-tls-skip-verify the WebSocket relay uses. An
imposter with a self-signed certificate therefore needs its CA there, or skip-verify; an untrusted
target answers the SUT 502, like any failed forward. The imposter still sees the SUT's Host.
Any other key in forward is refused with 400 (and fails a config file's startup), as is a
target that cannot be dialled: port 0, or a host carrying a scheme, port or path. An engine
older than this ignores host and scheme and forwards to 127.0.0.1, so an SDK that needs them
should read the rule back from GET /intercept/rules: an engine that supports them echoes them.
| 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. |
PUT /intercept/rules |
Replace the whole set with one rule (object) or many (array) in a single swap; 200 with the stored set. An empty array clears. A refused body (bad JSON, scripted rule, over 10,000 rules) leaves the old set in place. |
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).
Rules match first-to-last and the first match wins, so appending a rule can never override one
that is already installed. To change a rule — point a host at a different imposter, swap a served
body — PUT the whole new set. The swap is atomic: a request in flight is matched against the old
set or the new one, never an empty store, which a DELETE followed by a POST cannot promise.
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=).
Each leaf the listener mints names the host in its subject and carries the extensions strict
X.509 verifiers require — an Authority Key Identifier matching the CA's Subject Key Identifier, its
own SKI, and CA:FALSE — so it verifies under openssl verify -x509_strict and under Python 3.13+,
whose default ssl context enables VERIFY_X509_STRICT. This holds for a generated CA and for one
you supply (--intercept-ca-cert/caCertPath/caCertPem), provided that CA carries an SKI.
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.