Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@ jobs:
fail-fast: false
matrix:
include:
- elixir: '1.15'
otp: '25'
- elixir: '1.16'
otp: '26'
- elixir: '1.17'
otp: '26'
- elixir: '1.17'
otp: '27'
- elixir: '1.18'
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- **Require Mint `~> 1.11`** (was `~> 1.9`). Mint 1.11.0 is the lowest release clearing six 2026 advisories relevant to proxying untrusted upstreams: unbounded HTTP/1 status-line and chunk-extension buffering (CVE-2026-82728), quadratic chunk-size parsing (CVE-2026-82729), HTTP/1 response smuggling via unvalidated chunk-size lines (CVE-2026-82672) and via chunked framing when `chunked` isn't the final transfer coding (CVE-2026-94194), and HTTP/2 memory exhaustion via HPACK-indexed cookies (CVE-2026-91043) and oversized frames (CVE-2026-92103).

### Removed
- Dropped support for Elixir 1.15 and OTP 26, which are outside their security-patch windows, and Elixir 1.16, which cannot run on OTP 27. Philter now requires Elixir `~> 1.17` on OTP 27 or later.
- Dropped the optional `:phoenix` dependency. No code referenced it — Philter builds on Plug, and Phoenix routers and controllers accept plain Plugs, so Phoenix applications can use `Philter.ProxyPlug` unchanged.

## [0.4.0] - 2026-07-14
Expand Down
60 changes: 31 additions & 29 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Philter is a streaming HTTP proxy library for Elixir with O(1) memory body observation. It forwards HTTP requests to upstream servers while capturing body observations (SHA256 hash, size, timing, preview) without buffering the full body in memory.
Philter is a streaming HTTP proxy library for Elixir with O(1) memory body observation. It forwards HTTP requests to upstream servers while capturing body observations (SHA256 hash, size, preview and, for matching content types, the full body) without buffering the full body in memory.

Core deps: `mint ~> 1.9`, `plug ~> 1.14`. Optional: `jason ~> 1.0`. Test: `bypass ~> 2.1`.
Core deps: `mint ~> 1.11`, `plug ~> 1.14`. Optional: `jason ~> 1.0`. Test: `bypass ~> 2.1`, `x509 ~> 0.8` (self-signed certs for the TLS tests). Requires Elixir `~> 1.17` on OTP 27 or later.

Philter depends on Plug, not Phoenix. Phoenix routers and controllers accept plain Plugs, so Phoenix apps can use `Philter.ProxyPlug` without Philter knowing Phoenix exists.

