diff --git a/.cargo/deny.toml b/.cargo/deny.toml index 7df4040..53212fe 100644 --- a/.cargo/deny.toml +++ b/.cargo/deny.toml @@ -12,17 +12,16 @@ yanked = "deny" id = "RUSTSEC-2025-0134" [licenses] +# Encountered licenses only (verified via `cargo deny --target all list`): +# wasi/wit-bindgen crates carry Apache-2.0 WITH LLVM-exception; r-efi's +# LGPL-2.1-or-later option is satisfied via the MIT/Apache alternatives. allow = [ "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", - "BSD-2-Clause", "BSD-3-Clause", "ISC", - "Zlib", - "0BSD", "CC0-1.0", - "Unicode-DFS-2016", "Unicode-3.0", ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6370671..0f99310 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: uses: taiki-e/install-action@cargo-audit - name: cargo deny check - run: cargo deny check --config .cargo/deny.toml + run: cargo deny --config .cargo/deny.toml check - name: cargo audit run: cargo audit --deny warnings diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index e60042a..b4f2298 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -69,4 +69,4 @@ jobs: uses: taiki-e/install-action@cargo-deny - name: Check licenses - run: cargo deny check --config .cargo/deny.toml licenses + run: cargo deny --config .cargo/deny.toml check licenses diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 2c31886..1a756e9 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -4,94 +4,172 @@ Comparative benchmarks of tiny-proxy, nginx, and Caddy as reverse proxies. All three proxies were configured with equivalent routing rules, forwarding to the same backend (hashicorp/http-echo). The only variable is the proxy implementation. -## Environment - -- **Host:** Docker Desktop on macOS (Apple Silicon, M-series) -- **Tool:** [hey](https://github.com/rakyll/hey) -- **Proxies:** tiny-proxy 0.3.0 (Rust, rustls), nginx (alpine), Caddy (alpine) -- **Backend:** hashicorp/http-echo (minimal response overhead) -- **Method:** 10 000 requests, 100 concurrent connections, best of 3 runs -- **Warmup:** 200 requests before each measurement -- **Containers:** each proxy and the backend ran in separate Docker containers on the same host - -> **Note:** These are local benchmarks run through Docker networking on a single machine. Absolute numbers will differ on dedicated hardware. The relative comparison between proxies is what's useful here. - -## Results +- **Setup:** [`benchmarks/compose.yml`](benchmarks/compose.yml) · proxy configs: [`benchmarks/proxies/`](benchmarks/proxies/) +- **Runner:** [`benchmarks/run.sh`](benchmarks/run.sh) (3-way comparison) · [`benchmarks/run_ab.sh`](benchmarks/run_ab.sh) (version-vs-version) +- **Raw results:** [`benchmarks/results/`](benchmarks/results/) + +## Methodology + +- **Tool:** [hey](https://github.com/rakyll/hey) — 10 000 requests, 100 concurrent connections, best of 3 runs (per-scenario warmup of 200 requests) +- **Backend:** hashicorp/http-echo in its own container (minimal overhead) +- **TLS scenario:** `-disable-keepalive` — every request performs a full TCP + TLS handshake; `-host localhost` sets bare-hostname SNI (hey otherwise sends `host:port`, which rustls rejects per RFC 6066) +- All proxies run with out-of-the-box defaults — no worker tuning, buffer sizing, or OS-level tweaks. Both nginx and Caddy have extensive tuning options (`worker_processes`, `worker_connections`, `proxy_buffer_size`, `keepalive_timeout`) that can meaningfully improve their numbers. These benchmarks reflect a fair "zero-config" comparison, not maximum achievable performance for any proxy. +- tiny-proxy runs on the Tokio multi-thread runtime (one worker per CPU core); nginx uses `worker_processes auto`. + +> **Note:** These are local benchmarks through Docker Desktop networking on a +> single machine. Absolute numbers will differ on dedicated hardware — the +> relative comparison is what's useful. Run-to-run variance on this setup is +> roughly ±10–15%; differences below that should be considered noise. For +> cleanest results, close background applications — other running containers +> (databases, message brokers, …) on the same Docker daemon measurably add +> noise. + +## Environment (latest run, 2026-10-06) + +- **Host:** Apple M1 Max (10 cores), 32 GB RAM, macOS 27.0 +- **Docker:** Docker Desktop, engine 27.4.0 +- **Images:** + - tiny-proxy 0.6.0 — built from this repository (`Dockerfile`, `--all-features`, musl static binary) + - nginx:alpine `sha256:8b1e78743a03…` + - caddy:alpine `sha256:86deaf5e3d34…` + - hashicorp/http-echo `sha256:fcb75f691c8b…` +- **tiny-proxy version in container:** verify anytime with `docker compose exec tiny-proxy tiny-proxy --version` + +## Results — tiny-proxy 0.6.0 vs nginx vs Caddy (2026-10-06) ### 1. Plain Text (~11 bytes response) | Proxy | RPS | Avg | p50 | p90 | p95 | p99 | |-------|-----|-----|-----|-----|-----|-----| -| tiny-proxy | 16 275 | 5.8ms | 5.5ms | 8.6ms | 10.1ms | 22.2ms | -| nginx | 16 418 | 5.9ms | 5.1ms | 9.5ms | 11.2ms | 19.5ms | -| Caddy | 17 542 | 5.5ms | 4.8ms | 9.1ms | 10.5ms | 22.4ms | - -All three proxies perform within ~7% of each other. The differences are within run-to-run variance. +| tiny-proxy | 17 047 | 5.8ms | 5.4ms | 8.2ms | 9.5ms | 15.3ms | +| nginx | 20 390 | 4.8ms | 4.2ms | 7.3ms | 9.2ms | 16.6ms | +| caddy | 19 406 | 5.0ms | 4.1ms | 8.3ms | 9.6ms | 17.4ms | ### 2. JSON API (~200 bytes response) | Proxy | RPS | Avg | p50 | p90 | p95 | p99 | |-------|-----|-----|-----|-----|-----|-----| -| tiny-proxy | 17 184 | 5.7ms | 5.3ms | 7.9ms | 8.9ms | 16.2ms | -| nginx | 20 273 | 4.8ms | 4.2ms | 7.0ms | 8.3ms | 19.8ms | -| Caddy | 17 596 | 5.5ms | 4.6ms | 9.3ms | 10.9ms | 24.7ms | - -nginx shows higher throughput on this scenario (~18% more RPS than tiny-proxy). Latency distributions are comparable at p50, with tiny-proxy showing tighter p99. +| tiny-proxy | 17 558 | 5.6ms | 5.2ms | 7.8ms | 8.9ms | 15.4ms | +| nginx | 20 360 | 4.8ms | 4.4ms | 6.9ms | 8.0ms | 17.4ms | +| caddy | 19 460 | 5.0ms | 4.2ms | 8.3ms | 10.1ms | 22.2ms | ### 3. TLS Termination -Each request establishes a new TCP connection and TLS handshake (`-disable-keepalive`), making this the most demanding scenario. +Every request establishes a new TCP connection and TLS handshake (`-disable-keepalive`), making this the most demanding scenario. | Proxy | RPS | Avg | p50 | p90 | p95 | p99 | |-------|-----|-----|-----|-----|-----|-----| -| tiny-proxy | 2 672 | 36.9ms | 36.0ms | 49.1ms | 53.6ms | 61.5ms | -| nginx | 2 437 | 39.3ms | 33.6ms | 70.1ms | 88.0ms | 117.4ms | -| Caddy | 2 129 | 44.2ms | 33.5ms | 90.2ms | 115.5ms | 157.8ms | +| tiny-proxy | 2 613 | 37.7ms | 37.0ms | 50.7ms | 55.8ms | **66.3ms** | +| nginx | 2 515 | 37.7ms | 31.0ms | 68.8ms | 83.0ms | 128.4ms | +| caddy | 2 091 | 45.8ms | 36.0ms | 89.1ms | 109.9ms | 149.7ms | + +**Takeaways:** + +- nginx leads plain HTTP throughput by ~15%; tiny-proxy and Caddy are close behind. +- tiny-proxy leads TLS termination in throughput and, more importantly, in tail latency: p99 of 66ms vs nginx 128ms and Caddy 150ms — rustls (aws-lc-rs backend) handles handshakes efficiently. +- tiny-proxy shows the tightest p99 across all scenarios. + +Full raw output of this run: [`benchmarks/results/summary_20261006_112802.md`](benchmarks/results/summary_20261006_112802.md). Historical runs live in the same directory. + +## Version A/B — did a release regress performance? + +Absolute RPS drifts between Docker Desktop / macOS updates, so comparing runs from different months is misleading. Use `run_ab.sh` to compare two tiny-proxy versions **side by side, in the same environment, at the same moment**: + +```bash +cd benchmarks +./run_ab.sh v0.5.0 # v0.5.0 (from git tag) vs current working tree +ROUNDS=9 ./run_ab.sh v0.5.0 +``` + +How it works: + +- builds one image from a `git archive` of the requested ref and one from the working tree; +- runs BOTH containers simultaneously (old on `8080/8443`, new on `8090/8446`) against the same backends — no container churn between measurements; +- performs interleaved rounds measuring each scenario on old, then immediately on new, so thermal drift and background load affect both equally; +- reports the **median** RPS across rounds (robust against outlier rounds). + +### v0.5.0 → v0.6.0 (forward_auth release), 2026-10-06 -tiny-proxy shows ~10% higher throughput than nginx and ~25% higher than Caddy. The p99 latency difference is more pronounced: tiny-proxy at 61ms vs nginx at 117ms and Caddy at 158ms. This is likely due to rustls (with aws-lc-rs backend) handling TLS handshakes efficiently. +| Scenario | v0.5.0 (median RPS) | v0.6.0 (median RPS) | Δ | +|----------|--------------------:|--------------------:|---| +| Plain Text | 16 384 | 14 300 | −13% | +| JSON API | 14 855 | 15 370 | +3% | +| TLS | 2 235 | 2 197 | −2% | + +The text/json numbers flip sign between repeated A/B runs while using the identical code path (`handle_path` + `reverse_proxy`), which places the difference inside the ±10–15% environment noise of this setup. **Conclusion: v0.6.0 performs on par with v0.5.0** — the forward_auth middleware adds no measurable overhead when not configured. TLS is at parity as well. + +## Reproduce + +```bash +cd benchmarks +docker compose up -d # or let run.sh do it +./run.sh # 3-way comparison (add --skip-tls to skip scenario 3) +./run_ab.sh v0.5.0 # version A/B against the current tree +``` + +Requirements: Docker, `hey` (`brew install hey`). Certificates are generated automatically on first run. ## Configuration Details All proxies used the same routing: two paths (`/text/`, `/json/`) forwarding to separate backend instances. -**tiny-proxy:** +**tiny-proxy** ([benchmarks/proxies/tiny-proxy.conf](benchmarks/proxies/tiny-proxy.conf)): + ``` localhost:8080 { handle_path /text/* { reverse_proxy backend:9000 } handle_path /json/* { reverse_proxy backend-json:9000 } } + +localhost:8443 { + tls /etc/ssl/tiny-proxy/cert.pem /etc/ssl/tiny-proxy/key.pem + handle_path /text/* { reverse_proxy backend:9000 } + handle_path /json/* { reverse_proxy backend-json:9000 } +} ``` -**nginx:** +**nginx** ([benchmarks/proxies/nginx.conf](benchmarks/proxies/nginx.conf)): + ```nginx -location /text/ { proxy_pass http://backend:9000/; } -location /json/ { proxy_pass http://backend-json:9000/; } +worker_processes auto; + +events { worker_connections 1024; } + +http { + server { + listen 80; + location /text/ { proxy_pass http://backend:9000/; proxy_set_header Host $host; proxy_http_version 1.1; } + location /json/ { proxy_pass http://backend-json:9000/; proxy_set_header Host $host; proxy_http_version 1.1; } + } + server { + listen 443 ssl; + ssl_certificate /etc/ssl/bench/cert.pem; + ssl_certificate_key /etc/ssl/bench/key.pem; + # same locations + } +} ``` -**Caddy:** +**Caddy** ([benchmarks/proxies/Caddyfile](benchmarks/proxies/Caddyfile)): + ``` :8082 { reverse_proxy /text/* backend:9000 reverse_proxy /json/* backend-json:9000 } -``` - -All proxies ran with out-of-the-box defaults — no worker tuning, buffer sizing, keepalive optimization, or OS-level tweaks. Both nginx and Caddy have extensive tuning options (e.g., `worker_processes`, `worker_connections`, `proxy_buffer_size`, `keepalive_timeout`) that can meaningfully improve their numbers. These benchmarks reflect a fair "zero-config" comparison, not maximum achievable performance for any of the proxies. -## Reproduce - -```bash -cd benchmarks -docker compose up -d -./run.sh +:8445 { + tls /etc/ssl/bench/cert.pem /etc/ssl/bench/key.pem + reverse_proxy /text/* backend:9000 +} ``` -See `benchmarks/` for compose file, proxy configs, and the runner script. - ## Image Sizes +Measured locally on 2026-10-06 (`docker image ls`): + | Proxy | Docker image size | |-------|-------------------| -| tiny-proxy | 22 MB (Alpine + 4.6 MB binary) | -| nginx | 43 MB (nginx:alpine) | -| Caddy | 40 MB (caddy:alpine) | +| tiny-proxy 0.6.0 | 23 MB (Alpine + 5.6 MB static musl binary, `--all-features`) | +| nginx | 92 MB (nginx:alpine) | +| Caddy | 85 MB (caddy:alpine) | diff --git a/CHANGELOG.md b/CHANGELOG.md index c3dc08f..1027502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_No unreleased changes yet._ +## [0.6.0] - 2026-10-05 + +### Added + +- **Forward Auth middleware** (`forward_auth` directive): performs a `GET` + authorization subrequest to an external auth service before the request + reaches its backend. The original request body is never consumed by the + subrequest and remains streamed to the backend. + - `to` — auth endpoint (validated at config load: absolute `http`/`https` + URI with a host, in both the text parser and the JSON API); + - `request_headers` — whitelist of client headers copied to the subrequest + (default: `Authorization`, `Cookie`); everything else is not forwarded; + - `response_headers` — auth response headers propagated to the backend as + trusted identity headers. Client-supplied values for these headers are + removed **before** the subrequest runs, so identity headers cannot be + spoofed — including when the auth service omits them; + - `failure_mode` — `closed` (default, `503` on auth infrastructure + failure) or `open` (continue without identity headers). Explicit + `401`/`403` denials are never bypassed by `open`; + - `timeout` — subrequest deadline (default: `5s`); + - `401` responses preserve the auth service's `WWW-Authenticate` header; + - the auth response body is drained frame by frame without buffering; a `2xx` + whose body stream breaks mid-flight is treated as an infrastructure + failure (subject to `failure_mode`), not as an authorization decision; + - works inside `handle_path` / `method` blocks and is evaluated in + directive order (multiple `forward_auth` authorize sequentially); + - the subrequest carries `X-Original-URI` (path + query, captured before + any rewriting), `X-Original-Method`, `X-Forwarded-For`/`-Host`/`-Proto`, + and the request's `X-Request-ID`. `Host` on the subrequest is a system + header (always the endpoint's authority, not overridable via + `request_headers`). +- Integration tests: authorized/unauthorized/forbidden flows, fail-closed + and fail-open infrastructure failures, `401` + `open` non-bypass, + broken auth body stream (closed and open), identity-header spoofing, + POST body preservation, query strings, nested `handle_path`, header + casing, request-ID propagation, and streaming (SSE-style) responses. +- Forward-auth metrics: `auth_requests_total{result}` (`allowed` / `denied` / + `error`) and `auth_request_duration_seconds` histogram when the `metrics` + feature is enabled. + +### Changed + +- `process_directives()` is now `async` and takes an additional + `&AuthCtx` argument (forward-auth execution context). +- The proxy's request body type is unified to + `BoxBody>`; the pooled Hyper client is + shared between backend requests and forward-auth subrequests (one client, + one connection pool). +- `Proxy::start_with_addr` / `start_tls` now use the unified client type. + +### Fixed + +- `reverse_proxy` no longer drops the query string when proxying + (path + query are forwarded to the backend). ## [0.5.0] - 2026-06-16 diff --git a/Cargo.lock b/Cargo.lock index abf88d8..028bea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,9 +69,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" @@ -96,9 +96,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -106,14 +106,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -298,9 +299,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -351,7 +352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -461,9 +462,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -656,7 +657,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -883,6 +884,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1081,14 +1088,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -1131,9 +1138,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -1323,7 +1330,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1396,7 +1403,7 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tiny-proxy" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "arc-swap", @@ -1753,7 +1760,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2e53dc5..49341ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tiny-proxy" -version = "0.5.0" +version = "0.6.0" edition = "2021" description = "A high-performance HTTP reverse proxy server written in Rust with SSE support, connection pooling, and configurable routing" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 81d4f99..8063f2e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Lightweight, embeddable HTTP reverse proxy written in Rust with Caddy-like confi - **Method-based Routing**: Different behavior for different HTTP methods - **Direct Responses**: Respond with custom status codes and bodies - **Authentication Module**: Token validation and header substitution +- **Forward Auth**: Delegate authorization to an external service (`forward_auth`) - **Management API**: REST API for runtime configuration management (optional feature) - **Prometheus Metrics**: Request counters, latency histograms, and TLS handshake metrics (optional feature) @@ -405,6 +406,74 @@ localhost:8080 { } ``` +#### `forward_auth` + +Delegate authorization to an external auth service before the request reaches +its backend. tiny-proxy sends a `GET` subrequest (without the client's body) +to the configured endpoint; the original request continues only on a `2xx` +decision. + +```caddy +example.com { + forward_auth { + to http://auth:8080/verify + + # Client headers copied to the auth subrequest (default). + request_headers Authorization Cookie + + # Auth response headers propagated to the backend as + # trusted identity headers. + response_headers X-User X-Email X-Roles + + # closed (default) | open + failure_mode closed + } + + reverse_proxy http://api:8080 +} +``` + +The auth subrequest carries: + +- the whitelisted `request_headers` copied from the client request + (everything else is **not** forwarded); +- `X-Original-URI` / `X-Original-Method` describing the client request + (the URI includes the query string and is captured before any path + rewriting by `handle_path` / `strip_prefix` / `uri_replace`); +- `X-Forwarded-For` / `X-Forwarded-Host` / `X-Forwarded-Proto` + (`X-Forwarded-For` carries the immediate peer's IP — the socket address — + matching what tiny-proxy sends to backends; client-supplied + `X-Forwarded-For` chains are not forwarded); +- the request's `X-Request-ID` for tracing correlation. + +`Host` is a system header on the subrequest: it is always set to the auth +endpoint's authority and cannot be overridden via `request_headers`. The +whitelisted headers are copied from the request as it exists at the point +where `forward_auth` is evaluated — i.e., after any earlier `header` +directives have run. + +Outcome handling: + +| Auth response | Behavior | +|---------------|----------| +| `2xx` (body fully received) | Allow. Configured `response_headers` are copied onto the backend request; the auth body is drained without buffering and never forwarded. | +| `401` / `403` | Deny. Status (and `WWW-Authenticate`) is returned to the client; the backend is not contacted. | +| other `4xx` | Deny, preserving the auth service's status. | +| `5xx` / connect error / timeout / broken `2xx` body stream | Infrastructure failure: `failure_mode closed` (default) returns `503` and never contacts the backend; `failure_mode open` continues without identity headers. | + +`failure_mode open` **never** bypasses an explicit `401`/`403` denial — it +only applies to auth-service infrastructure failures. The subrequest is +bounded by `timeout` (default: `5s`). + +**Security:** client-supplied values for the configured `response_headers` +are always removed before the auth subrequest runs, so a client cannot spoof +trusted identity headers — not even when the auth service omits them or +`failure_mode open` lets a request through. + +The directive can appear anywhere in the pipeline (including inside +`handle_path` and `method` blocks) and is evaluated in directive order; +multiple `forward_auth` directives authorize sequentially. + ### Configuration Examples #### Simple Reverse Proxy @@ -463,6 +532,30 @@ localhost:8080 { } ``` +#### Forward Auth with Identity Propagation + +```caddy +example.com { + handle_path /api/* { + forward_auth { + to http://auth:8080/verify + request_headers Authorization Cookie + response_headers X-User X-Email X-Roles + failure_mode closed + } + reverse_proxy http://api:8080 + } + reverse_proxy http://static:8080 +} +``` + +Requests to `/api/*` are authorized first: the client's `Authorization` and +`Cookie` headers plus `X-Original-URI`/`X-Original-Method` go to +`http://auth:8080/verify`; on `2xx` the auth service's `X-User` / `X-Email` / +`X-Roles` response headers are attached to the backend request (client-supplied +values for those headers are stripped first). Non-API paths are not +authorized. See [`forward_auth`](#forward_auth) for details. + ### Placeholders Use placeholders in `header` and `header_up` values: @@ -548,6 +641,8 @@ Prometheus metrics exposed via a separate admin HTTP server on `/metrics`: - `http_request_duration_seconds{method,status}` — histogram - `http_active_requests` — gauge (in-flight requests) - `tls_handshakes_total{status}` — counter (`ok` / `fail`) +- `auth_requests_total{result}` — counter (`allowed` / `denied` / `error`) +- `auth_request_duration_seconds` — histogram (forward-auth subrequests) ```bash # CLI flag or TINY_PROXY_METRICS_ADDR env var @@ -608,18 +703,33 @@ RUST_LOG=debug cargo test ## Benchmarking -Run benchmarks: +### Comparative: tiny-proxy vs nginx vs Caddy + +Docker-based end-to-end benchmarks against nginx and Caddy with equivalent +configs (plain HTTP, JSON API, TLS termination), plus a version-vs-version +A/B mode for checking releases for performance regressions: ```bash -cargo bench +cd benchmarks +./run.sh # 3-way comparison (add --skip-tls to skip scenario 3) +./run_ab.sh v0.5.0 # A/B: git ref vs current tree, same-day, same-env ``` -Run specific benchmark: +Latest results (Apple M1 Max, Docker Desktop): tiny-proxy is within ~15% of +nginx on plain HTTP throughput, **leads TLS termination** with a p99 tail of +66ms vs nginx 128ms / Caddy 150ms, and ships the smallest image (23 MB vs +92/85 MB). Full methodology, configs, raw results and version history: +**[BENCHMARKS.md](BENCHMARKS.md)**. + +### Micro-benchmarks (criterion) ```bash -cargo bench -- benchmark_name +cargo bench # all +cargo bench -- pattern_matching # specific group ``` +Results land in `target/criterion/report/index.html`. + ## Development ### Project Structure diff --git a/benchmarks/ab-override.yml b/benchmarks/ab-override.yml new file mode 100644 index 0000000..c9f2a62 --- /dev/null +++ b/benchmarks/ab-override.yml @@ -0,0 +1,19 @@ +# A/B override: run TWO tiny-proxy versions side by side on separate ports. +# Used by run_ab.sh for same-day version comparisons. Set: +# TINY_PROXY_IMAGE_OLD / TINY_PROXY_IMAGE_NEW +services: + tiny-proxy: + image: ${TINY_PROXY_IMAGE_OLD:?set TINY_PROXY_IMAGE_OLD} + build: !reset [] + + tiny-proxy-new: + image: ${TINY_PROXY_IMAGE_NEW:?set TINY_PROXY_IMAGE_NEW} + ports: + - "8090:8090" + - "8446:8446" + volumes: + - ./proxies/tiny-proxy-ab.conf:/etc/tiny-proxy/config.conf:ro + - ./certs:/etc/ssl/tiny-proxy:ro + depends_on: + - backend + - backend-json diff --git a/benchmarks/proxies/tiny-proxy-ab.conf b/benchmarks/proxies/tiny-proxy-ab.conf new file mode 100644 index 0000000..b8a815c --- /dev/null +++ b/benchmarks/proxies/tiny-proxy-ab.conf @@ -0,0 +1,22 @@ +# tiny-proxy config for the A/B benchmark (run_ab.sh). +# Same routing as tiny-proxy.conf, but on the "new version" ports so both +# versions can run side by side and be measured back-to-back without +# container restarts. +localhost:8090 { + handle_path /text/* { + reverse_proxy backend:9000 + } + handle_path /json/* { + reverse_proxy backend-json:9000 + } +} + +localhost:8446 { + tls /etc/ssl/tiny-proxy/cert.pem /etc/ssl/tiny-proxy/key.pem + handle_path /text/* { + reverse_proxy backend:9000 + } + handle_path /json/* { + reverse_proxy backend-json:9000 + } +} diff --git a/benchmarks/run.sh b/benchmarks/run.sh index 6b698bf..351522b 100755 --- a/benchmarks/run.sh +++ b/benchmarks/run.sh @@ -14,9 +14,9 @@ set -uo pipefail cd "$(dirname "$0")" # --- Config --- -REQUESTS=10000 -CONNECTIONS=100 -RUNS=3 +REQUESTS=${REQUESTS:-10000} +CONNECTIONS=${CONNECTIONS:-100} +RUNS=${RUNS:-3} RESULTS_DIR="results" log() { echo -e "\033[0;32m[BENCH]\033[0m $*" >&2; } @@ -80,6 +80,12 @@ mkdir -p "$RESULTS_DIR" TIMESTAMP=$(date +%Y%m%d_%H%M%S) SUMMARY_FILE="$RESULTS_DIR/summary_${TIMESTAMP}.md" +# --- Record the exact environment for reproducibility --- +TINY_VERSION=$(docker compose -f compose.yml exec -T tiny-proxy tiny-proxy --version 2>/dev/null | awk '{print $NF}') +NGINX_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' nginx:alpine 2>/dev/null) +CADDY_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' caddy:alpine 2>/dev/null) +DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null) + # --- Parse hey output --- parse_hey() { local output="$1" @@ -202,7 +208,9 @@ print_table() { echo "" echo "**Date:** $(date)" echo "**Tool:** hey — $REQUESTS requests, $CONNECTIONS connections, best of $RUNS runs" - echo "**Environment:** Docker Desktop on $(uname -sm)" + echo "**Environment:** Docker Desktop ${DOCKER_VERSION:-?} on $(uname -sm)" + echo "**tiny-proxy:** ${TINY_VERSION:-unknown}" + echo "**Images:** nginx ${NGINX_DIGEST:-?} · caddy ${CADDY_DIGEST:-?}" echo "" print_table "1. Plain Text (~11 bytes response)" 0 3 diff --git a/benchmarks/run_ab.sh b/benchmarks/run_ab.sh new file mode 100755 index 0000000..5658c0c --- /dev/null +++ b/benchmarks/run_ab.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# A/B benchmark: compare a git ref (e.g. v0.5.0) against the current working +# tree, built and measured back-to-back on the same day in the same +# environment. nginx / Caddy are not involved — only tiny-proxy is measured. +# +# Usage: +# cd benchmarks +# ./run_ab.sh v0.5.0 # compare v0.5.0 vs current tree +# +# Methodology (differs from run.sh on purpose): +# - BOTH versions run side by side in separate containers (old on +# 8080/8443, new on 8090/8446) hitting the same backends, so there is no +# container churn between measurements; +# - ROUNDS interleaved measurement rounds; within a round each scenario is +# measured on old, then immediately on new, so thermal drift / background +# load hits both versions equally; +# - median RPS across rounds is reported (not "best of N"), which is robust +# against outlier rounds; +# - same load profile as run.sh: 10 000 requests / 100 connections. +# +# Why: absolute RPS drifts between Docker Desktop / macOS updates, so comparing +# runs from different days is misleading. This script removes the environment +# variable from the comparison entirely. + +set -uo pipefail + +cd "$(dirname "$0")" + +OLD_REF=${1:?usage: run_ab.sh } +OLD_LABEL=${OLD_REF//\//_} +OLD_TAG="tiny-proxy-ab:${OLD_LABEL}" +NEW_TAG="tiny-proxy-ab:current" + +REQUESTS=${REQUESTS:-10000} +CONNECTIONS=${CONNECTIONS:-100} +ROUNDS=${ROUNDS:-5} + +log() { echo -e "\033[0;32m[A/B]\033[0m $*" >&2; } + +# --- Build both images --- +log "Building $OLD_TAG from git ref $OLD_REF ..." +OLD_CTX="${TMPDIR:-/tmp}/tp-ab-${OLD_LABEL}" +rm -rf "$OLD_CTX" && mkdir -p "$OLD_CTX" +# NOTE: git archive from a subdirectory only exports that subtree — run from repo root. +git -C .. archive "$OLD_REF" | tar -x -C "$OLD_CTX" || { echo "error: git archive $OLD_REF failed" >&2; exit 1; } +docker build -q -t "$OLD_TAG" "$OLD_CTX" >/dev/null || { echo "error: docker build $OLD_TAG failed" >&2; exit 1; } + +log "Building $NEW_TAG from working tree ..." +docker build -q -t "$NEW_TAG" .. >/dev/null || { echo "error: docker build $NEW_TAG failed" >&2; exit 1; } + +# --- Start everything (both versions + backends) --- +log "Starting services (old on 8080/8443, new on 8090/8446) ..." +TINY_PROXY_IMAGE_OLD=$OLD_TAG TINY_PROXY_IMAGE_NEW=$NEW_TAG \ + docker compose -f compose.yml -f ab-override.yml up -d >/dev/null + +for port in 8080 8090; do + ok=0 + for _ in $(seq 1 15); do + if curl -sf "http://localhost:$port/text/" -o /dev/null 2>/dev/null; then ok=1; break; fi + sleep 1 + done + [ "$ok" = 1 ] || { echo "error: proxy on port $port did not become ready" >&2; \ + docker compose -f compose.yml -f ab-override.yml logs --tail 20; exit 1; } +done + +cleanup() { + # Interpolation vars are required even for `down` — pass dummies. + TINY_PROXY_IMAGE_OLD=x TINY_PROXY_IMAGE_NEW=x \ + docker compose -f compose.yml -f ab-override.yml down >/dev/null 2>&1 +} +trap cleanup EXIT + +# --- Measurement --- +port_for() { + case $1 in + old-text) echo "http://localhost:8080/text/" ;; + old-json) echo "http://localhost:8080/json/" ;; + old-tls) echo "https://localhost:8443/text/" ;; + new-text) echo "http://localhost:8090/text/" ;; + new-json) echo "http://localhost:8090/json/" ;; + new-tls) echo "https://localhost:8446/text/" ;; + esac +} + +# One hey run. Prints RPS, or nothing on repeated failure. +measure() { + local key=$1 url=$2; shift 2 + local out rps attempt + for attempt in 1 2 3; do + out=$(hey -n "$REQUESTS" -c "$CONNECTIONS" "$@" "$url" 2>&1) || true + if echo "$out" | grep -q '\[200\]'; then + rps=$(echo "$out" | grep "Requests/sec" | awk '{printf "%.0f", $2}') + if [ "${rps:-0}" -gt 0 ] 2>/dev/null; then + echo "$rps" + return 0 + fi + fi + sleep 2 + done +} + +median() { sort -n | awk '{a[NR]=$1} END {print (NR%2) ? a[(NR+1)/2] : (a[NR/2]+a[NR/2+1])/2}'; } + +# --- Warmup (both ports, once) --- +for port in 8080 8090; do + hey -n 200 -c 10 "http://localhost:$port/text/" >/dev/null 2>&1 || true +done +sleep 1 + +# --- Interleaved rounds --- +# Bash 3.2 (macOS default) has no associative arrays — plain vars per combo. +SAMPLES_old_text=""; SAMPLES_old_json=""; SAMPLES_old_tls="" +SAMPLES_new_text=""; SAMPLES_new_json=""; SAMPLES_new_tls="" + +log "Running $ROUNDS interleaved rounds (old and new measured back-to-back) ..." +for round in $(seq 1 $ROUNDS); do + for scenario in text json tls; do + for version in old new; do + key="${version}-${scenario}" + rps=$(measure "$key" "$(port_for "$key")" $([ "$scenario" = tls ] && echo -disable-keepalive -host localhost)) + if [ -z "$rps" ]; then + log "round $round $version/$scenario: SKIPPED (measurement failed)" + continue + fi + eval "SAMPLES_${version}_${scenario}=\"\${SAMPLES_${version}_${scenario}} \$rps\"" + log "round $round $version/$scenario: $rps RPS" + done + done + sleep 1 +done + +# --- Report (stdout): label|scenario|median_rps|rounds --- +for version in old new; do + case $version in + old) label=$OLD_REF ;; + new) label=current ;; + esac + for scenario in text json tls; do + eval "samples=\"\${SAMPLES_${version}_${scenario}}\"" + med=$(echo "$samples" | tr ' ' '\n' | grep -v '^$' | median) + [ -n "$med" ] || med=0 + echo "${label}|${scenario}|$(printf '%.0f' "$med")|$(echo "$samples" | tr ' ' '\n' | grep -v '^$' | tr '\n' ' ')" + done +done + +log "A/B done." diff --git a/examples/forward_auth.caddy b/examples/forward_auth.caddy new file mode 100644 index 0000000..40323a0 --- /dev/null +++ b/examples/forward_auth.caddy @@ -0,0 +1,45 @@ +# Example: forward_auth — delegate authorization to an external auth service +# +# Run with: +# tiny-proxy --config examples/forward_auth.caddy +# +# Request flow for /api/*: +# client -> tiny-proxy -> GET http://auth:8080/verify (subrequest) +# Authorization + Cookie + X-Original-URI ... +# <- 2xx: continue to http://api:8080 with X-User/X-Email/X-Roles +# <- 401/403: denied, backend never contacted + +localhost:8080 { + # Protected API: auth applies only inside this route. + handle_path /api/* { + forward_auth { + # Auth service endpoint (validated at config load). + to http://auth:8080/verify + + # Client headers copied to the subrequest (default if omitted). + request_headers Authorization Cookie + + # Auth response headers propagated to the backend as trusted + # identity headers. Client-supplied values for these headers + # are stripped first (spoofing protection). + response_headers X-User X-Email X-Roles + + # closed (default): auth outage -> 503, backend never contacted. + # open: auth infra failure -> continue WITHOUT identity headers + # (explicit 401/403 are still enforced). + failure_mode closed + + # Subrequest deadline (default: 5s). + timeout 5s + } + + reverse_proxy http://api:8080 + } + + # Public paths: no authorization. + handle_path /health { + respond 200 "ok" + } + + reverse_proxy http://static:8080 +} diff --git a/src/api/middleware.rs b/src/api/middleware.rs index 2d09028..bd86344 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -6,10 +6,12 @@ use http_body_util::Full; use hyper::{Request, Response, StatusCode}; /// API authentication middleware +/// +/// The error variant is boxed to keep `Result` small (clippy::result_large_err). pub async fn auth_middleware( req: Request, api_key: &str, -) -> Result, Response>> +) -> Result, Box>>> where B: Body, { @@ -25,13 +27,14 @@ where } } -fn unauthorized_response(message: &str) -> Response> { +fn unauthorized_response(message: &str) -> Box>> { let body = format!(r#"{{"error": "Unauthorized", "message": "{}"}}"#, message); Response::builder() .status(StatusCode::UNAUTHORIZED) .header("Content-Type", "application/json") .body(Full::new(Bytes::from(body))) + .map(Box::new) .expect("static response build") } diff --git a/src/config/mod.rs b/src/config/mod.rs index 884934b..f2257b3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,4 +3,6 @@ mod models; mod parser; pub use address::{extract_hostname, resolve_listen_addr, tls_redirect_port}; -pub use models::{Config, Directive, HeaderDirective, SiteConfig, TlsConfig}; +pub use models::{ + Config, Directive, FailureMode, ForwardAuthConfig, HeaderDirective, SiteConfig, TlsConfig, +}; diff --git a/src/config/models.rs b/src/config/models.rs index 0a408c4..8c567e4 100644 --- a/src/config/models.rs +++ b/src/config/models.rs @@ -1,5 +1,8 @@ use std::collections::HashMap; +use hyper::header::HeaderName; +use hyper::Uri; + #[cfg(feature = "api")] use serde::{Deserialize, Serialize}; @@ -64,6 +67,10 @@ pub enum Directive { status: u16, body: String, }, + /// Forward-auth middleware: performs an authorization subrequest before + /// the request continues down the pipeline. Evaluated in directive order, + /// so it can appear at site level or inside `handle_path` / `method` blocks. + ForwardAuth(ForwardAuthConfig), } /// A single header operation within a `header_up` block. @@ -74,3 +81,242 @@ pub struct HeaderDirective { pub name: String, pub value: Option, } + +/// Behavior of `forward_auth` when the auth service cannot produce an +/// authorization decision (connection error, timeout, 5xx, malformed response). +/// +/// Explicit `401`/`403` decisions are **never** bypassed by `Open`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "api", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "api", serde(rename_all = "lowercase"))] +pub enum FailureMode { + /// Reject the request with `503 Service Unavailable` (default, secure). + #[default] + Closed, + /// Let the request continue **without** identity headers when the auth + /// service has an infrastructure failure. + Open, +} + +/// `forward_auth` middleware configuration. +/// +/// The endpoint, header names, and failure mode are validated when the +/// configuration is loaded (text parser and JSON API alike) — never deferred +/// to request time. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "api", derive(Serialize, Deserialize))] +pub struct ForwardAuthConfig { + /// Auth service endpoint. Must be an absolute `http://`/`https://` URI. + #[cfg_attr(feature = "api", serde(rename = "to", with = "serde_impl::uri"))] + pub endpoint: Uri, + + /// Client headers copied into the auth subrequest (case-insensitive). + /// Everything else is **not** forwarded. + #[cfg_attr( + feature = "api", + serde( + with = "serde_impl::header_name_list", + default = "default_request_headers" + ) + )] + pub request_headers: Vec, + + /// Auth response headers propagated to the backend as trusted identity + /// headers. Client-supplied values for these headers are always removed + /// before the auth request runs (spoofing protection). + #[cfg_attr(feature = "api", serde(with = "serde_impl::header_name_list", default))] + pub response_headers: Vec, + + /// Infrastructure-failure behavior. Explicit 401/403 denials are not + /// affected by this setting. + #[cfg_attr(feature = "api", serde(default))] + pub failure_mode: FailureMode, + + /// Auth subrequest timeout in seconds. + #[cfg_attr(feature = "api", serde(default = "default_auth_timeout_secs"))] + pub timeout_secs: u64, +} + +/// Default client headers forwarded to the auth service: credentials only. +pub fn default_request_headers() -> Vec { + vec![ + HeaderName::from_static("authorization"), + HeaderName::from_static("cookie"), + ] +} + +/// Default auth subrequest timeout: 5 seconds. +pub fn default_auth_timeout_secs() -> u64 { + 5 +} + +/// Validate and normalize a forward-auth endpoint. +/// +/// Requires an absolute `http`/`https` URI with an authority; fills in an +/// empty path with `/`. Used by both the text parser and the JSON API so +/// both config formats enforce the same rules. +pub fn normalize_auth_endpoint(uri: Uri) -> Result { + let scheme = uri + .scheme_str() + .ok_or_else(|| "missing scheme (use http:// or https://)".to_string())?; + if scheme != "http" && scheme != "https" { + return Err(format!("unsupported scheme '{scheme}' (use http or https)")); + } + if uri.authority().is_none() { + return Err("missing host".to_string()); + } + if uri.path_and_query().is_none() { + let mut parts = uri.into_parts(); + parts.path_and_query = Some( + "/".parse() + .map_err(|e| format!("failed to set default path: {e}"))?, + ); + return Uri::from_parts(parts).map_err(|e| e.to_string()); + } + Ok(uri) +} + +/// Serde helpers mapping strongly-typed config fields to their JSON form. +/// Both directions validate, so an invalid value cannot enter the model +/// through the management API either. +#[cfg(feature = "api")] +mod serde_impl { + use super::normalize_auth_endpoint; + use hyper::header::HeaderName; + use hyper::Uri; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub(super) mod uri { + use super::*; + + pub fn serialize(value: &Uri, serializer: S) -> Result { + value.to_string().serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + let uri = Uri::try_from(s.as_str()).map_err(serde::de::Error::custom)?; + normalize_auth_endpoint(uri).map_err(serde::de::Error::custom) + } + } + + pub(super) mod header_name_list { + use super::*; + + pub fn serialize( + value: &[HeaderName], + serializer: S, + ) -> Result { + let names: Vec<&str> = value.iter().map(|n| n.as_str()).collect(); + names.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let raw = Vec::::deserialize(deserializer)?; + raw.into_iter() + .map(|s| HeaderName::from_bytes(s.as_bytes()).map_err(serde::de::Error::custom)) + .collect() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_config() -> ForwardAuthConfig { + ForwardAuthConfig { + endpoint: "http://auth.internal:8080/verify".parse().unwrap(), + request_headers: vec![ + HeaderName::from_static("authorization"), + HeaderName::from_static("cookie"), + ], + response_headers: vec![ + HeaderName::from_static("x-user"), + HeaderName::from_static("x-email"), + ], + failure_mode: FailureMode::Open, + timeout_secs: 10, + } + } + + #[test] + fn test_normalize_auth_endpoint_fills_default_path() { + let uri = normalize_auth_endpoint("http://auth.internal:8080".parse().unwrap()).unwrap(); + assert_eq!(uri.path(), "/"); + assert_eq!(uri.authority().unwrap().as_str(), "auth.internal:8080"); + } + + #[test] + fn test_normalize_auth_endpoint_accepts_path_and_query() { + let uri = normalize_auth_endpoint("https://auth/verify?x=1".parse().unwrap()).unwrap(); + assert_eq!(uri.path_and_query().unwrap().as_str(), "/verify?x=1"); + } + + #[test] + fn test_normalize_auth_endpoint_rejects_relative() { + assert!(normalize_auth_endpoint("/verify".parse().unwrap()).is_err()); + } + + #[test] + fn test_normalize_auth_endpoint_rejects_other_schemes() { + assert!(normalize_auth_endpoint("ftp://auth/".parse().unwrap()).is_err()); + // `unix://` cannot even be parsed as a `Uri`, which is equally rejected + // at config-load time by the parser / serde layer. + assert!("unix:///run/x.sock".parse::().is_err()); + } + + #[cfg(feature = "api")] + #[test] + fn test_forward_auth_serde_round_trip() { + let json = serde_json::to_string(&sample_config()).unwrap(); + assert!( + json.contains("\"to\":\"http://auth.internal:8080/verify\""), + "{json}" + ); + assert!(json.contains("\"failure_mode\":\"open\""), "{json}"); + + let parsed: ForwardAuthConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.endpoint, sample_config().endpoint); + assert_eq!(parsed.failure_mode, FailureMode::Open); + assert_eq!(parsed.timeout_secs, 10); + assert_eq!( + parsed.request_headers, + vec![ + HeaderName::from_static("authorization"), + HeaderName::from_static("cookie"), + ] + ); + assert_eq!(parsed.response_headers.len(), 2); + } + + #[cfg(feature = "api")] + #[test] + fn test_forward_auth_serde_defaults() { + let parsed: ForwardAuthConfig = + serde_json::from_str(r#"{"to": "http://auth/verify"}"#).unwrap(); + assert_eq!(parsed.failure_mode, FailureMode::Closed); + assert_eq!(parsed.timeout_secs, default_auth_timeout_secs()); + assert_eq!(parsed.request_headers, default_request_headers()); + assert!(parsed.response_headers.is_empty()); + } + + #[cfg(feature = "api")] + #[test] + fn test_forward_auth_serde_rejects_invalid_values() { + assert!( + serde_json::from_str::(r#"{"to": "ftp://auth/verify"}"#).is_err() + ); + assert!(serde_json::from_str::(r#"{"to": "/verify"}"#).is_err()); + assert!(serde_json::from_str::( + r#"{"to": "http://auth/verify", "request_headers": ["Bad Header!"]}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"to": "http://auth/verify", "failure_mode": "banana"}"# + ) + .is_err()); + } +} diff --git a/src/config/parser.rs b/src/config/parser.rs index 94b7687..54cf676 100644 --- a/src/config/parser.rs +++ b/src/config/parser.rs @@ -1,6 +1,6 @@ use crate::config::address::{extract_hostname, resolve_listen_addr}; -use crate::config::models::HeaderDirective; -use crate::config::{Config, Directive, SiteConfig}; +use crate::config::models::{normalize_auth_endpoint, HeaderDirective}; +use crate::config::{Config, Directive, FailureMode, ForwardAuthConfig, SiteConfig}; use crate::error::ProxyError; use std::collections::HashMap; use std::net::SocketAddr; @@ -15,6 +15,18 @@ struct PendingBlock { read_timeout: Option, // header_up operations collected inside a reverse_proxy block header_up: Vec, + forward_auth: Option, +} + +/// Raw state accumulated while parsing a `forward_auth { ... }` block. +/// Validated and converted into [`ForwardAuthConfig`] when the block closes. +#[derive(Debug, Default)] +struct ForwardAuthBlock { + to: Option, + request_headers: Option>, + response_headers: Option>, + failure_mode: Option, + timeout: Option, } /// Parse a human-readable duration string into seconds. @@ -59,6 +71,67 @@ fn parse_duration(s: &str) -> Result { Ok(value * multiplier) } +/// Validate a raw `forward_auth` block into a fully typed configuration. +/// +/// All validation happens here — at config-load time — so the runtime never +/// sees an invalid endpoint, header name, or failure mode. +fn build_forward_auth(pending: ForwardAuthBlock, line: usize) -> Result { + use crate::config::models::{default_auth_timeout_secs, default_request_headers}; + + let raw_to = pending.to.ok_or_else(|| { + ProxyError::Parse(format!( + "forward_auth block closed on line {} is missing 'to '", + line + )) + })?; + + let uri: hyper::Uri = raw_to.parse().map_err(|e| { + ProxyError::Parse(format!( + "forward_auth 'to' is not a valid URI on line {}: {}", + line, e + )) + })?; + let endpoint = normalize_auth_endpoint(uri).map_err(|e| { + ProxyError::Parse(format!( + "Invalid forward_auth 'to' URL '{}' on line {}: {}", + raw_to, line, e + )) + })?; + + let parse_headers = + |names: &[String], what: &str| -> Result, ProxyError> { + names + .iter() + .map(|n| { + hyper::header::HeaderName::from_bytes(n.as_bytes()).map_err(|_| { + ProxyError::Parse(format!( + "Invalid header name '{}' in forward_auth {} on line {}", + n, what, line + )) + }) + }) + .collect() + }; + + let request_headers = match pending.request_headers { + Some(names) => parse_headers(&names, "request_headers")?, + None => default_request_headers(), + }; + + let response_headers = match pending.response_headers { + Some(names) => parse_headers(&names, "response_headers")?, + None => Vec::new(), + }; + + Ok(Directive::ForwardAuth(ForwardAuthConfig { + endpoint, + request_headers, + response_headers, + failure_mode: pending.failure_mode.unwrap_or(FailureMode::Closed), + timeout_secs: pending.timeout.unwrap_or_else(default_auth_timeout_secs), + })) +} + impl Config { pub fn from_file(path: &str) -> Result { let content = std::fs::read_to_string(path)?; @@ -98,6 +171,7 @@ impl FromStr for Config { // Nested block (handle_path, method, reverse_proxy, etc.) let directive_type = parts[0].to_string(); + let is_forward_auth = directive_type == "forward_auth"; // Filter out trailing "{" from args let args = parts[1..] .iter() @@ -111,6 +185,11 @@ impl FromStr for Config { connect_timeout: None, read_timeout: None, header_up: vec![], + forward_auth: if is_forward_auth { + Some(ForwardAuthBlock::default()) + } else { + None + }, }); directive_stack.push(vec![]); continue; @@ -122,7 +201,7 @@ impl FromStr for Config { let finished_directives = directive_stack .pop() .expect("directive_stack has at least 2 elements"); - let block_info = block_stack.pop().expect("block_stack has matching entry"); + let mut block_info = block_stack.pop().expect("block_stack has matching entry"); let completed_directive = match block_info.directive_type.as_str() { "handle_path" => { @@ -145,6 +224,20 @@ impl FromStr for Config { header_up: block_info.header_up, } } + "forward_auth" => { + if !finished_directives.is_empty() { + return Err(ProxyError::Parse(format!( + "Nested directives are not allowed inside a forward_auth block (line {}). Allowed: to, request_headers, response_headers, failure_mode, timeout.", + line_num + 1 + ))); + } + let pending = block_info.forward_auth.take().ok_or_else(|| { + ProxyError::Parse( + "internal error: forward_auth block state missing".to_string(), + ) + })?; + build_forward_auth(pending, line_num + 1)? + } _ => { return Err(ProxyError::Parse(format!( "Unknown block type: {}", @@ -193,6 +286,91 @@ impl FromStr for Config { let directive_name = parts[0]; let args = parts[1..].to_vec(); + // Special handling: settings inside a forward_auth block + if let Some(block) = block_stack.last_mut() { + if block.directive_type == "forward_auth" { + let pending = match block.forward_auth.as_mut() { + Some(p) => p, + None => { + return Err(ProxyError::Parse( + "internal error: forward_auth block state missing".to_string(), + )) + } + }; + match directive_name { + "to" => { + let raw = args.first().map(|s| s.to_string()).ok_or_else(|| { + ProxyError::Parse(format!( + "Missing URL for forward_auth 'to' on line {}", + line_num + 1 + )) + })?; + pending.to = Some(raw); + continue; + } + "request_headers" | "response_headers" => { + if args.is_empty() { + return Err(ProxyError::Parse(format!( + "Missing header names for '{}' on line {}", + directive_name, + line_num + 1 + ))); + } + let headers: Vec = args.iter().map(|s| s.to_string()).collect(); + if directive_name == "request_headers" { + pending.request_headers = Some(headers); + } else { + pending.response_headers = Some(headers); + } + continue; + } + "failure_mode" => { + let raw = args.first().copied().ok_or_else(|| { + ProxyError::Parse(format!( + "Missing value for failure_mode on line {}", + line_num + 1 + )) + })?; + let mode = match raw { + "closed" => FailureMode::Closed, + "open" => FailureMode::Open, + other => { + return Err(ProxyError::Parse(format!( + "Invalid failure_mode '{}' on line {}. Expected 'open' or 'closed'", + other, + line_num + 1 + ))) + } + }; + pending.failure_mode = Some(mode); + continue; + } + "timeout" => { + let raw = args.first().copied().ok_or_else(|| { + ProxyError::Parse(format!( + "Missing value for forward_auth timeout on line {}", + line_num + 1 + )) + })?; + pending.timeout = Some(parse_duration(raw).map_err(|e| { + ProxyError::Parse(format!( + "Invalid forward_auth timeout on line {}: {}", + line_num + 1, + e + )) + })?); + continue; + } + _ => { + return Err(ProxyError::Parse(format!( + "Unexpected directive '{}' inside forward_auth block on line {}. Allowed: to, request_headers, response_headers, failure_mode, timeout.", + directive_name, line_num + 1 + ))); + } + } + } + } + // Special handling: timeout and header_up settings inside a reverse_proxy block if let Some(block) = block_stack.last_mut() { if block.directive_type == "reverse_proxy" { @@ -588,6 +766,186 @@ mod tests { assert!(err_msg.contains("Unexpected directive"), "{}", err_msg); } + #[test] + fn test_parse_forward_auth_minimal_defaults() { + let config = r#"localhost:8080 { + forward_auth { + to http://auth:8080/verify + } + reverse_proxy http://backend:9001 +}"#; + let config: Config = config.parse().unwrap(); + let site = config.sites.get("localhost:8080").unwrap(); + match &site.directives[0] { + Directive::ForwardAuth(auth) => { + assert_eq!(auth.endpoint, "http://auth:8080/verify"); + // Secure defaults + assert_eq!(auth.failure_mode, FailureMode::Closed); + assert_eq!(auth.timeout_secs, 5); + assert_eq!(auth.request_headers.len(), 2); + assert!(auth + .request_headers + .iter() + .any(|h| h.as_str() == "authorization")); + assert!(auth.request_headers.iter().any(|h| h.as_str() == "cookie")); + assert!(auth.response_headers.is_empty()); + } + _ => panic!("Expected ForwardAuth directive"), + } + } + + #[test] + fn test_parse_forward_auth_full() { + let config = r#"localhost:8080 { + forward_auth { + to https://auth.example.com:8443/authorize + request_headers Authorization Cookie X-API-Key + response_headers X-User X-Email X-Roles + failure_mode open + timeout 10s + } + reverse_proxy http://backend:9001 +}"#; + let config: Config = config.parse().unwrap(); + let site = config.sites.get("localhost:8080").unwrap(); + match &site.directives[0] { + Directive::ForwardAuth(auth) => { + assert_eq!(auth.endpoint, "https://auth.example.com:8443/authorize"); + assert_eq!(auth.failure_mode, FailureMode::Open); + assert_eq!(auth.timeout_secs, 10); + let req: Vec<&str> = auth.request_headers.iter().map(|h| h.as_str()).collect(); + assert_eq!(req, vec!["authorization", "cookie", "x-api-key"]); + let resp: Vec<&str> = auth.response_headers.iter().map(|h| h.as_str()).collect(); + assert_eq!(resp, vec!["x-user", "x-email", "x-roles"]); + } + _ => panic!("Expected ForwardAuth directive"), + } + } + + #[test] + fn test_parse_forward_auth_failure_mode_closed_explicit() { + let config = "localhost:8080 {\n forward_auth {\n to http://auth:8080/verify\n failure_mode closed\n }\n}"; + let config: Config = config.parse().unwrap(); + let site = config.sites.get("localhost:8080").unwrap(); + match &site.directives[0] { + Directive::ForwardAuth(auth) => assert_eq!(auth.failure_mode, FailureMode::Closed), + _ => panic!("Expected ForwardAuth directive"), + } + } + + #[test] + fn test_parse_forward_auth_invalid_failure_mode_rejected() { + let config = "localhost:8080 {\n forward_auth {\n to http://auth:8080/verify\n failure_mode banana\n }\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("failure_mode"), "{}", err_msg); + } + + #[test] + fn test_parse_forward_auth_missing_to_rejected() { + let config = "localhost:8080 {\n forward_auth {\n failure_mode open\n }\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("to"), "{}", err_msg); + } + + #[test] + fn test_parse_forward_auth_invalid_uri_rejected() { + // No scheme — not a usable upstream endpoint. + let config = "localhost:8080 {\n forward_auth {\n to auth:8080/verify\n }\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + + let config = + "localhost:8080 {\n forward_auth {\n to ftp://auth:8080/verify\n }\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + } + + #[test] + fn test_parse_forward_auth_invalid_header_name_rejected() { + let config = "localhost:8080 {\n forward_auth {\n to http://auth:8080/verify\n request_headers Authorization Bad\\ Header\n }\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("header name"), "{}", err_msg); + } + + #[test] + fn test_parse_forward_auth_unknown_key_rejected() { + let config = "localhost:8080 {\n forward_auth {\n to http://auth:8080/verify\n banana 1\n }\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("forward_auth block"), "{}", err_msg); + } + + #[test] + fn test_parse_forward_auth_nested_directive_rejected() { + let config = "localhost:8080 {\n forward_auth {\n to http://auth:8080/verify\n handle_path /x {\n respond 200\n }\n }\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + } + + #[test] + fn test_parse_forward_auth_inside_handle_path() { + let config = r#"localhost:8080 { + handle_path /api/* { + forward_auth { + to http://auth:8080/verify + response_headers X-User + } + reverse_proxy http://backend:9001 + } + respond 404 +}"#; + let config: Config = config.parse().unwrap(); + let site = config.sites.get("localhost:8080").unwrap(); + match &site.directives[0] { + Directive::HandlePath { directives, .. } => { + assert!(matches!(directives[0], Directive::ForwardAuth(_))); + assert!(matches!(directives[1], Directive::ReverseProxy { .. })); + } + _ => panic!("Expected HandlePath directive"), + } + } + + #[test] + fn test_forward_auth_endpoint_path_normalized() { + let config = "localhost:8080 {\n forward_auth {\n to http://auth:8080\n }\n}"; + let config: Config = config.parse().unwrap(); + let site = config.sites.get("localhost:8080").unwrap(); + match &site.directives[0] { + Directive::ForwardAuth(auth) => { + assert_eq!(auth.endpoint.path(), "/"); + assert_eq!(auth.endpoint.authority().unwrap(), "auth:8080"); + } + _ => panic!("Expected ForwardAuth directive"), + } + } + + #[test] + fn test_forward_auth_example_config_parses() { + // The shipped example must stay in sync with the parser. + let content = include_str!("../../examples/forward_auth.caddy"); + let config: Config = content.parse().unwrap(); + assert_eq!(config.sites.len(), 1); + + let site = config.sites.get("localhost:8080").unwrap(); + match &site.directives[0] { + Directive::HandlePath { + pattern, + directives, + } => { + assert_eq!(pattern, "/api/*"); + assert!(matches!(directives[0], Directive::ForwardAuth(_))); + } + _ => panic!("Expected HandlePath directive"), + } + } + #[test] fn test_parse_tls_directive() { let config = r#"example.com:443 { diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 769ebcb..07753bd 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -10,6 +10,8 @@ //! | `http_request_duration_seconds` | histogram | `method`, `status` | //! | `http_active_requests` | gauge | (none) | //! | `tls_handshakes_total` | counter | `status` (`ok` / `fail`) | +//! | `auth_requests_total` | counter | `result` (`allowed` / `denied` / `error`) | +//! | `auth_request_duration_seconds` | histogram | (none) | //! //! # Usage //! @@ -21,5 +23,5 @@ mod recorder; mod server; -pub use recorder::{record_request, tls_handshake, MetricsGuard}; +pub use recorder::{record_auth, record_request, tls_handshake, MetricsGuard}; pub use server::start_metrics_server; diff --git a/src/metrics/recorder.rs b/src/metrics/recorder.rs index c76ac4a..252526c 100644 --- a/src/metrics/recorder.rs +++ b/src/metrics/recorder.rs @@ -82,3 +82,20 @@ pub fn tls_handshake(status: &str) { /// No-op when `metrics` feature is disabled. #[cfg(not(feature = "metrics"))] pub fn tls_handshake(_status: &str) {} + +/// Record a forward-auth subrequest outcome. +/// +/// `result` is `allowed` (2xx), `denied` (4xx decision), or `error` +/// (infrastructure failure — the request then continues or fails per +/// `failure_mode`). +#[cfg(feature = "metrics")] +pub fn record_auth(result: &str, duration: std::time::Duration) { + use metrics::{counter, histogram}; + + counter!("auth_requests_total", "result" => result.to_owned()).increment(1); + histogram!("auth_request_duration_seconds").record(duration.as_secs_f64()); +} + +/// No-op when `metrics` feature is disabled. +#[cfg(not(feature = "metrics"))] +pub fn record_auth(_result: &str, _duration: std::time::Duration) {} diff --git a/src/proxy/forward_auth.rs b/src/proxy/forward_auth.rs new file mode 100644 index 0000000..da48e90 --- /dev/null +++ b/src/proxy/forward_auth.rs @@ -0,0 +1,471 @@ +//! Forward-auth middleware: authorization subrequests before a request may +//! continue to its backend. +//! +//! `forward_auth` is a middleware, not a terminal handler. When the directive +//! pipeline reaches a [`crate::config::Directive::ForwardAuth`] entry, the proxy +//! sends a `GET` subrequest (no client body) to the configured auth endpoint. +//! The original request continues only on a 2xx decision; explicit 401/403 +//! denials are returned to the client; infrastructure failures (connect +//! error, timeout, 5xx, malformed response) respect the configured +//! [`FailureMode`](crate::config::FailureMode). +//! +//! # Decision vs infrastructure failure +//! +//! ```text +//! AUTH DECISION (never bypassed by failure_mode open) +//! 2xx -> allow (only with a fully drained response body) +//! 401 / 403 -> deny (status + WWW-Authenticate preserved) +//! other 4xx -> deny (auth service status preserved) +//! +//! AUTH INFRASTRUCTURE FAILURE (subject to failure_mode) +//! connect error / timeout / DNS / 5xx / malformed / +//! 2xx whose body stream broke mid-flight +//! closed -> 503 to the client, backend NOT contacted +//! open -> continue WITHOUT identity headers +//! ``` +//! +//! # Identity-header spoofing protection +//! +//! Configured `response_headers` are scrubbed from the client request **before** +//! the subrequest runs, so a client can never inject trusted identity headers — +//! not even when `failure_mode open` lets a request through without auth. + +use std::net::SocketAddr; +use std::time::Duration; + +use http_body_util::BodyExt; +use hyper::body::Incoming; +use hyper::header::{HeaderValue, WWW_AUTHENTICATE}; +use hyper::{HeaderMap, Method, Request, StatusCode, Version}; +use hyper_util::client::legacy::Error as ClientError; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +use crate::config::ForwardAuthConfig; +use crate::metrics::record_auth; +use crate::proxy::types::{ProxyClient, ProxyRequestBody}; + +/// Per-request context needed to execute a forward-auth subrequest. +pub struct AuthCtx<'a> { + /// Shared pooled client — the same instance used for backend requests. + pub client: &'a ProxyClient, + /// The client's original request target (path + query) as received by + /// tiny-proxy, captured before any path rewriting (`handle_path`, + /// `strip_prefix`, `uri_replace`). Sent as `X-Original-URI`. + pub original_uri: &'a str, + /// Client socket address, used for `X-Forwarded-For` on the subrequest. + pub remote_addr: SocketAddr, + /// Whether the client connected over TLS, used for `X-Forwarded-Proto`. + pub is_tls: bool, +} + +/// Outcome of a forward-auth subrequest. +#[derive(Debug)] +pub enum AuthResult { + /// 2xx from the auth service. `identity` carries the configured + /// response headers extracted from the auth response (trusted values). + Allowed { identity: HeaderMap }, + /// Explicit denial (401/403/other 4xx). The status is preserved from the + /// auth service decision. + Denied { + status: StatusCode, + www_authenticate: Option, + }, + /// Infrastructure failure — the auth service produced no valid decision. + Failed { reason: AuthFailure }, +} + +/// Classification of forward-auth infrastructure failures. +#[derive(Debug)] +pub enum AuthFailure { + Connect(String), + Timeout, + ServerError(StatusCode), + Malformed(String), +} + +/// Remove client-supplied values for the configured identity headers. +/// +/// Runs **before** the subrequest so that spoofed values can never reach the +/// backend — including when `failure_mode open` continues without auth or when +/// the auth response omits a configured header. +pub fn scrub_identity_headers(req: &mut Request, cfg: &ForwardAuthConfig) { + for name in &cfg.response_headers { + if req.headers_mut().remove(name).is_some() { + debug!("forward_auth: scrubbed client-supplied identity header {name}"); + } + } +} + +/// Copy trusted identity headers from the auth response onto the request +/// sent to the backend. Only headers explicitly listed in `response_headers` +/// are ever present in `identity`. +pub fn apply_identity_headers(req: &mut Request, identity: &HeaderMap) { + for (name, value) in identity { + req.headers_mut().insert(name.clone(), value.clone()); + debug!("forward_auth: applied identity header {name}"); + } +} + +/// Empty request body for the auth subrequest. The client's request body is +/// never consumed by forward auth. +pub(crate) fn empty_request_body() -> ProxyRequestBody { + use http_body_util::{BodyExt, Empty}; + // Empty's error type is Infallible; convert to the unified boxed error type. + Empty::::new().map_err(|e| match e {}).boxed() +} + +/// Box the client's streaming request body into the unified client body type. +pub(crate) fn box_incoming(body: Incoming) -> ProxyRequestBody { + use http_body_util::BodyExt; + body.map_err(|e| Box::new(e) as Box) + .boxed() +} + +/// Build the authorization subrequest. +/// +/// The subrequest is `GET ` with: +/// - the whitelisted `request_headers` copied from the client request; +/// - `X-Original-URI` / `X-Original-Method` describing the client request; +/// - `X-Forwarded-For` / `X-Forwarded-Host` / `X-Forwarded-Proto` matching the +/// forwarding conventions used for backend requests (`X-Forwarded-For` is the +/// immediate peer's IP, not a forwarded client chain); +/// - the request's `X-Request-ID` propagated for tracing correlation; +/// - no body (the client's body is left untouched for the backend). +fn build_auth_request( + original_uri: &str, + remote_addr: SocketAddr, + is_tls: bool, + req: &Request, + cfg: &ForwardAuthConfig, +) -> Result, String> { + let mut headers = HeaderMap::new(); + + // Whitelisted client headers only — never a blind copy. + for name in &cfg.request_headers { + if let Some(value) = req.headers().get(name) { + headers.insert(name.clone(), value.clone()); + } + } + + // Host comes from the (validated) endpoint authority. It is a system + // header: inserted after the whitelist copy so the endpoint always wins + // over a client-supplied Host. + if let Some(authority) = cfg.endpoint.authority() { + if let Ok(host) = HeaderValue::from_str(authority.as_str()) { + headers.insert(hyper::header::HOST, host); + } + } + + let put = |headers: &mut HeaderMap, name: &'static str, value: &str| { + if let Ok(v) = HeaderValue::from_str(value) { + headers.insert(name, v); + } else { + debug!("forward_auth: skipping invalid {name} value"); + } + }; + put(&mut headers, "x-original-uri", original_uri); + put(&mut headers, "x-original-method", req.method().as_str()); + put( + &mut headers, + "x-forwarded-for", + &remote_addr.ip().to_string(), + ); + if let Some(host) = req.headers().get(hyper::header::HOST) { + headers.insert("x-forwarded-host", host.clone()); + } + put( + &mut headers, + "x-forwarded-proto", + if is_tls { "https" } else { "http" }, + ); + if let Some(id) = req.headers().get("x-request-id") { + headers.insert("x-request-id", id.clone()); + } + + let mut auth_req = Request::builder() + .method(Method::GET) + .uri(cfg.endpoint.clone()) + .version(Version::HTTP_11) + .body(empty_request_body()) + .map_err(|e| format!("failed to build auth request: {e}"))?; + *auth_req.headers_mut() = headers; + + Ok(auth_req) +} + +/// Execute the forward-auth subrequest and classify its outcome. +/// +/// This function is responsible for authorization only — it never proxies the +/// request and never mutates `req` (the caller scrubs/applies identity headers +/// around it). +pub async fn resolve_auth( + ctx: &AuthCtx<'_>, + req: &Request, + cfg: &ForwardAuthConfig, +) -> AuthResult { + let started = std::time::Instant::now(); + + let auth_req = match build_auth_request(ctx.original_uri, ctx.remote_addr, ctx.is_tls, req, cfg) + { + Ok(r) => r, + Err(e) => { + // Invalid metadata header values (e.g. non-ASCII original URI) — + // fail closed/open per configuration, never spoof. + record_auth("error", started.elapsed()); + return AuthResult::Failed { + reason: AuthFailure::Malformed(e), + }; + } + }; + + info!( + " forward_auth: GET {} (timeout: {}s, failure_mode: {:?})", + cfg.endpoint, cfg.timeout_secs, cfg.failure_mode + ); + + let fut = async { + let response = ctx.client.request(auth_req).await?; + let status = response.status(); + let www_authenticate = response.headers().get(WWW_AUTHENTICATE).cloned(); + + let mut identity = HeaderMap::new(); + if status.is_success() { + for name in &cfg.response_headers { + if let Some(value) = response.headers().get(name) { + identity.insert(name.clone(), value.clone()); + } + } + } + + // Drain the (auth-service-controlled, typically empty) body frame by + // frame — no buffering — so the pooled connection can be reused; + // bounded by the overall timeout. A body stream that breaks mid-flight + // is captured here and classified below. + let mut body_err: Option = None; + let mut body = response.into_body(); + loop { + match body.frame().await { + Some(Ok(_)) => continue, + Some(Err(e)) => { + body_err = Some(e.to_string()); + break; + } + None => break, + } + } + + Ok::<_, ClientError>((status, www_authenticate, identity, body_err)) + }; + + let outcome = match timeout(Duration::from_secs(cfg.timeout_secs), fut).await { + Ok(Ok((status, www_authenticate, identity, body_err))) => { + if status.is_success() { + // A 2xx whose body stream broke is not a reliable decision — + // treat it as an infrastructure failure (failure_mode applies). + if let Some(e) = body_err { + AuthResult::Failed { + reason: AuthFailure::Malformed(e), + } + } else { + AuthResult::Allowed { identity } + } + } else if status.is_client_error() { + AuthResult::Denied { + status, + www_authenticate, + } + } else { + AuthResult::Failed { + reason: AuthFailure::ServerError(status), + } + } + } + Ok(Err(e)) => { + let reason = if e.is_connect() { + AuthFailure::Connect(e.to_string()) + } else { + AuthFailure::Malformed(e.to_string()) + }; + AuthResult::Failed { reason } + } + Err(_elapsed) => AuthResult::Failed { + reason: AuthFailure::Timeout, + }, + }; + + match &outcome { + AuthResult::Allowed { .. } => { + record_auth("allowed", started.elapsed()); + info!(" forward_auth: allowed"); + } + AuthResult::Denied { status, .. } => { + record_auth("denied", started.elapsed()); + info!(" forward_auth: denied with {}", status.as_u16()); + } + AuthResult::Failed { reason } => { + record_auth("error", started.elapsed()); + warn!(" forward_auth: auth service failure ({:?})", reason); + } + } + + outcome +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use http_body_util::Empty; + use hyper::Request; + + fn remote_addr() -> SocketAddr { + "203.0.113.7:42000".parse().unwrap() + } + + fn make_config() -> ForwardAuthConfig { + ForwardAuthConfig { + endpoint: "http://auth.internal:8080/verify".parse().unwrap(), + request_headers: vec!["authorization".parse().unwrap()], + response_headers: vec!["x-user".parse().unwrap()], + failure_mode: crate::config::FailureMode::Closed, + timeout_secs: 5, + } + } + + #[test] + fn test_build_auth_request_metadata() { + let req = Request::builder() + .method("POST") + .uri("/api/users?id=123") + .header("Authorization", "Bearer tok") + .header("X-Request-ID", "req-42") + .header("Host", "example.com:8080") + .header("X-Internal-Secret", "leak-me") + .body(Empty::::new()) + .unwrap(); + + let cfg = make_config(); + + let auth_req = + build_auth_request("/api/users?id=123", remote_addr(), false, &req, &cfg).unwrap(); + + assert_eq!(*auth_req.method(), Method::GET); + assert_eq!(auth_req.uri(), &cfg.endpoint); + assert_eq!( + auth_req.headers().get("x-original-uri").unwrap(), + "/api/users?id=123" + ); + assert_eq!(auth_req.headers().get("x-original-method").unwrap(), "POST"); + assert_eq!( + auth_req.headers().get("authorization").unwrap(), + "Bearer tok" + ); + assert_eq!(auth_req.headers().get("x-request-id").unwrap(), "req-42"); + assert_eq!( + auth_req.headers().get("host").unwrap(), + "auth.internal:8080" + ); + assert_eq!( + auth_req.headers().get("x-forwarded-host").unwrap(), + "example.com:8080" + ); + assert_eq!( + auth_req.headers().get("x-forwarded-for").unwrap(), + "203.0.113.7" + ); + assert_eq!(auth_req.headers().get("x-forwarded-proto").unwrap(), "http"); + + // Non-whitelisted client headers must NOT reach the auth service. + assert!(auth_req.headers().get("x-internal-secret").is_none()); + } + + #[test] + fn test_build_auth_request_case_insensitive_copy() { + // Config header name is lowercase; client sends mixed case. + let req = Request::builder() + .method("GET") + .uri("/") + .header("aUtHoRiZaTiOn", "Bearer mixed-case") + .body(Empty::::new()) + .unwrap(); + + let auth_req = build_auth_request("/", remote_addr(), false, &req, &make_config()).unwrap(); + + assert_eq!( + auth_req.headers().get("Authorization").unwrap(), + "Bearer mixed-case" + ); + } + + #[test] + fn test_build_auth_request_missing_headers_ok() { + let req = Request::builder() + .method("GET") + .uri("/") + .body(Empty::::new()) + .unwrap(); + + let auth_req = build_auth_request("/", remote_addr(), false, &req, &make_config()).unwrap(); + + // Whitelisted header absent from the client request: simply not copied. + assert!(auth_req.headers().get("authorization").is_none()); + assert!(auth_req.headers().get("x-forwarded-host").is_none()); + } + + #[test] + fn test_scrub_and_apply_identity_headers() { + let cfg = make_config(); + let mut req = Request::builder() + .header("x-user", "attacker") + .header("x-keep", "kept") + .body(Empty::::new()) + .unwrap(); + + scrub_identity_headers(&mut req, &cfg); + assert!(req.headers().get("x-user").is_none()); + assert_eq!(req.headers().get("x-keep").unwrap(), "kept"); + + let mut identity = HeaderMap::new(); + identity.insert("x-user", HeaderValue::from_static("real-user")); + apply_identity_headers(&mut req, &identity); + assert_eq!(req.headers().get("x-user").unwrap(), "real-user"); + } + + #[test] + fn test_host_header_is_system_controlled() { + // Even if `request_headers` whitelists Host and the client sends one, + // the auth request's Host must be the endpoint authority — a client + // must not be able to influence where the subrequest appears to come from. + let mut cfg = make_config(); + cfg.request_headers.push("host".parse().unwrap()); + + let req = Request::builder() + .method("GET") + .uri("/") + .header("Host", "evil.example.com") + .body(Empty::::new()) + .unwrap(); + + let auth_req = build_auth_request("/", remote_addr(), false, &req, &cfg).unwrap(); + + assert_eq!( + auth_req.headers().get("host").unwrap(), + "auth.internal:8080" + ); + } + + #[test] + fn test_scrub_identity_case_insensitive() { + // Client sends the identity header with different casing than config. + let cfg = make_config(); + let mut req = Request::builder() + .header("X-USER", "attacker") + .body(Empty::::new()) + .unwrap(); + + scrub_identity_headers(&mut req, &cfg); + assert!(req.headers().get("x-user").is_none()); + assert!(req.headers().get("X-USER").is_none()); + } +} diff --git a/src/proxy/handler.rs b/src/proxy/handler.rs index f5e72bd..757d618 100644 --- a/src/proxy/handler.rs +++ b/src/proxy/handler.rs @@ -4,9 +4,6 @@ use http_body_util::{BodyExt, Full}; use hyper::body::Incoming; use hyper::header; use hyper::{Request, Response, StatusCode, Uri}; -use hyper_rustls::HttpsConnector; -use hyper_util::client::legacy::connect::HttpConnector; -use hyper_util::client::legacy::Client; use std::sync::Arc; use tokio::time::{timeout, Duration}; use tracing::{error, info}; @@ -20,6 +17,10 @@ use crate::config::{extract_hostname, Config, SiteConfig}; #[cfg(feature = "logging")] use crate::proxy::access_log::AccessLogGuard; use crate::proxy::access_log::{ensure_request_id, final_request_id}; +use crate::proxy::forward_auth::{ + apply_identity_headers, resolve_auth, scrub_identity_headers, AuthCtx, AuthResult, +}; +use crate::proxy::types::ProxyClient; use crate::proxy::ActionResult; use crate::proxy::directives::{ @@ -50,15 +51,21 @@ fn is_hop_header(name: &header::HeaderName) -> bool { } /// Process directives in order, applying modifications and returning final action. -/// Supports recursive handling of handle_path blocks. +/// Supports recursive handling of handle_path and method blocks. +/// +/// Middleware directives (`forward_auth`) are evaluated where they appear: +/// at site level, or inside the `handle_path` / `method` block that matched. +/// Multiple `forward_auth` directives run sequentially — all must allow the +/// request before a terminal directive is reached. /// /// Note: `info!` logs here are correlated with request ID only when the `logging` /// feature is enabled (via the tracing span set in `proxy()`). Without `logging`, /// these logs appear without request context. -pub fn process_directives( +pub async fn process_directives( directives: &[crate::config::Directive], req: &mut Request, current_path: &str, + ctx: &AuthCtx<'_>, ) -> Result { let mut modified_path = current_path.to_string(); @@ -84,7 +91,13 @@ pub fn process_directives( } => { if let Some(remaining_path) = match_pattern(pattern, &modified_path) { info!(" Matched handle_path: {}", pattern); - return process_directives(nested_directives, req, &remaining_path); + return Box::pin(process_directives( + nested_directives, + req, + &remaining_path, + ctx, + )) + .await; } } @@ -94,7 +107,13 @@ pub fn process_directives( } => { if handle_method(methods, req) { info!(" Matched method directive"); - return process_directives(nested_directives, req, &modified_path); + return Box::pin(process_directives( + nested_directives, + req, + &modified_path, + ctx, + )) + .await; } } @@ -120,6 +139,41 @@ pub fn process_directives( header_up.clone(), )); } + + crate::config::Directive::ForwardAuth(auth_cfg) => { + // Spoofing protection first: configured identity headers must + // never survive from client input, even under failure_mode open. + scrub_identity_headers(req, auth_cfg); + + match resolve_auth(ctx, req, auth_cfg).await { + AuthResult::Allowed { identity } => { + apply_identity_headers(req, &identity); + } + AuthResult::Denied { + status, + www_authenticate, + } => { + return Ok(ActionResult::AuthDenied { + status: status.as_u16(), + www_authenticate, + }); + } + AuthResult::Failed { .. } => match auth_cfg.failure_mode { + crate::config::FailureMode::Closed => { + // Fail closed: the backend is never contacted when + // the auth service cannot produce a decision. + return Ok(ActionResult::AuthDenied { + status: StatusCode::SERVICE_UNAVAILABLE.as_u16(), + www_authenticate: None, + }); + } + crate::config::FailureMode::Open => { + // Infrastructure failure only — continue without + // identity headers (already scrubbed above). + } + }, + } + } } } @@ -141,7 +195,7 @@ pub fn process_directives( /// since these are small and generated by the proxy itself pub async fn proxy( mut req: Request, - client: Client, Incoming>, + client: ProxyClient, config: Arc, remote_addr: std::net::SocketAddr, is_tls: bool, @@ -154,6 +208,13 @@ pub async fn proxy( let future = async move { let path = req.uri().path().to_string(); + // Original request target (path + query) as received from the client, + // captured before any directive rewriting — used for X-Original-URI. + let original_uri = req + .uri() + .path_and_query() + .map(|pq| pq.as_str().to_string()) + .unwrap_or_default(); let host = req .headers() .get(hyper::header::HOST) @@ -194,29 +255,37 @@ pub async fn proxy( } }; + let auth_ctx = AuthCtx { + client: &client, + original_uri: &original_uri, + remote_addr, + is_tls, + }; + // Process directives in correct order - let action_result = match process_directives(&site_config.directives, &mut req, &path) { - Ok(result) => result, - Err(e) => { - error!("Directive processing error: {}", e); - let final_id = final_request_id(&req, &initial_request_id); - #[cfg(feature = "logging")] - { - log_guard.set_request_id(final_id.clone()); - tracing::Span::current().record("req_id", final_id.as_str()); - } - let (response, _body_len) = - error_response_with_id(StatusCode::INTERNAL_SERVER_ERROR, &e, &final_id); - #[cfg(feature = "logging")] - { - log_guard.set_bytes_sent(_body_len); - log_guard.finish(500); + let action_result = + match process_directives(&site_config.directives, &mut req, &path, &auth_ctx).await { + Ok(result) => result, + Err(e) => { + error!("Directive processing error: {}", e); + let final_id = final_request_id(&req, &initial_request_id); + #[cfg(feature = "logging")] + { + log_guard.set_request_id(final_id.clone()); + tracing::Span::current().record("req_id", final_id.as_str()); + } + let (response, _body_len) = + error_response_with_id(StatusCode::INTERNAL_SERVER_ERROR, &e, &final_id); + #[cfg(feature = "logging")] + { + log_guard.set_bytes_sent(_body_len); + log_guard.finish(500); + } + #[cfg(feature = "metrics")] + metrics_guard.record(500); + return Ok(response); } - #[cfg(feature = "metrics")] - metrics_guard.record(500); - return Ok(response); - } - }; + }; // Read final request ID (directive may have overwritten X-Request-ID) let request_id = final_request_id(&req, &initial_request_id); @@ -269,6 +338,38 @@ pub async fn proxy( metrics_guard.record(status_code.as_u16()); Ok(response) } + ActionResult::AuthDenied { + status, + www_authenticate, + } => { + let status_code = StatusCode::from_u16(status).unwrap_or(StatusCode::FORBIDDEN); + + let body = if status_code == StatusCode::SERVICE_UNAVAILABLE { + "Authorization service unavailable" + } else { + "" + }; + let _body_len = body.len(); + + let boxed: ResponseBody = Full::new(Bytes::from(body.to_string())) + .map_err(|e| Box::new(e) as Box) + .boxed(); + let mut builder = Response::builder() + .status(status_code) + .header("X-Request-ID", &request_id); + if let Some(challenge) = www_authenticate { + builder = builder.header("WWW-Authenticate", challenge); + } + let response = builder.body(boxed)?; + #[cfg(feature = "logging")] + { + log_guard.set_bytes_sent(_body_len); + log_guard.finish(status_code.as_u16()); + } + #[cfg(feature = "metrics")] + metrics_guard.record(status_code.as_u16()); + Ok(response) + } ActionResult::ReverseProxy { backend_url, path_to_send, @@ -286,7 +387,17 @@ pub async fn proxy( // Use Uri::from_parts() instead of format!() + parse() - faster! let mut parts = backend_with_proto.parse::()?.into_parts(); - parts.path_and_query = Some(path_to_send.parse()?); + + // Preserve the client's query string: directive processing + // tracks only the path component (matching / stripping), so + // re-attach the query from the (not yet overwritten) request URI. + let path_with_query = match req.uri().query() { + Some(q) if !path_to_send.contains('?') => { + format!("{path_to_send}?{q}") + } + _ => path_to_send.clone(), + }; + parts.path_and_query = Some(path_with_query.parse()?); let new_uri = Uri::from_parts(parts)?; // Capture the original request URI (path + query) before we overwrite it — @@ -351,9 +462,17 @@ pub async fn proxy( &remote_ip, ); - // Forward request to backend with configurable timeout (default 30s) + // Forward request to backend with configurable timeout (default 30s). + // The client's streaming body is boxed into the unified client + // body type (no buffering). let backend_timeout = read_timeout.unwrap_or(30); - match timeout(Duration::from_secs(backend_timeout), client.request(req)).await { + let send_req = req.map(crate::proxy::forward_auth::box_incoming); + match timeout( + Duration::from_secs(backend_timeout), + client.request(send_req), + ) + .await + { Ok(Ok(response)) => { let status = response.status(); let headers = response.headers().clone(); diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index 31e180a..bd13da3 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -1,5 +1,6 @@ mod access_log; mod directives; +pub mod forward_auth; pub mod handler; #[allow(clippy::module_inception)] mod proxy; @@ -9,4 +10,4 @@ mod types; mod tls; pub use proxy::Proxy; -pub use types::ActionResult; +pub use types::{ActionResult, ProxyClient, ProxyRequestBody}; diff --git a/src/proxy/proxy.rs b/src/proxy/proxy.rs index d5285d8..1fe2bcf 100644 --- a/src/proxy/proxy.rs +++ b/src/proxy/proxy.rs @@ -1,7 +1,6 @@ use arc_swap::ArcSwap; -use hyper::body::Incoming; use hyper::service::service_fn; -use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; +use hyper_rustls::HttpsConnectorBuilder; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::client::legacy::Client; use hyper_util::rt::TokioExecutor; @@ -23,6 +22,7 @@ use crate::proxy::tls::{build_tls_acceptor, listen_http_redirect, listen_tls}; use crate::config::tls_redirect_port; use crate::config::{extract_hostname, resolve_listen_addr, Config}; use crate::proxy::handler::proxy; +use crate::proxy::types::{ProxyClient, ProxyRequestBody}; /// HTTP Proxy server that can be embedded into other applications /// @@ -75,7 +75,7 @@ use crate::proxy::handler::proxy; /// ``` pub struct Proxy { config: Arc>, - client: Client, Incoming>, + client: ProxyClient, max_concurrency: usize, semaphore: Arc, } @@ -104,10 +104,10 @@ impl Proxy { .enable_http1() .wrap_connector(http); - let client = Client::builder(TokioExecutor::new()) + let client: ProxyClient = Client::builder(TokioExecutor::new()) .pool_max_idle_per_host(100) .pool_idle_timeout(Duration::from_secs(90)) - .build::<_, Incoming>(https); + .build::<_, ProxyRequestBody>(https); let max_concurrency = std::env::var("TINY_PROXY_MAX_CONCURRENCY") .ok() @@ -149,10 +149,10 @@ impl Proxy { .enable_http1() .wrap_connector(http); - let client = Client::builder(TokioExecutor::new()) + let client: ProxyClient = Client::builder(TokioExecutor::new()) .pool_max_idle_per_host(100) .pool_idle_timeout(Duration::from_secs(90)) - .build::<_, Incoming>(https); + .build::<_, ProxyRequestBody>(https); let max_concurrency = std::env::var("TINY_PROXY_MAX_CONCURRENCY") .ok() @@ -443,7 +443,7 @@ impl Proxy { /// Core HTTP accept loop — shared between `start_http` and `start_all`. async fn run_http_loop( addr: SocketAddr, - client: Client, Incoming>, + client: ProxyClient, config: Arc>, semaphore: Arc, max_concurrency: usize, diff --git a/src/proxy/types.rs b/src/proxy/types.rs index 13919a1..0b7b5fc 100644 --- a/src/proxy/types.rs +++ b/src/proxy/types.rs @@ -1,5 +1,25 @@ +use bytes::Bytes; +use hyper::header::HeaderValue; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::Client; + use crate::config::HeaderDirective; +/// Unified request body type for outbound requests sent through the shared +/// pooled client. Backend proxying streams the client's [`hyper::body::Incoming`] +/// body boxed into this type; auth subrequests use an empty body. +/// +/// A single body type is required because `Client` fixes `B` per instance — +/// unifying here lets backend requests and forward-auth subrequests share one +/// connection pool. +pub type ProxyRequestBody = + http_body_util::combinators::BoxBody>; + +/// The shared pooled HTTP client used for backend requests and forward-auth +/// subrequests alike. +pub type ProxyClient = Client, ProxyRequestBody>; + /// Result of directive processing #[derive(Debug, Clone)] pub enum ActionResult { @@ -18,4 +38,13 @@ pub enum ActionResult { status: u16, url: String, }, + /// A `forward_auth` middleware rejected the request (401/403/other 4xx + /// denial) or the auth service failed while `failure_mode closed` was in + /// effect (503). + AuthDenied { + status: u16, + /// Authentication challenge preserved from the auth service response + /// (e.g. `WWW-Authenticate` on a 401). Never populated from client input. + www_authenticate: Option, + }, } diff --git a/tests/forward_auth_integration.rs b/tests/forward_auth_integration.rs new file mode 100644 index 0000000..63185ff --- /dev/null +++ b/tests/forward_auth_integration.rs @@ -0,0 +1,1255 @@ +//! Integration tests for the `forward_auth` middleware. +//! +//! Covers the v0.6.0 spec: decision vs infrastructure failure, identity-header +//! spoofing protection, failure modes, nested `handle_path`, request-body +//! preservation, and `X-Original-URI` semantics. + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::header::{HeaderValue, WWW_AUTHENTICATE}; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use tiny_proxy::config::{Directive, FailureMode, ForwardAuthConfig, SiteConfig}; +use tiny_proxy::{Config, Proxy}; +use tokio::net::TcpListener; + +// --------------------------------------------------------------------------- +// Test infrastructure +// --------------------------------------------------------------------------- + +async fn get_random_port_addr() -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + addr +} + +/// Scriptable behavior for the mock auth server. +#[derive(Clone)] +struct AuthScript { + status: u16, + /// Headers returned by the auth service (identity + WWW-Authenticate...). + headers: Vec<(String, String)>, + /// Artificial delay before responding. + delay: Option, +} + +impl AuthScript { + fn ok() -> Self { + Self { + status: 200, + headers: vec![], + delay: None, + } + } + + fn with_headers(mut self, headers: Vec<(&str, &str)>) -> Self { + self.headers = headers + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + self + } + + fn with_status(mut self, status: u16) -> Self { + self.status = status; + self + } + + fn with_delay(mut self, delay: Duration) -> Self { + self.delay = Some(delay); + self + } +} + +/// Captured state of one request received by the mock auth server. +#[derive(Debug, Clone, Default)] +struct AuthCapture { + method: String, + uri: String, + headers: Vec<(String, String)>, +} + +/// Shared state between the mock auth server and test assertions. +#[derive(Clone, Default)] +struct AuthState { + calls: Arc, + captures: Arc>>, +} + +impl AuthState { + fn new() -> Self { + Self::default() + } + + fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + fn last_capture(&self) -> AuthCapture { + self.captures + .lock() + .unwrap() + .last() + .cloned() + .unwrap_or_default() + } + + fn header_value(&self, name: &str) -> Option { + self.last_capture() + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.clone()) + } +} + +/// Start a scriptable mock auth server. Every request gets the scripted +/// status/headers; requests are captured for assertions. +async fn start_mock_auth(script: AuthScript, state: AuthState) -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => continue, + }; + let io = TokioIo::new(stream); + let script = script.clone(); + let state = state.clone(); + tokio::spawn(async move { + let service = service_fn(move |req: Request| { + let script = script.clone(); + let state = state.clone(); + async move { + state.calls.fetch_add(1, Ordering::SeqCst); + let (parts, body) = req.into_parts(); + // Drain auth request body (if any) for connection reuse. + let _ = body.collect().await; + + let mut capture = AuthCapture { + method: parts.method.as_str().to_string(), + uri: parts.uri.to_string(), + headers: vec![], + }; + for (name, value) in parts.headers.iter() { + capture.headers.push(( + name.as_str().to_string(), + value.to_str().unwrap_or_default().to_string(), + )); + } + state.captures.lock().unwrap().push(capture); + + if let Some(delay) = script.delay { + tokio::time::sleep(delay).await; + } + + let mut response = Response::new(Full::new(Bytes::new())); + *response.status_mut() = StatusCode::from_u16(script.status).unwrap(); + for (k, v) in &script.headers { + if let (Ok(name), Ok(value)) = ( + hyper::header::HeaderName::from_bytes(k.as_bytes()), + HeaderValue::from_str(v), + ) { + response.headers_mut().insert(name, value); + } + } + Ok::<_, Infallible>(response) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .await; + }); + } + }); + + addr +} + +/// Backend that echoes selected request properties back in the body and +/// counts every request it receives. +#[derive(Clone, Default)] +struct BackendState { + calls: Arc, +} + +impl BackendState { + fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +async fn start_echo_backend(state: BackendState) -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => continue, + }; + let io = TokioIo::new(stream); + let state = state.clone(); + tokio::spawn(async move { + let service = service_fn(move |req: Request| { + let state = state.clone(); + async move { + state.calls.fetch_add(1, Ordering::SeqCst); + let (parts, body) = req.into_parts(); + let body_bytes = body.collect().await.unwrap().to_bytes(); + + let header = |name: &str| { + parts + .headers + .get(name) + .and_then(|h| h.to_str().ok()) + .unwrap_or("") + .to_string() + }; + + let payload = format!( + "method={}|uri={}|query={}|x-user={}|x-email={}|authorization={}|body={}", + parts.method.as_str(), + parts.uri.path(), + parts.uri.query().unwrap_or(""), + header("x-user"), + header("x-email"), + header("authorization"), + String::from_utf8_lossy(&body_bytes), + ); + + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(payload)))) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .await; + }); + } + }); + + addr +} + +// --------------------------------------------------------------------------- +// Config helpers +// --------------------------------------------------------------------------- + +fn auth_config(endpoint: &str) -> ForwardAuthConfig { + ForwardAuthConfig { + endpoint: endpoint.parse().unwrap(), + request_headers: vec!["authorization".parse().unwrap(), "cookie".parse().unwrap()], + response_headers: vec!["x-user".parse().unwrap(), "x-email".parse().unwrap()], + failure_mode: FailureMode::Closed, + timeout_secs: 5, + } +} + +fn reverse_proxy(backend_addr: std::net::SocketAddr) -> Directive { + Directive::ReverseProxy { + to: format!("http://{backend_addr}"), + connect_timeout: None, + read_timeout: None, + header_up: vec![], + } +} + +fn build_config(proxy_host: &str, directives: Vec) -> Config { + let mut sites = HashMap::new(); + sites.insert( + proxy_host.to_string(), + SiteConfig { + address: proxy_host.to_string(), + directives, + tls: None, + }, + ); + Config { sites } +} + +/// Start the proxy on a random port with the given directives and return the +/// `host:port` clients should target. +async fn start_proxy(directives: Vec) -> String { + let proxy_addr = get_random_port_addr().await; + let proxy_host = format!("127.0.0.1:{}", proxy_addr.port()); + let config = build_config(&proxy_host, directives); + let proxy = Proxy::new(config); + tokio::spawn(async move { + proxy.start_with_addr(proxy_addr).await.unwrap(); + }); + tokio::time::sleep(Duration::from_millis(100)).await; + proxy_host +} + +fn test_client() -> hyper_util::client::legacy::Client< + hyper_util::client::legacy::connect::HttpConnector, + Full, +> { + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build::<_, Full>(hyper_util::client::legacy::connect::HttpConnector::new()) +} + +async fn body_string(response: Response) -> String { + String::from_utf8_lossy(&response.into_body().collect().await.unwrap().to_bytes()).to_string() +} + +fn query_param(payload: &str, key: &str) -> String { + // payload entries look like `key=value` separated by `|`; values may + // themselves contain `=` (e.g. base64 bodies) but not `|`. + payload + .split('|') + .find(|part| part.starts_with(key) && part.as_bytes().get(key.len()) == Some(&b'=')) + .map(|part| part[part.find('=').unwrap() + 1..].to_string()) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Tests: decisions +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_authorized_request_propagates_identity_and_original_headers() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth( + AuthScript::ok().with_headers(vec![ + ("X-User", "real-user"), + ("X-Email", "user@example.com"), + ("X-Roles", "admin"), // not configured in response_headers + ]), + auth_state.clone(), + ) + .await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users?id=123")) + .header("Authorization", "Bearer valid-token") + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload = body_string(response).await; + + assert_eq!(query_param(&payload, "x-user"), "real-user"); + assert_eq!(query_param(&payload, "x-email"), "user@example.com"); + assert_eq!(query_param(&payload, "authorization"), "Bearer valid-token"); + assert_eq!(query_param(&payload, "query"), "id=123"); + + assert_eq!(backend_state.call_count(), 1); + + assert_eq!(auth_state.call_count(), 1); + assert_eq!(auth_state.last_capture().uri, "/verify"); + assert_eq!( + auth_state.header_value("authorization").as_deref(), + Some("Bearer valid-token") + ); + assert_eq!( + auth_state.header_value("x-original-uri").as_deref(), + Some("/api/users?id=123") + ); + assert_eq!( + auth_state.header_value("x-original-method").as_deref(), + Some("GET") + ); + assert_eq!( + auth_state.header_value("host").as_deref(), + Some(auth_addr.to_string().as_str()) + ); +} + +#[tokio::test] +async fn test_unauthorized_401_denies_without_backend_contact() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth( + AuthScript::ok() + .with_status(401) + .with_headers(vec![("WWW-Authenticate", "Bearer realm=\"test\"")]), + auth_state.clone(), + ) + .await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .header("Authorization", "Bearer invalid") + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy should return the denial"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers().get(WWW_AUTHENTICATE).unwrap(), + "Bearer realm=\"test\"" + ); + assert_eq!( + backend_state.call_count(), + 0, + "backend must not be contacted" + ); + assert_eq!(auth_state.call_count(), 1); +} + +#[tokio::test] +async fn test_forbidden_403_denies_without_backend_contact() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth(AuthScript::ok().with_status(403), auth_state.clone()).await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy should return the denial"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + backend_state.call_count(), + 0, + "backend must not be contacted" + ); +} + +// --------------------------------------------------------------------------- +// Tests: infrastructure failures + failure modes +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_auth_500_closed_fails_closed() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth(AuthScript::ok().with_status(500), auth_state.clone()).await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy should return a failure response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + backend_state.call_count(), + 0, + "fail-closed: no backend contact" + ); +} + +#[tokio::test] +async fn test_auth_unreachable_closed_returns_503() { + // Bind then drop a listener to get a port that is (almost certainly) closed. + let dead_addr = get_random_port_addr().await; + + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let mut auth = auth_config(&format!("http://{dead_addr}/verify")); + auth.failure_mode = FailureMode::Closed; + // Short timeout: connection refused should be classified quickly. + auth.timeout_secs = 2; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy should return a failure response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + backend_state.call_count(), + 0, + "fail-closed: no backend contact" + ); +} + +#[tokio::test] +async fn test_auth_unreachable_open_continues_to_backend() { + let dead_addr = get_random_port_addr().await; + + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let mut auth = auth_config(&format!("http://{dead_addr}/verify")); + auth.failure_mode = FailureMode::Open; + auth.timeout_secs = 2; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy should continue despite auth outage"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + backend_state.call_count(), + 1, + "fail-open: request continues" + ); +} + +#[tokio::test] +async fn test_401_with_open_mode_still_denies() { + // Critical: failure_mode open must NEVER bypass an explicit 401 decision. + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth(AuthScript::ok().with_status(401), auth_state.clone()).await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let mut auth = auth_config(&format!("http://{auth_addr}/verify")); + auth.failure_mode = FailureMode::Open; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy should return the denial"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "failure_mode open must not bypass an explicit 401" + ); + assert_eq!( + backend_state.call_count(), + 0, + "backend must not be contacted" + ); +} + +#[tokio::test] +async fn test_auth_timeout_closed_returns_503() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth( + AuthScript::ok().with_delay(Duration::from_secs(3)), + auth_state.clone(), + ) + .await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let mut auth = auth_config(&format!("http://{auth_addr}/verify")); + auth.timeout_secs = 1; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth), + reverse_proxy(backend_addr), + ]) + .await; + + let started = std::time::Instant::now(); + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy should return a failure response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + started.elapsed() < Duration::from_secs(3), + "timeout must bound the wait" + ); + assert_eq!( + backend_state.call_count(), + 0, + "fail-closed: no backend contact" + ); +} + +/// Raw-TCP auth server that promises a 100-byte body but closes the connection +/// after a few bytes — the client sees a 2xx whose body stream breaks mid-flight. +async fn start_broken_body_auth() -> std::net::SocketAddr { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + loop { + let (mut stream, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => continue, + }; + tokio::spawn(async move { + let mut req_buf = [0u8; 4096]; + let _ = stream.read(&mut req_buf).await; + + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 100\r\n\r\npartial") + .await; + // Drop with 92 of 100 promised bytes missing. + }); + } + }); + + addr +} + +#[tokio::test] +async fn test_broken_auth_body_closed_returns_503() { + // A 200 response whose body stream breaks is not a reliable decision: + // fail-closed must treat it as an infrastructure failure. + let auth_addr = start_broken_body_auth().await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let mut auth = auth_config(&format!("http://{auth_addr}/verify")); + auth.failure_mode = FailureMode::Closed; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy should return a failure response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + backend_state.call_count(), + 0, + "fail-closed: no backend contact" + ); +} + +#[tokio::test] +async fn test_broken_auth_body_open_continues_to_backend() { + let auth_addr = start_broken_body_auth().await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let mut auth = auth_config(&format!("http://{auth_addr}/verify")); + auth.failure_mode = FailureMode::Open; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("fail-open should continue to the backend"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(backend_state.call_count(), 1); + + // Fail-open continues WITHOUT identity headers — a broken auth response + // must not leak partial identity data to the backend. + let payload = body_string(response).await; + assert_eq!(query_param(&payload, "x-user"), ""); +} + +// --------------------------------------------------------------------------- +// Tests: identity spoofing protection +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_identity_header_spoofing_is_rejected() { + // Client sends a spoofed X-User; auth returns the real one. + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth( + AuthScript::ok().with_headers(vec![("X-User", "real-user")]), + auth_state.clone(), + ) + .await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .header("X-User", "attacker") // spoofed identity + .header("X-Email", "attacker@example.com") // spoofed identity + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload = body_string(response).await; + assert_eq!(query_param(&payload, "x-user"), "real-user"); + // X-Email configured in response_headers but NOT returned by auth: + // the spoofed client value must NOT fall back through — it is removed. + assert_eq!(query_param(&payload, "x-email"), ""); +} + +#[tokio::test] +async fn test_spoofed_identity_headers_removed_when_auth_omits_them() { + // Auth returns 200 but none of the configured identity headers. + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth(AuthScript::ok(), auth_state.clone()).await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .header("x-user", "attacker") + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload = body_string(response).await; + assert_eq!( + query_param(&payload, "x-user"), + "", + "absent auth value must not fall back to untrusted client input" + ); +} + +// --------------------------------------------------------------------------- +// Tests: request body preservation + metadata +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_post_body_preserved_through_auth() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth( + AuthScript::ok().with_headers(vec![("X-User", "real-user")]), + auth_state.clone(), + ) + .await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let body = "{\"name\":\"test\",\"value\":42}"; + let response = client + .request( + Request::builder() + .method("POST") + .uri(format!("http://{proxy_host}/api/items")) + .header("Content-Type", "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + .await + .expect("proxy request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload = body_string(response).await; + + assert_eq!(query_param(&payload, "body"), body); + assert_eq!(query_param(&payload, "method"), "POST"); + + // The subrequest must not consume the client's body. + assert_eq!(auth_state.last_capture().method, "GET"); + assert_eq!( + auth_state.header_value("x-original-method").as_deref(), + Some("POST") + ); +} + +#[tokio::test] +async fn test_query_string_forwarded_in_original_uri() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth(AuthScript::ok(), auth_state.clone()).await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users?page=2&limit=10")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + auth_state.header_value("x-original-uri").as_deref(), + Some("/api/users?page=2&limit=10"), + "X-Original-URI must include the query string" + ); +} + +#[tokio::test] +async fn test_non_whitelisted_headers_not_forwarded_to_auth() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth(AuthScript::ok(), auth_state.clone()).await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .header("X-Internal-Secret", "backend-only-secret") + .header("User-Agent", "test-client") + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + auth_state.header_value("x-internal-secret").is_none(), + "non-whitelisted header must not reach the auth service" + ); + assert!(auth_state.header_value("user-agent").is_none()); +} + +#[tokio::test] +async fn test_request_id_propagated_to_auth() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth(AuthScript::ok(), auth_state.clone()).await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .header("X-Request-ID", "test-req-id-123") + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + auth_state.header_value("x-request-id").as_deref(), + Some("test-req-id-123") + ); +} + +// --------------------------------------------------------------------------- +// Tests: nested handle_path +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_forward_auth_inside_handle_path() { + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth( + AuthScript::ok().with_headers(vec![("X-User", "real-user")]), + auth_state.clone(), + ) + .await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let api_block = vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]; + // Public block: no auth. + let public_block = vec![reverse_proxy(backend_addr)]; + + let directives = vec![ + Directive::HandlePath { + pattern: "/api/*".to_string(), + directives: api_block, + }, + Directive::HandlePath { + pattern: "/public/*".to_string(), + directives: public_block, + }, + ]; + + let proxy_host = start_proxy(directives).await; + + let client = test_client(); + + // Protected path: auth runs, identity applied, path prefix stripped + // (handle_path semantics) and X-Original-URI keeps the original path. + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users?id=7")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let payload = body_string(response).await; + assert_eq!(query_param(&payload, "x-user"), "real-user"); + assert_eq!(auth_state.call_count(), 1); + assert_eq!( + auth_state.header_value("x-original-uri").as_deref(), + Some("/api/users?id=7"), + "X-Original-URI must be the client's original path even inside handle_path" + ); + + // Public path: no auth call. + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/public/stats")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + auth_state.call_count(), + 1, + "public path must not trigger auth" + ); +} + +// --------------------------------------------------------------------------- +// Tests: header casing +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_header_casing_case_insensitive() { + // Client sends mixed-case headers; config uses lowercase names. + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth( + AuthScript::ok().with_headers(vec![("x-UsEr", "real-user")]), + auth_state.clone(), + ) + .await; + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users")) + .header("AuThOrIzAtIoN", "Bearer mixed-case") + .header("X-UsEr", "attacker") + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .expect("proxy request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload = body_string(response).await; + // Whitelist copy is case-insensitive. + assert_eq!(query_param(&payload, "authorization"), "Bearer mixed-case"); + // Identity scrub/apply is case-insensitive: auth value wins. + assert_eq!(query_param(&payload, "x-user"), "real-user"); + + assert_eq!( + auth_state.header_value("authorization").as_deref(), + Some("Bearer mixed-case") + ); +} + +#[tokio::test] +async fn test_handle_path_strips_prefix_but_preserves_query() { + let backend_state = BackendState::default(); + let backend_addr = start_echo_backend(backend_state.clone()).await; + + let proxy_host = start_proxy(vec![Directive::HandlePath { + pattern: "/api/*".to_string(), + directives: vec![reverse_proxy(backend_addr)], + }]) + .await; + + let client = test_client(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("http://{proxy_host}/api/users?page=2&limit=10")) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let payload = body_string(response).await; + // Prefix stripped ... + assert_eq!(query_param(&payload, "uri"), "/users"); + // ... but the query string survives. + assert_eq!(query_param(&payload, "query"), "page=2&limit=10"); +} + +// --------------------------------------------------------------------------- +// Tests: streaming (SSE-style) responses pass through unbuffered +// --------------------------------------------------------------------------- + +/// Raw-TCP backend that streams a chunked response: headers + first chunk +/// immediately, then (after `delay`) the second chunk and terminator. +async fn start_streaming_backend(delay: Duration) -> std::net::SocketAddr { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + loop { + let (mut stream, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => continue, + }; + let delay = delay; + tokio::spawn(async move { + // Read the request head (small) so the backend is not blocked + // on the client's request buffer. + let mut req_buf = [0u8; 4096]; + let _ = stream.read(&mut req_buf).await; + + let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n"; + let _ = stream.write_all(head.as_bytes()).await; + // `event:a\n\n` is 9 bytes -> chunk size "9". + let _ = stream.write_all(b"9\r\nevent:a\n\n\r\n").await; + + tokio::time::sleep(delay).await; + + let _ = stream.write_all(b"9\r\nevent:b\n\n\r\n").await; + let _ = stream.write_all(b"0\r\n\r\n").await; + }); + } + }); + + addr +} + +#[tokio::test] +async fn test_streaming_response_not_buffered_by_forward_auth() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let auth_state = AuthState::new(); + let auth_addr = start_mock_auth(AuthScript::ok(), auth_state.clone()).await; + let backend_addr = start_streaming_backend(Duration::from_millis(1000)).await; + + let proxy_host = start_proxy(vec![ + Directive::ForwardAuth(auth_config(&format!("http://{auth_addr}/verify"))), + reverse_proxy(backend_addr), + ]) + .await; + + // Raw TCP client so chunk arrival timing is observable. + let mut stream = tokio::net::TcpStream::connect(&proxy_host).await.unwrap(); + stream + .write_all( + format!("GET /stream HTTP/1.1\r\nhost: {proxy_host}\r\nconnection: close\r\n\r\n") + .as_bytes(), + ) + .await + .unwrap(); + + // The backend sends `event:a` immediately and `event:b` after 1s. If the + // proxy buffered the response, nothing would arrive before ~1s; streaming + // must deliver `event:a` well within 700ms. + let mut received = Vec::new(); + let mut buf = [0u8; 1024]; + let first_chunk_deadline = tokio::time::Instant::now() + Duration::from_millis(700); + loop { + match tokio::time::timeout_at(first_chunk_deadline, stream.read(&mut buf)).await { + Ok(Ok(0)) | Err(_) | Ok(Err(_)) => break, + Ok(Ok(n)) => { + received.extend_from_slice(&buf[..n]); + if find(&received, b"event:a\n\n").is_some() { + break; + } + } + } + } + assert!( + find(&received, b"event:a\n\n").is_some(), + "first streamed chunk must arrive before the backend's second chunk (response is not buffered)" + ); + + // Read the rest of the streamed body. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + match tokio::time::timeout_at(deadline, stream.read(&mut buf)).await { + Ok(Ok(0)) | Err(_) | Ok(Err(_)) => break, + Ok(Ok(n)) => received.extend_from_slice(&buf[..n]), + } + } + assert!( + find(&received, b"event:b\n\n").is_some(), + "full streamed body received" + ); +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +}