Skip to content

egress: an "inject" rule intercepts only the claims that inject on the host; query and body credentials - #309

Merged
CMGS merged 11 commits into
mainfrom
feat/conditional-intercept
Oct 4, 2026
Merged

CMGS merged 11 commits into
mainfrom
feat/conditional-intercept

Conversation

@CMGS

@CMGS CMGS commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #308 (core plus bounded body substitution; WebSocket, HTTP/2 inside interception, and the rollout notes are out of scope).

What changes

Conditional interception. A pool rule's intercept takes true, false or "inject". An "inject" rule terminates TLS for a CONNECT only when the claim holds a non-empty credential whose hosts match. For every other claim it is a plain rule: its tunnels splice (origin certificate, HTTP/2, WebSocket and pinning intact) and plain HTTP forwards. The operator decides which hosts may be intercepted; the claim's inject entries decide which of them are.

{"host": "*", "intercept": "inject"}

Query and body credentials. inject takes query (a parameter name) as the alternative to header, and then requires a placeholder.

  • The first <query>=<placeholder> pair of the URL gets the value, URL-escaped. The placeholder may arrive percent-encoded, and every other byte of the query stays as sent.
  • With body: true (opt-in per credential) and no placeholder in the URL, the first such pair of an application/x-www-form-urlencoded body, or the first top-level string member of that name holding the placeholder in an application/json body, is filled instead. The body must have a known Content-Length of at most 64 KiB; any other body streams unchanged.
  • One fill per credential bounds how much a request can grow.
"FB": {"value": "EAAB…", "guest": false,
       "inject": {"hosts": ["graph.facebook.com"], "query": "access_token", "body": true, "placeholder": "@FB@"}}

