Skip to content

fix(relay): give cic-flow actuate the egress limits of the Rust http-executor - #180

Open
sinkog wants to merge 1 commit into
mainfrom
feature/relay-163-actuate-egress
Open

sinkog wants to merge 1 commit into
mainfrom
feature/relay-163-actuate-egress

Conversation

@sinkog

@sinkog sinkog commented Nov 14, 2026

Copy link
Copy Markdown
Member

Refs #163

What

makeActuateFn checked only the first URL against the module's egress allow-list and then sent with http.DefaultClient: redirects were followed (a redirect target was never re-checked), the proxy environment was honoured, there was no timeout, and the response was read with io.ReadAll. The production wiring passes a nil doer (cmd/relay/main.go:664), so this was the live path.

  • newEgressClient (new cmd/relay/egress_client.go): a redirect is never followed (the 3xx is returned to the caller), the proxy environment is ignored, connect and request timeouts are set (10 s / 30 s), and every connection is checked at dial time, after DNS resolution, against the blocked address ranges (private, loopback, link-local, unspecified, broadcast; IPv4-mapped unwrapped), the same ranges as the Rust http-executor. A nil doer now defaults to it.
  • Responses over 1 MiB are a typed error {"error": ..., "code": "response_too_large"} and are never held in memory: the declared length is checked first and the read stops one byte past the cap. The cap applies to any injected doer. A blocked address is code: "blocked_address". "error" stays the key guests already read; "code" is additive.
  • testdata/egress-parity/policy.txt is a fixture both executors assert (three limits and 25 address verdicts): TestEgressParity_Fixture in Go and egress_parity_fixture_matches_policy in http-executor. Plain text, so the Rust crate gets no new dependency.

Claim check against the issue

All three acceptance points are covered. The issue's "not checked" point is settled: the production wiring does pass a nil doer. Additions: the existing TestNewCICFlowRegistry_DefaultsDoer pinned http.DefaultClient and is updated; the Rust production policy is https-only while the Go path follows the module manifest (not aligned here, a manifest-level decision).

Behaviour change

The default client no longer dials private, loopback or link-local addresses. A module whose manifest allow-lists an internal host now gets blocked_address. I did not review production manifests; that is the owner's call. Tests build the client with allowPrivate=true (the counterpart of the Rust insecure_allowing); there is no such switch on the production path.

Tests

With the old behaviour emulated (a default-like client, unbounded read) these fail: all three cases of TestActuate_RedirectToNonAllowedHost_NotFollowed, all three of TestActuate_ResponseOverCap_TypedError, TestEgressClient_DialGuard_BlocksLoopback, TestEgressClient_IgnoresProxyEnvironment, TestEgressClient_DialGuard_MalformedAddressNotDialed, TestNewCICFlowRegistry_DefaultsDoer.

They pass on the old behaviour, so they document rather than protect: TestEgressParity_Fixture (it exercises isBlockedIP, which is unchanged) and TestActuate_ResponseAtCap_NotRefused. Mutating the fixture (a limit and one address verdict) fails both the Go and the Rust test.

TestEgressClient_IgnoresProxyEnvironment asserts the transport (Proxy == nil), because Go never proxies loopback, so a request-level check against a local server could not fail.

Local gates. Go (Docker golang:1.26.11): gofmt -s -l empty, go vet ./... clean, staticcheck ./cmd/relay/ clean, go test -count=1 ./... all ok, go test -race ./cmd/relay/ ok, SYMBOLS.md unchanged (no new exported symbol), MANIFEST.sha256 diff empty. Rust (Docker rust:1, --no-default-features): cargo test -p http-executor --locked --lib 29/29, cargo fmt --check, cargo clippy --all-targets -D warnings clean.

Reachability

cmd/relay/main.go:664 newCICFlowRegistry(..., nil) -> newEgressClient(false) -> makeActuateFn (registered at cic_flow_host_funcs.go:85) -> deps.doer.Do -> response cap. No new entry in deadcode ./....