Expand All @@ -24,53 +24,55 @@ mix lint.fix # Auto-format (alias for mix format)
mix ci # Full CI pipeline: deps.get + compile --warnings-as-errors + lint + test
```

CI runs tests across Elixir 1.15–1.18 with OTP 25–27. Compile uses `--warnings-as-errors`.
CI runs tests across Elixir 1.17–1.20 with OTP 27–29, with `compile --warnings-as-errors`. Format, credo and `mix deps.unlock --check-unused` run as a separate job, and dialyzer as another, both on the newest Elixir/OTP pair.

## Architecture

**`Philter`** (`lib/philter.ex`) — Main entry point. `proxy/2` takes a `Plug.Conn` and options, resolves and validates the upstream against the egress policy (`Philter.Egress`), then streams the request to the validated address via `Philter.Transport` and streams the response back. Returns the conn with observations in `conn.private[:philter_request_observation]` and `conn.private[:philter_response_observation]`. Handles errors as 502/504 responses.
**`Philter`** (`lib/philter.ex`): main entry point. `proxy/2` takes a `Plug.Conn` and options, resolves and validates the upstream against the egress policy (`Philter.Egress`), then streams the request to the validated address via `Philter.Transport` and streams the response back. On success it returns the conn with observations in `conn.private[:philter_request_observation]` and `conn.private[:philter_response_observation]`. Failures become 403/502/504 responses. Header building (hop-by-hop filtering, host rewrite, `:strip_headers`, `:extra_headers`) lives here too.

**`Philter.ProxyPlug`** — Plug for router-level forwarding. Delegates to `Philter.proxy/2`.
**`Philter.ProxyPlug`**: Plug for router-level forwarding. `init/1` checks for `:upstream` and the header option clash, then `call/2` delegates to `Philter.proxy/2`.

**`Philter.Egress`** — Deny-by-default SSRF egress gate. Resolves the upstream hostname and validates every resolved IP against a blocked-range set (RFC1918, loopback, link-local/cloud-metadata, CGNAT, reserved, plus IPv6 unique-local and link-local; IPv4-mapped and NAT64 forms are unwrapped and re-checked). Returns the validated addresses in resolution order for the transport to connect to (resolve-and-pin), or `{:error, reason}`. Transport-agnostic; policy comes from `:block_private_networks`, `:allowed_hosts` and `:dns_timeout`.
**`Philter.Egress`**: deny-by-default SSRF egress gate. Resolves the upstream hostname (IPv4 and IPv6 in parallel tasks under one shared `:dns_timeout`) and validates every resolved IP against a blocked-range set (RFC1918, loopback, link-local/cloud-metadata, CGNAT, reserved, plus IPv6 unique-local and link-local). IPv6 forms that embed an IPv4 address (IPv4-mapped, IPv4-compatible, NAT64, 6to4, Teredo) are unwrapped and re-checked. Returns the validated addresses in resolution order for the transport to connect to, or `{:error, reason}`. Has no dependency on the transport or `Philter.Config`; policy comes in as options. A `:resolver` option replaces `:inet.getaddrs/2` so tests can feed it synthetic addresses.

**`Philter.Transport`** — Mint-based HTTP/1 streaming transport. Connects directly to a caller-validated IP tuple without re-resolving the hostname (resolve-and-pin), while driving the Host header, TLS SNI and certificate hostname verification against the original hostname. Exposes a `stream_while/4` entry point that folds upstream events through the same reducer `Philter.proxy/2` uses. Interleaves reads between request-body chunk sends so an early upstream response cannot deadlock a large upload.
**`Philter.Transport`** (internal): Mint-based HTTP/1 streaming transport. Connects directly to a caller-validated IP tuple without re-resolving the hostname, while driving the Host header, TLS SNI and certificate hostname verification against the original hostname. Tries the validated addresses in order, with `:connect_timeout` shared as one budget across all attempts. TLS is always `verify: :verify_peer`; any `:verify`/`:verify_fun` in `:transport_opts` is dropped. Exposes `stream_while/4`, which folds upstream events through the reducer `Philter.proxy/2` passes in. Runs the socket in active mode with a `receive` pinned to its own socket, and drains pending messages between request-body chunks so an early upstream response cannot deadlock a large upload. Opens a fresh connection per request; there is no pool.

**`Philter.Handler`** — Behaviour for lifecycle callbacks. State threads through: `handle_request_started/2` → `handle_response_started/2` → `handle_response_finished/2`. Can reject requests before the upstream call. `handle_response_finished/2` is always called, even on error.
**`Philter.Handler`**: behaviour for lifecycle callbacks. State threads through `handle_request_started/2` → `handle_response_started/2` → `handle_response_finished/2`. Only `handle_response_finished/2` is required. `handle_request_started/2` can reject before the upstream call, in which case `handle_response_finished/2` is not called. Once past that point, `handle_response_finished/2` is always called, including on egress rejections and transport errors.

**`Philter.Observer`** — Single linked process spawned per request (replaced a previous 3-Agent design). Receives `:req_chunk`/`:resp_chunk`/`:resp_started`/`:finalize` messages. Fire-and-forget for chunks, synchronous for finalize (5s timeout).
**`Philter.Observer`** (internal): one linked process spawned per request. Receives `:req_chunk`/`:resp_chunk`/`:resp_started`/`:finalize` messages. Chunks are fire-and-forget; finalize is synchronous with a 5s timeout. Whether to keep the full request body is decided at spawn from the request content type; for the response it is decided on `:resp_started`, which resets the response observation using the upstream content type.

**`Philter.Observation`** — Incremental body observation state machine. Streams SHA256 via `:crypto.hash_init/:hash_update/:hash_final`, captures first 64KB preview (UTF-8 safe), tracks size, conditionally accumulates full body based on content-type match + size limit.
**`Philter.Observation`** (internal): incremental body observation. Streams SHA256 via `:crypto.hash_init/:hash_update/:hash_final` (lowercase hex), captures the first 64KB as a preview (UTF-8 safe), tracks size, and keeps the full body only when told to and only while it stays under `max_payload_size`.

**`Philter.Config`** — Resolves configuration by merging app env (`:philter`) with per-request overrides. Supports wildcard content-type patterns (e.g., `text/*`).
**`Philter.Config`**: resolves configuration as per-request option, then `:philter` app env, then built-in default. Also holds the content-type matcher, which ignores parameters like `charset` and supports wildcards such as `text/*`. `:finch_name` is still resolved but deprecated and ignored.

**`Philter.BodyStream`** — Adapts `Plug.Conn` body reading into a `{:stream, enumerable}` for the transport. Reads 64KB chunks.
**`Philter.Timing`**: builds the per-phase timing map (`connect_us`, `send_us`, `recv_us`) the transport returns when `collect_timing: true`. `queue_us` and `idle_time_us` are always `nil` and `reused_connection?` always `false`, since there is no pool.

**`Philter.UTF8`** — UTF-8 safe binary truncation for preview data.
**`Philter.BodyStream`** (internal): adapts `Plug.Conn.read_body/2` into a `{:stream, enumerable}` for the transport, reading 64,000-byte chunks and calling an `:on_chunk` callback for the observer.

**`Philter.UTF8`**: UTF-8 safe binary truncation for preview data.

### Request Flow

1. Resolve config (app env + per-request overrides) and handler
2. `handle_request_started/2` — can reject with `{:reject, status, body, state}`
3. Resolve and validate the upstream host via `Philter.Egress` — reject if any resolved IP falls in a blocked range (403), DNS times out (504), or nothing resolves (502)
4. Spawn linked Observer process
5. Build the transport request pinned to the validated addresses, stream request body via BodyStream (observer gets chunks)
6. `Philter.Transport.stream_while/4` — `:status` sets code, `:headers` filters hop-by-hop + starts chunked response, `:data` forwards chunks to client + observer
7. Finalize observer, call `handle_response_finished/2`, store observations in conn.private
1. Validate header options (`:headers` cannot be combined with `:extra_headers`/`:strip_headers`; raises `ArgumentError`), resolve config and handler, build the upstream URL and outbound headers
2. `handle_request_started/2`: can reject with `{:reject, status, body, state}`
3. Refuse a missing host or a non-`http(s)` scheme (502), then resolve and validate the upstream host via `Philter.Egress`: a blocked IP returns 403, a DNS timeout 504, nothing resolving 502. These rejections still call `handle_response_finished/2`, with empty observations
4. Spawn the linked Observer process
5. Build the transport request pinned to the validated addresses. The request body is streamed via BodyStream (observer gets the chunks) unless `content-length` is `0`, or there is no `content-length` and the request isn't chunked
6. `Philter.Transport.stream_while/4`: `:status` sets the code, `:headers` filters hop-by-hop headers, calls `handle_response_started/2` and starts a chunked response, `:data` forwards chunks to the client and the observer
7. Finalize the observer, call `handle_response_finished/2`, and on success store the observations in `conn.private`

### Key Design Decisions

- **Egress filtering** is deny-by-default: the upstream host is resolved once and every resolved address validated against internal ranges before connecting, then the transport pins to a validated IP and never re-resolves (closing the DNS-rebinding window) while preserving the hostname for the Host header, TLS SNI and certificate verification. `:allowed_hosts` is the escape hatch (still resolved, block check skipped); blocked resolutions return 403 and the resolved IP is logged server-side only, never to the client.
- **Hop-by-hop headers** (te, transfer-encoding, connection, etc.) are filtered from both request and response. Content-length is also removed from responses (chunked encoding used).
- **Custom `:headers` option** bypasses all request header filtering — headers are sent as-is.
- **Body accumulation** is conditional: only for matching content-types under `max_payload_size`. Preview and hash are always captured regardless.
- **Timeout errors** (`:timeout`, `:connect_timeout`, `{:closed, :timeout}`) return 504; all other errors return 502.
- **Egress filtering** is deny-by-default: the upstream host is resolved once and every resolved address validated against internal ranges before connecting, then the transport pins to a validated IP and never re-resolves (closing the DNS-rebinding window) while preserving the hostname for the Host header, TLS SNI and certificate verification. Connection identity (scheme, host, port) is taken from the parsed base `:upstream`, the same value that was validated; only the request path comes from the path-appended URL. `:allowed_hosts` is the escape hatch (still resolved, block check skipped). Blocked resolutions return 403 with a static body and the resolved IP is logged server-side only, never sent to the client.
- **Hop-by-hop headers** (te, transfer-encoding, connection, etc.) are filtered from both request and response. Content-length is also removed from responses (chunked encoding is used).
- **Custom `:headers` option** bypasses all request header filtering: headers are sent as-is, and a `host` entry is only added if the caller didn't supply one. Without `:headers`, `host` is always rewritten to the upstream.
- **Body accumulation** is conditional: only for matching content types under `max_payload_size`. Preview and hash are always captured regardless.
- **Error mapping**: a `Mint.TransportError` with reason `:timeout`, `:connect_timeout` or `{:closed, :timeout}` returns 504 and reaches the handler as `error: {:timeout, reason}`; every other transport error returns 502. Observations are only put in `conn.private` on success.

## Testing

Tests use `ExUnit` with `async: true` and `Bypass` for mocking upstream HTTP servers. Test support code is in `test/support/` (compiled via `elixirc_paths` in test env):
Tests use `ExUnit` with `async: true` throughout and `Bypass` for mocking upstream HTTP servers. Test support code is in `test/support/` (compiled via `elixirc_paths` in test env):

- `Philter.ConnCase` — CaseTemplate for Plug testing (no Phoenix dependency)
- `Philter.TestHelpers` — `bypass_upstream/0`, `test_handler/0`, `json_response/3`
- `Philter.TestHelpers`: `bypass_upstream/0`, `test_handler/0`, `json_response/3`, `text_response/3`
- `Philter.LogCapture`: log capture filtered to the calling process. `ExUnit.CaptureLog` sees every concurrently running test's logs, so it cannot prove nothing was logged under `async: true`; use `capture_own_log/1` for that. This works because Philter logs only from the process calling `proxy/2`.

`test/test_helper.exs` sets suite-wide app env, including `allowed_hosts` (`127.0.0.1`, `localhost`) so loopback Bypass servers pass the egress guard while it stays enabled for the rest of the suite.
`test/test_helper.exs` sets `allowed_hosts` (`127.0.0.1`, `localhost`) in app env so loopback Bypass servers pass the egress guard while it stays enabled for the rest of the suite. To test that an address is blocked, use a host that isn't on that list and pass a fake `:resolver` (see `test/philter/egress_integration_test.exs`), which also uses `x509` for the TLS/SNI tests.
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ development, add it to `:allowed_hosts` (see
## How a request flows

1. Configuration is resolved (per-request options over application config over defaults) and your handler's `handle_request_started/2` runs, which may reject the request outright.
2. The upstream hostname is resolved and every resolved address is validated against the egress policy. A blocked address returns `403`, a DNS timeout `504`, and an unresolvable host `502`.
2. The upstream URL must be `http` or `https` with a host, otherwise the request gets `502`. The hostname is then resolved and every resolved address is validated against the egress policy. A blocked address returns `403`, a DNS timeout `504`, and an unresolvable host `502`.
3. The request body is streamed upstream in chunks and the response is streamed back to the client, with each chunk passed through an observer that incrementally hashes, sizes and previews it.
4. On completion `handle_response_finished/2` is called (always, even on error) and the observations are stored in `conn.private`.
4. On completion `handle_response_finished/2` is called. It runs on errors too, but not when `handle_request_started/2` rejected the request. On success the observations are also stored in `conn.private`.

Upstream connection failures surface as `502 Bad Gateway` and upstream timeouts
as `504 Gateway Timeout`.
Expand All @@ -79,13 +79,15 @@ req_obs = conn.private[:philter_request_observation]
resp_obs = conn.private[:philter_response_observation]

# Each observation contains:
# - :hash - SHA256 hash of the body
# - :hash - SHA256 of the body as lowercase hex
# - :size - Total body size in bytes
# - :preview - First 64KB of the body (UTF-8 safe truncation)
# - :body - Full body (only if under max_payload_size and content-type matches)
```

The hash, size and preview are always captured. The full `:body` is only
The observations are only stored on a successful proxy; on an error, read them
from your handler's `handle_response_finished/2` instead. The hash, size and
preview are always captured. The full `:body` is only
accumulated when the content type matches `:persistable_content_types` and the
body stays under `:max_payload_size`.

Expand All @@ -96,6 +98,7 @@ Implement `Philter.Handler` to hook into the proxy lifecycle:
```elixir
defmodule MyApp.ProxyHandler do
use Philter.Handler
require Logger

@impl true
def handle_request_started(metadata, state) do
Expand Down Expand Up @@ -124,9 +127,18 @@ Philter.proxy(conn,
)
```

Only `handle_response_finished/2` is required; the other two default to
`{:ok, state}`. `:handler` takes either `{module, initial_state}` or a bare
module, which starts with `[]` as its state.

`handle_request_started/2` can reject a request before it reaches upstream by
returning `{:reject, status, body, state}`. `handle_response_finished/2` is
always called, even on error; check its `:error` field.
returning `{:reject, status, body, state}`, in which case no further callbacks
run. Otherwise `handle_response_finished/2` is always called, even on error;
check its `:error` field (`nil` on success) and expect `:status` to be `nil`
when no upstream response arrived.

`result.timing.total_us` is always set. Pass `collect_timing: true` to also
fill in `connect_us`, `send_us` and `recv_us`; otherwise those are `nil`.

## Configuration

Expand Down Expand Up @@ -207,8 +219,8 @@ against this **by default**.
### Reaching an internal host on purpose

If you genuinely need to proxy to an internal upstream, add its hostname to
`allowed_hosts`. Listed hosts bypass the egress check entirely (matched
case-insensitively, ignoring a trailing dot):
`allowed_hosts`. Listed hosts skip the block check (matched case-insensitively,
ignoring a trailing dot), though the name is still resolved as normal:

```elixir
Philter.proxy(conn,
Expand Down
Loading
Loading