Echo scrub. On a request that carried a query credential, the value reaches the guest as the placeholder in every response header and in the response body. This covers the value as sent, query- or path-escaped, and with / written \/ as JSON may (a redirect's Location, a Graph paging.next link, an echoed URL).

  • The proxy drops the guest's Accept-Encoding, Range and If-Range on such a request, so the transport decodes compression itself and no range splits a value.
  • The body streams with only a tail that may begin a match held back, so event streams stay live.
  • header credentials are not scrubbed: origins do not reflect them, and scrubbing them would put decompression and a scan on every streamed model response.

Telemetry. The egress usage event names the injected credentials in secret (gh,claim:<entry>), as the audit line does.

Design decisions

  1. An "inject" rule cannot carry secret. It acts as a plain rule for claims without a credential, and Eval deliberately skips always-intercept rules so a pool secret never rides a spliced or plaintext request. Refusing secret on "inject" lets Eval match the rule as a plain allow. A pool secret such as a gateway key stays on its own rule, unchanged.
  2. Modes are ranked false < "inject" < true. EvalHost picks the highest mode among the host's rules (the first rule on a tie), and EvalInner admits only rules of that mode. So [{"*","inject"}, {"api.github.com", true, secret, GET}] keeps that host's secret and method gate for every claim, whatever the order.
  3. The SOCKS5 door treats an inject rule as plain. It never injects, so holding a credential never removes a route.
  4. The query is rewritten pair by pair, not through URL.Query()/Encode. Those re-sort and re-escape the whole query. Names and values are compared unescaped (@ as %40, user[token] as user%5Btoken%5D), and only the filled value's bytes change. A form body uses the same routine.
  5. One fill per credential. A guest repeating the placeholder cannot make the host expand every copy. Before this, 3600 pairs in a 64 KiB form with an 8 KiB credential allocated about 29 MB per request.
  6. The echo scrub covers bodies. A query key comes back in a redirect's Location and, on the Graph API, in every paging link of the response body. A header-only scrub would hand the guest the key on the first paged call.
  7. JSON fills only top-level members named query. It never fills any string equal to the placeholder, and never a nested member, so a guest cannot choose a field where the key lands and is read back.
  8. A value inside another is scrubbed whole, also across reads. Echo forms are matched longest first, so a filled app id or app secret never matches inside an app token (id|secret) within one read. A match near the end of a read waits whenever an unfinished echo starts at or before it (what the held-back tail check already computes), so prefix, middle and suffix containment are all safe at any read boundary; the hold stays under one echo length.
  9. No new Secrets method. The tunnel decision calls the existing Credentials(host), which returns nil with 0 allocs when nothing matches. It allocates only on a match, which then takes the intercept path.
  10. Per-CONNECT semantics. A write that adds or removes an entry applies to the guest's next connection; a tunnel the guest keeps open stays as it was. This is documented.

Known limits (documented, or for the client side):

  • Interception stays HTTP/1.1, so gRPC clients fail on a host the claim holds a credential for.
  • The scrub does not cover:
    • a value the origin transforms beyond URL escaping or a JSON \/ (including JSON escapes of a quote, backslash or tab, and HTML-safe &, none of which a query key contains);
    • a value the origin stored earlier and returns on a request without a credential.
  • A client computing a derived proof from the token (Graph appsecret_proof) cannot do so from a placeholder.
  • Guests need the egress root in every trust store their clients read (SSL_CERT_FILE, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS).
  • Turning "inject" on for an already-served pool key is refused like true, since goldens must carry the root.

Hot path

Spliced tunnels and forward requests do no new work. An intercepted request without a claim credential allocates nothing new. The body read runs only for a body credential on a matching request, and the scrub only for a request that carried a query credential.

Per CONNECT (BenchmarkTunnelDecision, 0 allocs in every arm):

rule host ns/op
plain any 45–48
"inject" no credential 72
"inject" credential 43
true any 22–24

The production claimSecrets.Credentials costs 2 ns with no inject entries, and 586 ns for a claim with 64 entries and no match. The inject view is a flat slice, with no map lookup per entry.

Credential and scrub paths: the last two commits against 4c3a012, six interleaved runs (A,B,B,A,A,B), benchstat p=0.002:

benchmark 4c3a012 head
query credential: fill, scrubber, header scrub 1.57 µs, 34 allocs 0.79 µs, 17 allocs
form body fill 1.94 µs 1.14 µs
JSON body fill 2.32 µs 1.41 µs
64 KiB scrubbed page 7.70 µs, 24.8 KB 3.74 µs, 160 B
event stream, 256 events, scrubbed 49.5 µs 17.2 µs (unscrubbed: 7.7 µs)
Credentials, 64 entries, no match 1006 ns 586 ns

How the scrub got there:

  • It builds one scrubber per request, with the escaped forms and a first-byte table computed once.
  • It reads straight into one pooled 32 KiB buffer, split between the bytes read and the bytes scrubbed (16 KiB each, a TLS record).
  • It checks a held-back tail only where a form can start.
  • The header scrub uses Contains + ReplaceAll, which allocates nothing when nothing matches.

Tests

  • egress
    • A value contained in another (prefix, middle or suffix) is scrubbed whole at every read boundary; a JSON key written with escapes (access\u005ftoken) is filled.
    • An "inject" rule intercepts only for a claim with a non-empty credential and splices otherwise (origin certificate on the spliced leg). The SOCKS5 door splices through it.
    • An always rule outranks an earlier inject rule for its host, secret and method gate included.
    • Query substitution:
      • keeps every other byte and fills only the first pair;
      • an encoded parameter name is filled;
      • the echo is scrubbed from Location and from a gzip-negotiated body;
      • a Range request cannot split the value.
    • Form and JSON bodies:
      • filled with a correct length;
      • JSON only in the first top-level member of the name;
      • other media types and oversized bodies unchanged;
      • 3600 repeated pairs grow the form by one value.
    • The streaming scrub:
      • replaces across chunk boundaries and holds back only a possible match;
      • covers the escaped forms;
      • replaces a value that contains another whole.
    • InterceptMode JSON round trip and refusals; Validate refuses secret on "inject".
  • types: exactly one of header and query; query needs a placeholder; body needs query; equality sees query and body changes.
  • pool: a header and a query+body entry ride the env write into the credential; the usage event names the credential.
  • config: "inject" parses, and needs egress_ca like true.

Review and evidence

Review. Two rounds of reuse/simplification/efficiency/altitude and per-file style ledgers, plus three adversarial correctness passes, ran on this branch; each finding was fixed with a test or skipped with a recorded reason.

  • The control plane's end-to-end review of f1bee3c found the Range, escaping and nested-JSON issues fixed in 4c3a012, and verified 4c3a012 on its own isolated instance.
  • A separate review found the repeated-placeholder amplification fixed in 539c2d4, and the cross-read and escaped-JSON-key issues fixed in 3e30cda. The control plane's verifier at 940889d found the middle-containment residual fixed in 7faf41d (pairmid bench: 5-6 of 3000 lines leaked before). The reviewer confirmed closure at 7faf41d.
  • A loc-justify round (25-row mechanism ledger, all EARNED) cut the JSON-escaped scrub form and the short-body restore (8b1ad8a) and ended with no cut-list. The final /code + /simplify round (6c91afd, 940889d) is net -7 production lines and performance-neutral (geomean +0.3% over six interleaved runs).

Gates on 7faf41d:

  • GOWORK=off make go-lint: 10 × 0 issues. (5 modules × linux/darwin, plus fmt --diff).
  • asl -forwarder=false ./... is clean on darwin and linux for sandboxd, sdk/go and e2e.
  • go test -race: sandboxd in full on macOS; egress, pool, types and config in a golang:1.27.1 linux/arm64 container.

Bare metal. The bare-metal testbed ran scripts/credentials-e2e.sh, extended here with a second pool whose only rule is {"host":"*","intercept":"inject"}. Result on the head commit:

== credsmoke
  each claim's origin saw only its own credential, the plain claim the guest's placeholder, all the pool secret
  an open event stream reached the guest event by event through the proxy
  value absent from the guest (env, /proc/*/environ, /run, /etc) and blanked in GET env
  rotation applied on the next request, a hibernated claim rotated without waking
  a placeholder credential reached the origin only on the request that carried its placeholder
  uncovered host, pool secret header and guest entry refused with 400; a cleared env stops injecting
  a fork child carries no credential
CREDSMOKE PASS
== audit: claim credentials named, values absent
== inject pool: conditional interception, query and body placeholders
  with no credential both hosts splice: origin certificates, HTTP/2
  the claim with credentials is intercepted on their host only; the other claim still splices it
  query and form/JSON body placeholders filled at the origin; every echo of a value reached the guest as its placeholder
  value absent from the guest; a cleared env splices the next connection
CREDSMOKE INJECT PASS
== restart sandboxd, recheck the surviving claim
  the credential survived a sandboxd restart
CREDSMOKE REATTACH PASS
PASS

The inject leg proves the origin received the value without reading its echo, since the scrub erases echoes:

  • A timestamp query credential turns the origin's /time/valid answer to true.
  • The origin's echoed content-length matches the filled form and JSON bodies.

It also checks:

  • the usage journal names claim:SERP;
  • neither value appears in an egress audit record or the usage journal.

Not covered on hardware: an already-open spliced tunnel staying spliced after a PATCH. It follows from the per-CONNECT decision and is documented.

CMGS added 11 commits October 4, 2026 02:18
…e host, and a credential can fill a query or body placeholder (#308)

A pool rule with intercept "inject" terminates TLS only for a claim
holding a credential for the host and stays a plain rule for every other
claim, so a control plane can bind per-user keys to any host the
operator allows without breaking HTTP/2, WebSocket or pinned clients
for the claims that hold none. Such a rule cannot carry a secret.

inject.query swaps a placeholder query value byte-exactly; body: true
also covers a form or JSON body of known length up to 64 KiB. A value a
response header echoes back reaches the guest as the placeholder. The
egress usage event names the injected credentials.
…ub their echoes, and a JSON body fills only the named member

Review of the first commit: an earlier inject rule shadowed a later
always rule, dropping its secret and method gate; a JSON body filled any
string equal to the placeholder, which a guest could store and read
back; Graph-style paging links echoed a query key in the response body;
an empty credential still intercepted; and the SOCKS door refused a host
the claim injects on. The credentials leg on bare metal now covers the
inject pool.
…nd \/ escapings are scrubbed, and a JSON body fills only top-level members

A guest could fetch a reflected value in pieces with Range, each piece
too short to match; the proxy now drops Range and If-Range with
Accept-Encoding on a request that carried a query credential.
encodeURIComponent-style and PHP/Graph-style JSON echoes escaped past the
scrub. Filling a nested JSON member let the guest choose where the value
lands.
…m or JSON body

A guest repeating the placeholder made the proxy expand every copy into
the full value before sending: 3600 pairs in a 64 KiB form with an 8 KiB
credential allocated about 29 MB on the host per request. Filling the
first match bounds the growth by the value length.
…t modes, and a flat inject view

The scrub built a strings.Replacer and its escaped forms per response,
copied every body byte three times through an 8 KiB chunk, and searched
for a held-back tail in quadratic time; it now precomputes the forms and a
first-byte table once, reads straight into a pooled 16 KiB half buffer,
and checks a tail only where an old can start. A/B, six interleaved runs:
a query-credential request -49%, a form or JSON fill -42%/-38%, a 64 KiB
scrubbed page -52% and 24.8 KiB to 160 B, an event stream -65%. The
claim inject view drops a map lookup per entry (64 entries, no match:
1004 to 580 ns per CONNECT). Intercept modes are ordered by rank, so
EvalHost picks the highest and EvalInner reuses it; query and JSON fills
skip a document that never names the parameter.
…encoded parameter name is filled

Echo forms were tried in credential-name order, so a filled app id or app
secret matched inside an app token (id|secret) and the rest of the token
reached the guest; the forms now try longest first. The name pre-check
compared the decoded name with raw query bytes and skipped a name a client
percent-encodes, such as user[token].
… an escaped JSON name is filled

Longest-first only ordered whole matches inside one read: when a read
ended right after an app id that also starts the app token, the id was
replaced at once and the token's tail streamed out in the next read. A
match is now held while a longer echo may still complete from it. The
JSON pre-check compared the decoded name with raw bytes and skipped a
key written with escapes; it now skips only a body with no backslash.
…s the JSON-escaped form and the short-body restore

The always rule outranks every other, so EvalHost returns on it again
instead of scanning on (-3% per CONNECT). A JSON-escaped echo differs from
the raw value only for a credential holding a quote, backslash or tab,
which no query key does. A body shorter than its Content-Length fails the
upstream request with or without the restored prefix. The SOCKS docs say
an inject rule splices on 1080.
…horter comments

Proxy.inject returns a scrubber and the test send returns a reply, so each
moves below the block of the type it produces (/code layout). echo becomes
echoForm, no longer shadowed by test locals named echo. Six comments drop
restated facts.
…dsmoke shares one context

The tunnel gate is one condition instead of a switch with an empty case,
the intercept decoder has no fallthrough, and the header scrub calls
ReplaceAll alone (it returns the value untouched on no match). The pool
buffer size is written as the two scrub chunks newScrubReader splits it
into. credsmoke creates one context in main; tests table their named
cases with t.Run and share a chunked reader; docs name the CONNECT cost of
an inject rule and that only all-true intercept policies refuse SOCKS5.
A/B against 8b1ad8a, six interleaved runs: geomean +0.3%, no path moved.
… whole one

The hold only covered a longer echo starting where the shorter match did,
so with B = X+A+Y split right after A the scan wrote X raw, replaced A and
let Y through. A match within the last echo length of a read now waits
whenever an unfinished echo starts at or before it, which is what partial
already finds; the same rule covers the prefix case and drops the held
flag. The egress docs name the JSON escapes of a quote, backslash or tab
as outside the scrub. A/B against 940889d, six interleaved runs: -0.1%.
@CMGS
CMGS merged commit e53f665 into main Oct 4, 2026
2 checks passed
@CMGS
CMGS deleted the feat/conditional-intercept branch October 4, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

egress: intercept a pool host only for claims that inject on it, and substitute placeholders in the query string

1 participant