Fix signed-webhook breakage: X-Forwarded-* headers + plain-HTTP proxy mode - #94
Conversation
… mode Twilio webhooks through a tunnel returned 403 signature failures. Root causes at the edge (the body itself was already byte-faithful): - Port 80 301-redirected every request. Providers configured with an http:// webhook URL either drop the POST to a GET or re-sign against the redirect target, so signature validation can never pass. - No X-Forwarded-For/Proto/Host headers were added, so backends could not reconstruct the public URL without a manual base-URL override. Changes: - forward_http now sets X-Forwarded-For (appending to inbound chains), X-Forwarded-Proto and X-Forwarded-Host at the trust boundary. - New [server] plain_http_mode = "proxy" | "redirect" (default "redirect"): proxy mode forwards plain-HTTP requests whose subdomain resolves to a tunnel (ngrok parity) and 308-redirects the rest. - Redirects are now 308 (method/body-preserving) instead of 301, and the Location points at the HTTPS listener port instead of echoing the original port. - Integration suite: Twilio-style HMAC-SHA1 signed POST through the full proxy chain, validated backend-side from raw received bytes, over both edges; 308 fallback coverage; unit tests for the new header and redirect logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dening - plain-HTTP gate now uses a new side-effect-free TunnelCore::has_http_route instead of resolve_http, which dispatched a member and double-counted request metrics for every port-80 request. - IP rate limiting hoisted to both listener entry points so the gate probe is throttled and requests are counted exactly once. - Redirect Location is clamped to the configured domain — an arbitrary Host was an open redirect that 308 would forward method+body to. - X-Forwarded-For docs now state only the rightmost entry is edge-verified. - TestServerOpts gains plain_http_mode; new integration test proves redirect mode 308s even for registered tunnel hosts. - Drop stale ACME HTTP-01 claim from deploy/server.toml (DNS-01 only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marvin-agent-rockflow
left a comment
There was a problem hiding this comment.
Hermes Agent Review
Head: 6111130 · Files: 14 · +749 / -20
Verdict: Approve
Solid fix for the Twilio/signed-webhook breakage. The root causes (301 method downgrade + missing X-Forwarded-*) are correctly identified, and the second commit hardens the first-pass issues well (open redirect under 308, gate probe side effects via has_http_route, IP rate-limit hoisted so the existence check is throttled and not double-counted).
Critical
- None
Warnings
- None blocking
Suggestions
- Domain clamp is case-sensitive (
redirect_to_https/extract_subdomain): Host headers are case-insensitive per RFC 9110. A mixed-caseHostlikeMyApp.Edge.Rustunnel.comfailsends_with(".{domain}")and gets clamped to the bare domain, breaking an otherwise-valid 308. Same pre-existing pattern inextract_subdomain. Consider ASCII-lowercase before compare (low practical risk — most clients send lowercase — but cheap to fix). - WebSocket path still skips
set_forwarded_headers(handle_ws_upgrade): pre-existing; only matters if backends behind WS tunnels rely onX-Forwarded-*. Not needed for the webhook goal of this PR. - Stale ACME HTTP-01 wording may still linger in
docs/docker-deployment.md(this PR correctly dropped it fromdeploy/server.toml). Optional doc sweep.
Looks good
plain_http_modedefault staysredirect(no surprise prod behavior); deploy configs opt intoproxyexplicitly with clear comments.X-Forwarded-Proto/Hostoverwritten at the trust boundary;X-Forwarded-Forappend + rightmost-trusted docs match real reverse-proxy practice.- Body is never re-serialized — correct invariant for HMAC providers.
- 301 → 308 is the right status for method/body-preserving redirects; Location now targets the HTTPS listener port.
- Open-redirect clamp is important once 308 is in play (method+body would follow to attacker Host).
- Integration suite (
webhook_fidelity.rs) is excellent: raw-byte capture, Twilio-style HMAC-SHA1, both edges, plus redirect-mode never-proxies and 308 fallback coverage. - Unit tests cover header append/overwrite, redirect status/port, and domain clamp.
Automated hourly review by marvin-agent-rockflow (Hermes). Will re-review only if new commits land.
| // Only redirect within our own domain — an arbitrary Host here would | ||
| // make this an open redirect (and, with 308, forward method + body to | ||
| // an attacker-chosen destination). | ||
| if name != domain && !name.ends_with(&format!(".{domain}")) { |
There was a problem hiding this comment.
💡 Suggestion (nit): This domain membership check is case-sensitive. Host values are case-insensitive in practice/RFC 9110 — e.g. MyApp.Edge.Example.com would fail ends_with(".{domain}") and get clamped to the bare domain, producing a surprising Location. Same pattern exists in extract_subdomain. Consider comparing lowercased forms (and/or lowercasing once at Host parse). Low practical risk; not blocking.
Why
Twilio webhooks pointed at a rustunnel tunnel failed signature validation (403) while ngrok worked immediately. Investigation (with a local byte-fidelity echo test) showed the request body is already byte-faithful through the tunnel — the breakage is at the edge:
http://webhook URL either has its POST degraded to a GET or re-signs against the redirect target — signature validation can never succeed across that hop.X-Forwarded-For/X-Forwarded-Proto/X-Forwarded-Hostwere added, so backends couldn't reconstruct the public URL that providers sign (this is why a manual base-URL override was needed at all).What
forward_httpsetsX-Forwarded-For(appended to inbound chains),X-Forwarded-Proto, andX-Forwarded-Host; spoofed inbound Proto/Host are overwritten at the trust boundary.[server] plain_http_mode = "proxy" | "redirect"(defaultredirect, so no behavior change until enabled).proxyforwards plain-HTTP requests whose subdomain resolves to a tunnel — ngrok parity, makinghttp://webhook URLs work — and 308-redirects the rest. Enabled indeploy/server.tomlanddeploy/local/server.toml.Locationtargets the HTTPS listener port instead of echoing the plain-HTTP port.Tests
tests/integration/webhook_fidelity.rs: Twilio-style HMAC-SHA1-signed form POST through the full proxy chain (both edges), captured as raw bytes by the local service and re-validated backend-side using a URL reconstructed fromX-Forwarded-*— encoding the "proxy must not change bytes" invariant. Plus 308 fallback coverage.cargo fmt,clippy -D warnings, and the full workspace suite (22 suites) pass locally.Rollout
Edges need
plain_http_mode = "proxy"added to theirserver.tomlwhen this ships (config default staysredirect).🤖 Generated with Claude Code