Not covered / notes

  • Parity is partial: limits and address verdicts are shared. "No redirects" is tested on each side separately (the Rust side by client construction, not by the fixture). The https-only vs manifest-level scheme difference remains.
  • The Rust max_request_bytes (request cap) is not mirrored; the issue does not ask for it.
  • Not run locally: the Rust crate with the default rustls feature, coverage-rust, deny-rust. main() was not run.
  • Related: Interface proposal: sign+send host capability for WASM provider modules (R1+R2) #89.
  • The commit carries the developer's Vault signing metadata, added by a global commit hook. No workflow or CI files are touched.

🤖 Generated with Claude Code

…executor

makeActuateFn checked only the first URL against the module's egress allow-list and
then sent with http.DefaultClient, which follows redirects (a redirect target was never
re-checked), honours the proxy environment, has no timeout, and read the response with
io.ReadAll. The production wiring passes a nil doer, so this was the live path.

- newEgressClient: a redirect is never followed (the 3xx is returned), the proxy
  environment is ignored, connect/request timeouts are set, and every connection is
  checked at dial time against the blocked address ranges (private, loopback,
  link-local, unspecified, broadcast; IPv4-mapped unwrapped), which also covers a DNS
  answer pointing at a private address. A nil doer now defaults to it.
- responses over 1 MiB are a typed error (code response_too_large) and are never
  held in memory: the declared length is checked first and the read stops one byte
  past the cap. The cap applies to any injected doer as well.
- typed errors are {"error": msg, "code": code}; "error" stays the key guests read.
- testdata/egress-parity/policy.txt is a fixture both executors assert (the same
  limits and the same blocked/allowed address verdicts): a Go test and a Rust test in
  http-executor, so a drift on either side fails.

The registry default test pinned http.DefaultClient and is updated.

Not aligned: the Rust executor allows only https in its production shape; the Go
path allows http when the module manifest permits it, which is a manifest-level
decision.

Refs #163

---
[signing-metadata]
key = cic-my-sign-key
signature = vault:v1:MEUCIE77Azt26wus6/rJkucMqPXevxJQ75DCjw8cJsmCtwZ9AiEA2omc+J3UvVJCjcw58r8xQ/19CxeGAJQXP6k7DSLwMOg=
hash-algorithm = sha256
digest = KIrQlLKOFiO+gCJDyIPwi9Q2AojDPEdz/qQH1w/l3AI=

[certificate]
-----BEGIN CERTIFICATE-----
MIICBjCCAaygAwIBAgIUSnRMR6RPnEbg296XWPOqq/u5PCwwCgYIKoZIzj0EAwIw
QzELMAkGA1UEBhMCSFUxGTAXBgNVBAoMEENlbnRyYWxJbmZyYUNvcmUxGTAXBgNV
BAMMEENJQyBEZXZlbG9wZXIgQ0EwHhcNMjYwMzIwMTMyMjU5WhcNMjYxMjMxMTMy
MjU5WjBFMQswCQYDVQQGEwJIVTEZMBcGA1UECgwQQ2VudHJhbEluZnJhQ29yZTEb
MBkGA1UEAwwSR2Fib3IgWm9sdGFuIFNpbmtvMFkwEwYHKoZIzj0CAQYIKoZIzj0D
AQcDQgAEIG2CVmTfmLB9pLLclj7YmP2eedAjklpy4LGrU2ijoiy6Xqpuybv7OgJe
i+ez31s65NEV8+X/ByeX1cstR988z6N8MHowCQYDVR0TBAIwADAdBgNVHQ4EFgQU
yZN6AIX/TNnIJ9GwAa/NRN3ujHAwHwYDVR0jBBgwFoAUXn6CHYzPUqU4JVP8g+OS
WeDYjhcwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEF
BQcDBDAKBggqhkjOPQQDAgNIADBFAiEA+bFzXRoJ4PCQbhAAtpkcMjt0vNj5rEW0
lOMBGDNyaWkCIB1vmM7PcZzv/c9bIrxF5kqv6QXomouhByUfeNUTbpKW
-----END CERTIFICATE-----
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.

1 participant