diff --git a/Cargo.lock b/Cargo.lock index d5f6446..0ad50ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2563,12 +2563,14 @@ dependencies = [ "arc-swap", "axum", "futures-util", + "hmac", "rcgen", "reqwest 0.12.28", "rustls", "rustunnel-protocol", "rustunnel-server", "serde_json", + "sha1", "sqlx", "tempfile", "tokio", diff --git a/crates/rustunnel-server/src/config.rs b/crates/rustunnel-server/src/config.rs index aded0c7..621b6f1 100644 --- a/crates/rustunnel-server/src/config.rs +++ b/crates/rustunnel-server/src/config.rs @@ -140,6 +140,12 @@ pub struct ServerSection { /// Allowed CORS origin for the external dashboard (e.g. "https://dashboard.rustunnel.com") #[serde(default = "default_dashboard_origin")] pub dashboard_origin: String, + /// Plain-HTTP (port 80) behaviour: `"proxy"` forwards requests whose + /// subdomain resolves to a tunnel (ngrok parity — required for signed + /// webhooks configured with an `http://` URL) and redirects the rest; + /// `"redirect"` (default) 308-redirects everything to HTTPS. + #[serde(default)] + pub plain_http_mode: crate::edge::PlainHttpMode, } fn default_dashboard_port() -> u16 { @@ -318,6 +324,7 @@ impl Default for ServerConfig { control_port: 9000, dashboard_port: 4040, dashboard_origin: "http://localhost:3000".to_string(), + plain_http_mode: crate::edge::PlainHttpMode::default(), }, tls: TlsSection { cert_path: "cert.pem".to_string(), diff --git a/crates/rustunnel-server/src/core/router.rs b/crates/rustunnel-server/src/core/router.rs index 4060c34..5c3bb18 100644 --- a/crates/rustunnel-server/src/core/router.rs +++ b/crates/rustunnel-server/src/core/router.rs @@ -982,6 +982,13 @@ impl TunnelCore { self.dispatch_member(&group) } + /// Side-effect-free existence check for an HTTP route. Unlike + /// `resolve_http` this does not dispatch a member (no request-count + /// increment, no random pick) — use it for gating decisions. + pub fn has_http_route(&self, subdomain: &str) -> bool { + self.http_routes.contains_key(subdomain) + } + /// Look up the tunnel and its session's control channel by TCP port. pub fn resolve_tcp(&self, port: u16) -> Option<(TunnelInfo, mpsc::Sender)> { let group = self.tcp_routes.get(&port)?.clone(); diff --git a/crates/rustunnel-server/src/edge/http.rs b/crates/rustunnel-server/src/edge/http.rs index 18a61e7..c263b0d 100644 --- a/crates/rustunnel-server/src/edge/http.rs +++ b/crates/rustunnel-server/src/edge/http.rs @@ -1,9 +1,21 @@ //! HTTP / HTTPS edge proxy. //! -//! * Port 80 — plain HTTP, every request → 301 redirect to HTTPS. +//! * Port 80 — plain HTTP. Behaviour depends on `plain_http_mode`: +//! - `proxy` (recommended): requests whose `Host` subdomain resolves to a +//! tunnel are proxied directly (like the HTTPS edge, with +//! `X-Forwarded-Proto: http`), so signed webhooks configured with an +//! `http://` URL work without a redirect hop. Unresolvable hosts get a +//! 308 redirect to HTTPS. +//! - `redirect`: every request → 308 redirect to HTTPS (method-preserving; +//! previously 301, which turned followed POSTs into GETs). //! * Port 443 — TLS-terminated; requests are proxied through the tunnel //! identified by the `Host` subdomain. //! +//! Both proxy paths add `X-Forwarded-For`, `X-Forwarded-Proto` and +//! `X-Forwarded-Host` before forwarding, and never re-serialize the request +//! body — bytes reach the local service exactly as sent (required for +//! HMAC-signed webhooks, e.g. Twilio). +//! //! Proxy flow for a normal request //! ──────────────────────────────── //! 1. Parse `Host` header → extract subdomain. @@ -64,11 +76,41 @@ fn empty() -> BoxBody { // ── shared context ──────────────────────────────────────────────────────────── +/// Behaviour of the plain-HTTP (port 80) listener. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PlainHttpMode { + /// 308 redirect every request to HTTPS (legacy behaviour, minus the + /// method-dropping 301). + #[default] + Redirect, + /// Proxy requests whose subdomain resolves to a tunnel; redirect the rest. + Proxy, +} + +/// Scheme the public caller used to reach the edge — drives +/// `X-Forwarded-Proto` and the plain-HTTP fallback behaviour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ForwardScheme { + Http, + Https, +} + +impl ForwardScheme { + fn as_str(self) -> &'static str { + match self { + ForwardScheme::Http => "http", + ForwardScheme::Https => "https", + } + } +} + /// Runtime limits passed through the edge proxy hot-path. #[derive(Clone)] pub struct HttpEdgeConfig { pub rate_limit_rps: u32, pub request_body_max_bytes: usize, + pub plain_http_mode: PlainHttpMode, } #[derive(Clone)] @@ -78,6 +120,9 @@ struct ProxyCtx { domain: String, rate_limit_rps: u32, request_body_max_bytes: usize, + plain_http_mode: PlainHttpMode, + /// Public HTTPS port, used to build redirect Locations (omitted when 443). + https_port: u16, } // ── public entry point ──────────────────────────────────────────────────────── @@ -98,19 +143,21 @@ pub async fn run_http_edge( domain, rate_limit_rps: limits.rate_limit_rps, request_body_max_bytes: limits.request_body_max_bytes, + plain_http_mode: limits.plain_http_mode, + https_port: https_addr.port(), }; tokio::select! { - r = run_http_redirect(http_addr, ctx.domain.clone()) => r, - r = run_https_proxy(https_addr, tls_config, ctx) => r, + r = run_http_plain(http_addr, ctx.clone()) => r, + r = run_https_proxy(https_addr, tls_config, ctx) => r, } } -// ── HTTP redirect (port 80) ─────────────────────────────────────────────────── +// ── plain HTTP (port 80) ────────────────────────────────────────────────────── -async fn run_http_redirect(addr: SocketAddr, domain: String) -> crate::error::Result<()> { +async fn run_http_plain(addr: SocketAddr, ctx: ProxyCtx) -> crate::error::Result<()> { let listener = bind_reuse(addr)?; - info!(%addr, "HTTP redirect listener ready"); + info!(%addr, mode = ?ctx.plain_http_mode, "HTTP listener ready"); loop { let (tcp, peer) = match listener.accept().await { @@ -121,24 +168,53 @@ async fn run_http_redirect(addr: SocketAddr, domain: String) -> crate::error::Re } }; let _ = tcp.set_nodelay(true); - let domain = domain.clone(); + let ctx = ctx.clone(); tokio::spawn(async move { let io = TokioIo::new(tcp); let svc = service_fn(move |req: Request| { - let domain = domain.clone(); - async move { Ok::<_, Infallible>(redirect_to_https(req, &domain)) } + let ctx = ctx.clone(); + async move { Ok::<_, Infallible>(plain_http_request(req, peer, ctx).await) } }); if let Err(e) = hyper::server::conn::http1::Builder::new() .serve_connection(io, svc) + .with_upgrades() .await { - debug!(%peer, "HTTP redirect error: {e}"); + debug!(%peer, "HTTP conn error: {e}"); } }); } } -fn redirect_to_https(req: Request, domain: &str) -> Response { +/// Dispatch a plain-HTTP request: proxy it when `plain_http_mode = "proxy"` +/// and the Host resolves to a registered tunnel, redirect to HTTPS otherwise. +async fn plain_http_request( + req: Request, + peer: SocketAddr, + ctx: ProxyCtx, +) -> Response { + // Rate-limit before any routing work so the gate probe below cannot be + // driven unthrottled (and can't serve as a subdomain-existence oracle). + if !ctx.core.ip_limiter.check(peer.ip()) { + return err_response(StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"); + } + + if ctx.plain_http_mode == PlainHttpMode::Proxy { + let resolvable = req + .headers() + .get(HOST) + .and_then(|v| v.to_str().ok()) + .and_then(|h| extract_subdomain(h, &ctx.domain)) + .map(|sub| ctx.core.has_http_route(&sub)) + .unwrap_or(false); + if resolvable { + return proxy_request(req, peer, ctx, ForwardScheme::Http).await; + } + } + redirect_to_https(req, &ctx.domain, ctx.https_port) +} + +fn redirect_to_https(req: Request, domain: &str, https_port: u16) -> Response { // Sanitise the Host header to prevent header injection: only allow chars // that are valid in a hostname or port (alphanumeric, hyphens, dots, colon). let raw_host = req @@ -147,15 +223,30 @@ fn redirect_to_https(req: Request, domain: &str) -> Response .and_then(|v| v.to_str().ok()) .unwrap_or(domain); let host = sanitize_host(raw_host).unwrap_or_else(|| domain.to_string()); + // Strip any incoming port; the redirect target is the HTTPS listener. + let mut name = host.split(':').next().unwrap_or(&host); + // 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}")) { + name = domain; + } + let authority = if https_port == 443 { + name.to_string() + } else { + format!("{name}:{https_port}") + }; let pq = req .uri() .path_and_query() .map(|pq| pq.as_str()) .unwrap_or("/"); - let location = format!("https://{host}{pq}"); + let location = format!("https://{authority}{pq}"); + // 308: permanent AND method/body-preserving. A 301 here turned followed + // POSTs (e.g. webhooks) into GETs. Response::builder() - .status(StatusCode::MOVED_PERMANENTLY) + .status(StatusCode::PERMANENT_REDIRECT) .header("Location", location) .body(empty()) .unwrap() @@ -222,7 +313,15 @@ async fn run_https_proxy( let io = TokioIo::new(tls); let svc = service_fn(move |req: Request| { let ctx = ctx.clone(); - async move { Ok::<_, Infallible>(proxy_request(req, peer, ctx).await) } + async move { + if !ctx.core.ip_limiter.check(peer.ip()) { + return Ok::<_, Infallible>(err_response( + StatusCode::TOO_MANY_REQUESTS, + "Rate limit exceeded", + )); + } + Ok::<_, Infallible>(proxy_request(req, peer, ctx, ForwardScheme::Https).await) + } }); if let Err(e) = hyper::server::conn::http1::Builder::new() .serve_connection(io, svc) @@ -241,13 +340,13 @@ async fn proxy_request( req: Request, peer: SocketAddr, ctx: ProxyCtx, + scheme: ForwardScheme, ) -> Response { let start = Instant::now(); - // ── 0. IP rate limit ────────────────────────────────────────────────── - if !ctx.core.ip_limiter.check(peer.ip()) { - return err_response(StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"); - } + // IP rate limiting already happened at the listener entry points + // (`plain_http_request` / the HTTPS service closure) — checking the + // sliding window again here would double-count each request. // ── 1. Extract subdomain ────────────────────────────────────────────── let host = match req.headers().get(HOST).and_then(|v| v.to_str().ok()) { @@ -375,6 +474,9 @@ async fn proxy_request( req, yamux_stream, bytes_counter, + peer, + scheme, + &host, HttpCaptureCtx { tx: ctx.capture_tx.clone(), conn_id, @@ -466,6 +568,9 @@ async fn forward_http( req: Request, yamux_stream: YamuxStream, bytes_counter: Arc, + peer: SocketAddr, + scheme: ForwardScheme, + original_host: &str, capture: HttpCaptureCtx, ) -> Result, Box> { // Bridge yamux (futures::io) → tokio::io → hyper::rt IO. @@ -484,6 +589,7 @@ async fn forward_http( // Strip hop-by-hop headers before forwarding upstream. let (mut parts, body) = req.into_parts(); remove_hop_by_hop(&mut parts.headers); + set_forwarded_headers(&mut parts.headers, peer, scheme, original_host); let fwd_req = Request::from_parts(parts, body); let upstream = sender.send_request(fwd_req).await?; @@ -637,6 +743,43 @@ fn remove_hop_by_hop(headers: &mut hyper::HeaderMap) { } } +/// Add the standard reverse-proxy forwarding headers. +/// +/// `X-Forwarded-For` appends the peer IP to any inbound value (standard +/// chain behaviour, matching ngrok). Because this edge faces the internet +/// directly, earlier entries in the chain are client-supplied and MUST NOT +/// be trusted — backends should read the rightmost entry only. IPv6 peers +/// are emitted unbracketed (nginx convention). +/// +/// `X-Forwarded-Proto` and `X-Forwarded-Host` are overwritten — the edge is +/// authoritative for both, so spoofed inbound values never reach the tunnel. +fn set_forwarded_headers( + headers: &mut hyper::HeaderMap, + peer: SocketAddr, + scheme: ForwardScheme, + original_host: &str, +) { + use hyper::header::HeaderValue; + + let peer_ip = peer.ip().to_string(); + let xff = match headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { + Some(existing) => format!("{existing}, {peer_ip}"), + None => peer_ip, + }; + if let Ok(v) = HeaderValue::from_str(&xff) { + headers.insert("x-forwarded-for", v); + } + + headers.insert( + "x-forwarded-proto", + HeaderValue::from_static(scheme.as_str()), + ); + + if let Ok(v) = HeaderValue::from_str(original_host) { + headers.insert("x-forwarded-host", v); + } +} + fn err_response(status: StatusCode, msg: &str) -> Response { Response::builder() .status(status) @@ -719,6 +862,80 @@ mod tests { ); } + #[test] + fn redirect_is_permanent_and_method_preserving() { + let req = Request::builder() + .uri("/api/webhooks/sms/inbound?x=1") + .header("host", "myapp.tunnel.example.com") + .body(()) + .unwrap(); + let resp = redirect_to_https(req, "tunnel.example.com", 443); + assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT); + assert_eq!( + resp.headers().get("Location").unwrap(), + "https://myapp.tunnel.example.com/api/webhooks/sms/inbound?x=1" + ); + } + + #[test] + fn redirect_never_leaves_our_domain() { + // A Host outside the configured domain must not become an open + // redirect target — fall back to the bare domain instead. + let req = Request::builder() + .uri("/steal") + .header("host", "evil.example.net") + .body(()) + .unwrap(); + let resp = redirect_to_https(req, "tunnel.example.com", 443); + assert_eq!( + resp.headers().get("Location").unwrap(), + "https://tunnel.example.com/steal" + ); + } + + #[test] + fn redirect_swaps_port_for_https_listener() { + // Incoming Host carries the plain-HTTP port; the Location must point + // at the HTTPS listener instead of echoing the original port. + let req = Request::builder() + .uri("/p") + .header("host", "myapp.tunnel.example.com:8080") + .body(()) + .unwrap(); + let resp = redirect_to_https(req, "tunnel.example.com", 8443); + assert_eq!( + resp.headers().get("Location").unwrap(), + "https://myapp.tunnel.example.com:8443/p" + ); + } + + #[test] + fn forwarded_headers_set_and_appended() { + use hyper::header::HeaderValue; + let peer: SocketAddr = "203.0.113.9:55555".parse().unwrap(); + + // Fresh request — headers created from scratch. + let mut headers = hyper::HeaderMap::new(); + set_forwarded_headers(&mut headers, peer, ForwardScheme::Https, "app.example.com"); + assert_eq!(headers.get("x-forwarded-for").unwrap(), "203.0.113.9"); + assert_eq!(headers.get("x-forwarded-proto").unwrap(), "https"); + assert_eq!(headers.get("x-forwarded-host").unwrap(), "app.example.com"); + + // Inbound X-Forwarded-For is appended to; spoofed Proto/Host are + // overwritten at the trust boundary. + let mut headers = hyper::HeaderMap::new(); + headers.insert("x-forwarded-for", HeaderValue::from_static("198.51.100.1")); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); + headers.insert("x-forwarded-host", HeaderValue::from_static("evil.example")); + set_forwarded_headers(&mut headers, peer, ForwardScheme::Http, "app.example.com"); + assert_eq!( + headers.get("x-forwarded-for").unwrap(), + "198.51.100.1, 203.0.113.9" + ); + assert_eq!(headers.get("x-forwarded-proto").unwrap(), "http"); + assert_eq!(headers.get("x-forwarded-host").unwrap(), "app.example.com"); + } + #[test] fn websocket_detection() { // Test the header-presence logic directly against a HeaderMap. diff --git a/crates/rustunnel-server/src/edge/mod.rs b/crates/rustunnel-server/src/edge/mod.rs index f790e8e..883ff3d 100644 --- a/crates/rustunnel-server/src/edge/mod.rs +++ b/crates/rustunnel-server/src/edge/mod.rs @@ -4,6 +4,6 @@ pub mod tcp; pub mod udp; pub use capture::{CaptureEvent, CaptureTx}; -pub use http::{run_http_edge, HttpEdgeConfig}; +pub use http::{run_http_edge, HttpEdgeConfig, PlainHttpMode}; pub use tcp::run_tcp_edge; pub use udp::run_udp_edge; diff --git a/crates/rustunnel-server/src/main.rs b/crates/rustunnel-server/src/main.rs index d47e66d..5eeee89 100644 --- a/crates/rustunnel-server/src/main.rs +++ b/crates/rustunnel-server/src/main.rs @@ -190,6 +190,7 @@ async fn run(config: Arc) -> Result<()> { let limits = HttpEdgeConfig { rate_limit_rps: config.limits.rate_limit_rps, request_body_max_bytes: config.limits.request_body_max_bytes, + plain_http_mode: config.server.plain_http_mode, }; tokio::spawn(async move { if let Err(e) = run_http_edge( diff --git a/deploy/local/server.toml b/deploy/local/server.toml index f90f57b..c773d44 100644 --- a/deploy/local/server.toml +++ b/deploy/local/server.toml @@ -6,6 +6,7 @@ http_port = 8080 https_port = 8443 control_port = 4040 dashboard_port = 4041 +plain_http_mode = "proxy" dashboard_origin = "http://localhost:3002" [tls] diff --git a/deploy/server.toml b/deploy/server.toml index 957a907..e0db265 100644 --- a/deploy/server.toml +++ b/deploy/server.toml @@ -10,13 +10,21 @@ # *.edge.rustunnel.com → domain = "edge.rustunnel.com" -# Port 80 — HTTP edge (redirects to HTTPS; also handles ACME HTTP-01 challenges) +# Port 80 — HTTP edge (see plain_http_mode below) # Port 443 — HTTPS edge (TLS-terminated tunnel ingress) # Binding to ports < 1024 requires CAP_NET_BIND_SERVICE (granted in the # systemd unit via AmbientCapabilities=CAP_NET_BIND_SERVICE). http_port = 80 https_port = 443 +# Plain-HTTP (port 80) behaviour: +# "proxy" — requests whose subdomain resolves to a tunnel are proxied +# directly with X-Forwarded-Proto: http (ngrok parity; required +# for HMAC-signed webhooks configured with an http:// URL, +# e.g. Twilio). Everything else 308-redirects to HTTPS. +# "redirect" — every request 308-redirects to HTTPS (default). +plain_http_mode = "proxy" + # Clients connect here over a TLS WebSocket (wss://). control_port = 4040 diff --git a/docs/client-guide.md b/docs/client-guide.md index a002402..36500c0 100644 --- a/docs/client-guide.md +++ b/docs/client-guide.md @@ -311,6 +311,27 @@ rustunnel http 3000 --no-reconnect rustunnel http 3000 --server tunnel.example.com:9000 --token rt_live_abc123 ``` +**Receiving webhooks (Twilio, Stripe, GitHub, …):** + +HTTP tunnels are safe for HMAC-signed webhooks: + +- The request body is forwarded **byte-for-byte** — the proxy never parses or + re-serializes it, so signatures computed over the raw payload stay valid. +- Every proxied request carries `X-Forwarded-For` (caller IP, appended to any + existing chain), `X-Forwarded-Proto` (`http` or `https`) and + `X-Forwarded-Host` (the public tunnel host). Frameworks use these to + reconstruct the public URL, which signed-webhook validation depends on — + no manual base-URL override needed. `X-Forwarded-Proto` and + `X-Forwarded-Host` are set authoritatively by the edge; for + `X-Forwarded-For`, only the **rightmost** entry is edge-verified — earlier + entries are supplied by the caller and must not be trusted for IP + allowlists or logging. +- Prefer configuring providers with the **`https://` tunnel URL**. `http://` + URLs also work when the server runs with `plain_http_mode = "proxy"` + (the default on rustunnel.com edges); on `redirect` servers an `http://` + webhook URL gets a 308 redirect, which many providers either refuse to + follow or re-sign against the target URL — breaking signature validation. + --- ### `tcp` — TCP tunnel diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 381197f..9a96ae8 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -26,6 +26,8 @@ serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tempfile = { workspace = true } +hmac = "0.12" +sha1 = "0.10" [[test]] name = "auth" @@ -82,3 +84,7 @@ path = "integration/group_events_sse.rs" [[test]] name = "dashboard_scope" path = "integration/dashboard_scope.rs" + +[[test]] +name = "webhook_fidelity" +path = "integration/webhook_fidelity.rs" diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2563f7c..d353675 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -178,6 +178,10 @@ pub struct TestServerOpts { /// the client's version-gate falls through to solo registration /// without spamming `decode_frame` warnings. pub server_version_override: Option, + /// Plain-HTTP (port 80) listener behaviour. Most tests use `Proxy` + /// (the rustunnel.com edge posture); `Redirect` covers the + /// conservative default of shipped configs. + pub plain_http_mode: rustunnel_server::edge::PlainHttpMode, } /// A running server instance with all components live on random ports. @@ -307,6 +311,7 @@ impl TestServer { load_balancing_enabled, alert_webhook_url: None, server_version_override: None, + plain_http_mode: rustunnel_server::edge::PlainHttpMode::Proxy, }) .await } @@ -327,6 +332,7 @@ impl TestServer { load_balancing_enabled, alert_webhook_url, server_version_override, + plain_http_mode, } = opts; let admin_token = admin_token.as_str(); let [tcp_low, tcp_high] = tcp_port_range; @@ -346,6 +352,7 @@ impl TestServer { control_port, dashboard_port, dashboard_origin: "http://localhost:3000".to_string(), + plain_http_mode, }, tls: TlsSection { cert_path: cert_path.clone(), @@ -450,6 +457,7 @@ impl TestServer { let limits = rustunnel_server::edge::HttpEdgeConfig { rate_limit_rps: config.limits.rate_limit_rps, request_body_max_bytes: config.limits.request_body_max_bytes, + plain_http_mode: config.server.plain_http_mode, }; async move { let _ = rustunnel_server::edge::run_http_edge( diff --git a/tests/integration/alert_webhook.rs b/tests/integration/alert_webhook.rs index 1460225..6e275a0 100644 --- a/tests/integration/alert_webhook.rs +++ b/tests/integration/alert_webhook.rs @@ -83,6 +83,7 @@ async fn alert_webhook_fires_once_when_group_goes_zero_healthy() { load_balancing_enabled: true, alert_webhook_url: Some(webhook_url.clone()), server_version_override: None, + plain_http_mode: rustunnel_server::edge::PlainHttpMode::Proxy, }) .await; @@ -223,6 +224,7 @@ async fn per_tenant_webhooks_fan_out_alongside_operator_url() { load_balancing_enabled: true, alert_webhook_url: Some(op_url.clone()), server_version_override: None, + plain_http_mode: rustunnel_server::edge::PlainHttpMode::Proxy, }) .await; @@ -321,6 +323,7 @@ async fn shared_tenant_webhook_is_deduped() { load_balancing_enabled: true, alert_webhook_url: Some(op_url.clone()), server_version_override: None, + plain_http_mode: rustunnel_server::edge::PlainHttpMode::Proxy, }) .await; diff --git a/tests/integration/version_gate.rs b/tests/integration/version_gate.rs index 4442960..0a567dd 100644 --- a/tests/integration/version_gate.rs +++ b/tests/integration/version_gate.rs @@ -46,6 +46,7 @@ async fn start_with_pretend_version(pretend_version: &str) -> TestServer { load_balancing_enabled: true, alert_webhook_url: None, server_version_override: Some(pretend_version.to_string()), + plain_http_mode: rustunnel_server::edge::PlainHttpMode::Proxy, }) .await } diff --git a/tests/integration/webhook_fidelity.rs b/tests/integration/webhook_fidelity.rs new file mode 100644 index 0000000..d1a0f0e --- /dev/null +++ b/tests/integration/webhook_fidelity.rs @@ -0,0 +1,447 @@ +//! HMAC-signed-webhook fidelity — end-to-end integration test. +//! +//! # What this tests +//! +//! The invariant that real webhook providers (Twilio, Stripe, GitHub…) +//! depend on: **the proxy must not change bytes**, and it must give the +//! backend enough information (`X-Forwarded-*`) to reconstruct the exact +//! public URL the provider signed. +//! +//! Twilio's scheme is the strictest of the lot — HMAC-SHA1 over +//! `URL + sorted(param_name + param_value)` — so one flipped byte in the +//! form body (e.g. a `%2B` decoded to `+` and re-emitted literally) or a +//! wrong reconstructed URL kills the signature. This suite: +//! +//! 1. signs a Twilio-style form POST against the public tunnel URL, +//! 2. sends it through the full proxy chain (HTTPS edge and plain-HTTP +//! edge in `proxy` mode), +//! 3. captures the *raw bytes* that reach the local service (no HTTP +//! library parsing on the receiving side), +//! 4. re-validates the signature the way a real backend would — URL +//! rebuilt from `X-Forwarded-Proto`/`X-Forwarded-Host`, params parsed +//! from the received body bytes, +//! 5. asserts the body arrived byte-identical. +//! +//! Also covers the plain-HTTP fallback: unresolvable hosts must get a 308 +//! (method-preserving) redirect to the HTTPS listener — a 301 here turned +//! followed webhook POSTs into GETs. + +#[path = "../common/mod.rs"] +mod common; + +use std::net::SocketAddr; +use std::sync::Arc; + +use common::*; +use hmac::{Hmac, Mac}; +use sha1::Sha1; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; + +/// Twilio-style body: `+` phone numbers (percent-encoded), a literal `+` +/// meaning space, an empty param, raw UTF-8, and pre-encoded reserved chars. +const FORM_BODY: &str = "Body=hello+world&From=%2B31615940830&FromZip=&To=%2B31682223345&Uni=%C3%A9&Special=a%20b%26c%3Dd"; +const AUTH_TOKEN: &str = "twilio-test-auth-token"; + +// ── raw-byte capture server ─────────────────────────────────────────────────── + +/// A local "service" that never parses the request: it records the exact +/// bytes on the wire (head + body) and answers 200. +async fn start_raw_capture_server() -> (SocketAddr, Arc>>>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind raw capture server"); + let addr = listener.local_addr().unwrap(); + let captured: Arc>>> = Arc::new(Mutex::new(Vec::new())); + + let store = captured.clone(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + let store = store.clone(); + tokio::spawn(async move { + let mut raw = Vec::new(); + let mut buf = [0u8; 65536]; + // Read until end-of-headers, then Content-Length more bytes. + let (head_len, content_len) = loop { + let Ok(n) = sock.read(&mut buf).await else { + return; + }; + if n == 0 { + return; + } + raw.extend_from_slice(&buf[..n]); + if let Some(pos) = raw.windows(4).position(|w| w == b"\r\n\r\n") { + let head = &raw[..pos]; + let clen = std::str::from_utf8(head) + .ok() + .and_then(|h| { + h.lines().find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse::().ok())? + }) + }) + .unwrap_or(0); + break (pos + 4, clen); + } + }; + while raw.len() < head_len + content_len { + let Ok(n) = sock.read(&mut buf).await else { + return; + }; + if n == 0 { + break; + } + raw.extend_from_slice(&buf[..n]); + } + store.lock().await.push(raw); + let _ = sock + .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nOK") + .await; + }); + } + }); + + (addr, captured) +} + +// ── Twilio-style signature helpers ──────────────────────────────────────────── + +/// Compute the Twilio request signature: base64(HMAC-SHA1(auth_token, +/// url + concat(sorted(param_name + param_value)))). +fn twilio_signature(auth_token: &str, url: &str, form_body: &str) -> String { + let mut params: Vec<(String, String)> = form_body + .split('&') + .filter(|p| !p.is_empty()) + .map(|p| { + let (k, v) = p.split_once('=').unwrap_or((p, "")); + (url_decode(k), url_decode(v)) + }) + .collect(); + params.sort(); + + let mut data = url.to_string(); + for (k, v) in ¶ms { + data.push_str(k); + data.push_str(v); + } + + let mut mac = + Hmac::::new_from_slice(auth_token.as_bytes()).expect("hmac accepts any key length"); + mac.update(data.as_bytes()); + base64_encode(&mac.finalize().into_bytes()) +} + +/// Minimal application/x-www-form-urlencoded decoder (`+` → space, `%XX`). +fn url_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < bytes.len() => { + match std::str::from_utf8(&bytes[i + 1..i + 3]) + .ok() + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { + Some(b) => { + out.push(b); + i += 2; + } + None => out.push(b'%'), + } + } + b => out.push(b), + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn base64_encode(data: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = u32::from_be_bytes([0, b[0], b[1], b[2]]); + out.push(TABLE[(n >> 18) as usize & 63] as char); + out.push(TABLE[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { + TABLE[(n >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + TABLE[n as usize & 63] as char + } else { + '=' + }); + } + out +} + +// ── raw-request inspection helpers ──────────────────────────────────────────── + +struct ReceivedRequest { + head: String, + body: Vec, +} + +fn parse_raw(raw: &[u8]) -> ReceivedRequest { + let pos = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .expect("received request has header terminator"); + ReceivedRequest { + head: String::from_utf8_lossy(&raw[..pos]).into_owned(), + body: raw[pos + 4..].to_vec(), + } +} + +impl ReceivedRequest { + fn header(&self, name: &str) -> Option<&str> { + self.head.lines().find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case(name).then(|| v.trim()) + }) + } + + fn path(&self) -> &str { + self.head.lines().next().unwrap().split(' ').nth(1).unwrap() + } + + /// Reconstruct the public URL the way a backend behind a proxy does. + fn reconstructed_url(&self) -> String { + let proto = self.header("x-forwarded-proto").expect("X-Forwarded-Proto"); + let host = self.header("x-forwarded-host").expect("X-Forwarded-Host"); + format!("{proto}://{host}{}", self.path()) + } +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +/// Full Twilio flow over the HTTPS edge: sign against the public URL, POST +/// through the tunnel, validate server-side from the received raw bytes. +#[tokio::test] +async fn signed_webhook_survives_https_edge() { + init_tracing(); + + let (local_addr, captured) = start_raw_capture_server().await; + let server = TestServer::start().await; + + let mut client = TestClient::connect(&server).await.expect("client auth"); + let session_id = client.session_id.unwrap(); + let (_, subdomain, _) = client + .register_http_tunnel(Some("twiliotest")) + .await + .expect("tunnel registration"); + connect_data_bridge(&server, session_id, local_addr) + .await + .expect("data bridge ready"); + + // "Twilio" signs against the public URL it was configured with. + let host = format!("{subdomain}.{}", server.domain); + let public_url = format!( + "https://{host}:{}/api/webhooks/sms/inbound", + server.https_port + ); + let signature = twilio_signature(AUTH_TOKEN, &public_url, FORM_BODY); + + let resp = insecure_http_client() + .post(format!( + "https://127.0.0.1:{}/api/webhooks/sms/inbound", + server.https_port + )) + .header("Host", format!("{host}:{}", server.https_port)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("X-Twilio-Signature", &signature) + .body(FORM_BODY) + .send() + .await + .expect("signed POST through tunnel"); + assert_eq!(resp.status(), 200); + + let captured = captured.lock().await; + assert_eq!(captured.len(), 1, "expected exactly one captured request"); + let req = parse_raw(&captured[0]); + + // 1. Body must be byte-identical — one flipped byte kills the HMAC. + assert_eq!( + req.body, + FORM_BODY.as_bytes(), + "request body was not byte-faithful through the tunnel" + ); + + // 2. Signature header must arrive intact. + assert_eq!(req.header("x-twilio-signature"), Some(signature.as_str())); + + // 3. Backend-side validation: rebuild the URL from X-Forwarded-* and the + // received body bytes, recompute, compare. This is exactly what + // twilio's SDK validators do behind a proxy. + let backend_sig = twilio_signature( + AUTH_TOKEN, + &req.reconstructed_url(), + std::str::from_utf8(&req.body).unwrap(), + ); + assert_eq!( + backend_sig, + signature, + "backend-side signature validation failed: reconstructed URL {}", + req.reconstructed_url() + ); + + assert_eq!(req.header("x-forwarded-proto"), Some("https")); + assert!(req.header("x-forwarded-for").is_some()); +} + +/// Same flow over the plain-HTTP edge in `proxy` mode — an `http://` webhook +/// URL must work without a redirect hop (ngrok parity). +#[tokio::test] +async fn signed_webhook_survives_plain_http_edge() { + init_tracing(); + + let (local_addr, captured) = start_raw_capture_server().await; + let server = TestServer::start().await; + + let mut client = TestClient::connect(&server).await.expect("client auth"); + let session_id = client.session_id.unwrap(); + let (_, subdomain, _) = client + .register_http_tunnel(Some("twilioplain")) + .await + .expect("tunnel registration"); + connect_data_bridge(&server, session_id, local_addr) + .await + .expect("data bridge ready"); + + let host = format!("{subdomain}.{}", server.domain); + let public_url = format!( + "http://{host}:{}/api/webhooks/sms/inbound", + server.http_port + ); + let signature = twilio_signature(AUTH_TOKEN, &public_url, FORM_BODY); + + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{}/api/webhooks/sms/inbound", + server.http_port + )) + .header("Host", format!("{host}:{}", server.http_port)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("X-Twilio-Signature", &signature) + .body(FORM_BODY) + .send() + .await + .expect("signed POST through plain-HTTP tunnel"); + assert_eq!( + resp.status(), + 200, + "plain-HTTP proxy mode must not redirect tunnel traffic" + ); + + let captured = captured.lock().await; + assert_eq!(captured.len(), 1); + let req = parse_raw(&captured[0]); + + assert_eq!(req.body, FORM_BODY.as_bytes()); + assert_eq!(req.header("x-forwarded-proto"), Some("http")); + + let backend_sig = twilio_signature( + AUTH_TOKEN, + &req.reconstructed_url(), + std::str::from_utf8(&req.body).unwrap(), + ); + assert_eq!(backend_sig, signature); +} + +/// In `redirect` mode (the shipped-config default), even a host that +/// resolves to a registered tunnel must get a 308 on the plain-HTTP port — +/// never be proxied. +#[tokio::test] +async fn redirect_mode_never_proxies_registered_tunnels() { + init_tracing(); + + let tcp_low = alloc_tcp_port_range(10); + let udp_low = alloc_tcp_port_range(10); + let server = TestServer::start_with_opts(TestServerOpts { + control_port: free_port(), + http_port: free_port(), + https_port: free_port(), + dashboard_port: free_port(), + tcp_port_range: [tcp_low, tcp_low + 9], + udp_port_range: [udp_low, udp_low + 9], + require_auth: true, + admin_token: "integration-test-token".to_string(), + load_balancing_enabled: false, + alert_webhook_url: None, + server_version_override: None, + plain_http_mode: rustunnel_server::edge::PlainHttpMode::Redirect, + }) + .await; + + let mut client = TestClient::connect(&server).await.expect("client auth"); + let (_, subdomain, _) = client + .register_http_tunnel(Some("redirmode")) + .await + .expect("tunnel registration"); + let host = format!("{subdomain}.{}", server.domain); + + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let resp = http + .post(format!("http://127.0.0.1:{}/hook", server.http_port)) + .header("Host", &host) + .body("a=1") + .send() + .await + .expect("POST to registered tunnel host in redirect mode"); + + assert_eq!(resp.status(), 308); + assert_eq!( + resp.headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .expect("Location header"), + &format!("https://{host}:{}/hook", server.https_port) + ); +} + +/// Plain-HTTP requests that do not resolve to a tunnel must get a 308 +/// (method- and body-preserving), never a 301. +#[tokio::test] +async fn plain_http_fallback_redirects_with_308() { + init_tracing(); + let server = TestServer::start().await; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let resp = client + .post(format!("http://127.0.0.1:{}/hook", server.http_port)) + .header("Host", "notregistered.localhost") + .body("a=1") + .send() + .await + .expect("POST to unresolvable host"); + + assert_eq!(resp.status(), 308); + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .expect("Location header"); + assert_eq!( + location, + &format!("https://notregistered.localhost:{}/hook", server.https_port) + ); +}