From 2279720a89d30a72052c5b2623ee2ecc29e479eb Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Sun, 4 Oct 2026 07:54:11 +0200 Subject: [PATCH 1/8] Update dependencies Picks up Mint 1.11.0, which fixes six published advisories (CVE-2026-82728, CVE-2026-82729, CVE-2026-82672, CVE-2026-91043, CVE-2026-92103, CVE-2026-94194), plus cowboy/cowlib, hpax, plug_crypto and dev tooling. --- mix.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mix.lock b/mix.lock index 11abbec..7f11542 100644 --- a/mix.lock +++ b/mix.lock @@ -1,26 +1,26 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "cowboy": {:hex, :cowboy, "2.17.0", "059d6ae769214cedf55d115ce5bf38649ff6b32ec693067cf6b185d0ab1b53c3", [:make, :rebar3], [{:cowlib, ">= 2.18.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "84f0e4c9d5820342cd506472052b79336d0d9d3624e46303f6d0af9dc94e8d5f"}, + "cowboy": {:hex, :cowboy, "2.19.0", "78b9d92d25a23e56d7341040b67946c04067cd77de966e2372865f673a8e6f59", [:make, :rebar3], [{:cowlib, ">= 2.20.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "986dae81f99fcb78ef2d8efc21d738cee189410840eeaf32eca84ada81dcf6d4"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.18.0", "4a3ef8cb012c21c7cbb7c925f75405e26d7fcd393fd78c46187539dbc9a48310", [:make, :rebar3], [], "hexpm", "c4f89d33a61162d4cfdcb5a1af7e562d80a700b2573cc71020ce6521b152dc1a"}, + "cowlib": {:hex, :cowlib, "2.20.0", "bb525377ba634cd6d68bac7bff5d98571f738806139d335daca827717c5dc172", [:make, :rebar3], [], "hexpm", "7d41a0dd2c093041ff3779ac5fe8a1585a68ec7cb2dd1de0536bdd2452fd7ba1"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, - "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"}, + "dialyxir": {:hex, :dialyxir, "1.4.8", "7ef671a8aff9948b091d8c30f09467fbb16e77305cda451bce48109a0f5e021c", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cbd5a851571e5dfeb32aaf2e840bfa98b7864cb3071bf2ef5d95d1276b12e072"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, - "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, + "makeup": {:hex, :makeup, "1.2.3", "c3ef0ff83acf305384acba0f70bff87d8732502baa1eb3eb081d8c154fbe18f5", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "15970751b7021c2bcd7ed56a54b3def23affd3969fb7c214dffdf28e97ce6f76"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, + "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_cowboy": {:hex, :plug_cowboy, "2.9.0", "87e21e0d9054ced99c36d128f49e3ea2cd8b745fffb97de50bff99706087af4f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2002bafba4f3a45b55a58e68d70211b153a7ed18d37edb1ceb6e96e7a92c422e"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "x509": {:hex, :x509, "0.9.2", "a75aa605348abd905990f3d2dc1b155fcde4e030fa2f90c4a91534405dce0f6e", [:mix], [], "hexpm", "4c5ede75697e565d4b0f5be04c3b71bb1fd3a090ea243af4bd7dae144e48cfc7"}, From c8d49913b43e800491e5154190cf555d3121d9eb Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Sun, 4 Oct 2026 07:54:26 +0200 Subject: [PATCH 2/8] Require Mint 1.11 or later Earlier versions carry HTTP/1 response-smuggling and memory/CPU exhaustion advisories that apply directly to a proxy talking to untrusted upstreams. The lock only protects this repo; the floor protects downstream apps. --- CHANGELOG.md | 3 +++ mix.exs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee15325..2d03873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ 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 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. diff --git a/mix.exs b/mix.exs index 232c6a3..8a3306e 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,7 @@ defmodule Philter.MixProject do defp deps do [ # Required - core functionality - {:mint, "~> 1.9"}, + {:mint, "~> 1.11"}, {:plug, "~> 1.14"}, # Optional - enhanced features From 621a3e027271aea8d022384ef59d7866aa740020 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Sun, 4 Oct 2026 07:54:51 +0200 Subject: [PATCH 3/8] Drop Elixir 1.15 support 1.15 is outside Elixir's security-patch window. CI now covers 1.16-1.20 on OTP 26-29. --- .github/workflows/ci.yml | 2 -- CHANGELOG.md | 1 + CLAUDE.md | 2 +- mix.exs | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fea7c44..3dbfb6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,6 @@ jobs: fail-fast: false matrix: include: - - elixir: '1.15' - otp: '25' - elixir: '1.16' otp: '26' - elixir: '1.17' diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d03873..0724f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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, which no longer receives security patches. Philter now requires Elixir `~> 1.16`. - 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 diff --git a/CLAUDE.md b/CLAUDE.md index e64587a..7cfb126 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ 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.16–1.20 with OTP 26–29. Compile uses `--warnings-as-errors`. ## Architecture diff --git a/mix.exs b/mix.exs index 8a3306e..f44c2e1 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Philter.MixProject do [ app: :philter, version: @version, - elixir: "~> 1.15", + elixir: "~> 1.16", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), From 7393fe2a6169c132381666084723bd61647ffb2a Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Sun, 4 Oct 2026 08:09:11 +0200 Subject: [PATCH 4/8] Refresh CLAUDE.md and README against current code --- CLAUDE.md | 61 +++++++++++++++++++++++++++++-------------------------- README.md | 28 +++++++++++++++++-------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7cfb126..355afb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.16`. 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. @@ -24,53 +24,56 @@ 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.16–1.20 with OTP 26–29. Compile uses `--warnings-as-errors`. +CI runs tests across Elixir 1.16–1.20 with OTP 26–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`. +- `Philter.ConnCase`: Plug CaseTemplate (no Phoenix dependency); currently unused by the suite -`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. diff --git a/README.md b/README.md index a36b361..57dff30 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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`. @@ -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 @@ -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 @@ -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, From ed33920e8af11407da6951ee8936deaa18fd1806 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Sun, 4 Oct 2026 08:10:07 +0200 Subject: [PATCH 5/8] Fix stale docs left over from the Finch transport ProxyPlug now points at proxy/2 for its options instead of keeping a partial copy, and its observations example (which could never run after forward) is replaced with the handler route. Handler, Config and proxy/2 docs now match current behaviour for reused_connection?, allowed_hosts, rejected requests and error paths. Removes a comment about a deleted Dialyzer ignore file and the unused Philter.ConnCase test helper. --- CLAUDE.md | 1 - lib/philter.ex | 7 ++++--- lib/philter/config.ex | 5 ++--- lib/philter/handler.ex | 10 ++++++---- lib/philter/proxy_plug.ex | 33 +++++++-------------------------- lib/philter/transport.ex | 5 ----- test/support/conn_case.ex | 31 ------------------------------- 7 files changed, 19 insertions(+), 73 deletions(-) delete mode 100644 test/support/conn_case.ex diff --git a/CLAUDE.md b/CLAUDE.md index 355afb2..49b9926 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,5 @@ Tests use `ExUnit` with `async: true` throughout and `Bypass` for mocking upstre - `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`. -- `Philter.ConnCase`: Plug CaseTemplate (no Phoenix dependency); currently unused by 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. diff --git a/lib/philter.ex b/lib/philter.ex index 8e78938..6a6f27c 100644 --- a/lib/philter.ex +++ b/lib/philter.ex @@ -176,7 +176,7 @@ defmodule Philter do hostname resolves to a private, loopback, link-local or otherwise internal address (SSRF egress guard). See `Philter.Egress`. - * `:allowed_hosts` - Hosts that bypass the egress block check entirely (the + * `:allowed_hosts` - Hosts that are still resolved but skip the egress block check (the escape hatch, e.g. a deliberately internal upstream). Exact match after downcase and trailing-dot strip. Default: `[]`. @@ -192,7 +192,7 @@ defmodule Philter do ## Return Value - Returns the `conn` with response sent. Observations are stored in: + Returns the `conn` with response sent. On success, observations are stored in: * `conn.private[:philter_request_observation]` - Request body observation * `conn.private[:philter_response_observation]` - Response body observation @@ -202,7 +202,8 @@ defmodule Philter do ## Error Handling On upstream errors, returns `502 Bad Gateway`. On timeouts, returns `504 Gateway Timeout`. - The handler's `handle_response_finished/2` is still called with the `:error` field set. + The handler's `handle_response_finished/2` is still called with the `:error` field set, + and observations are not stored in `conn.private`. """ @spec proxy(Plug.Conn.t(), proxy_opts()) :: Plug.Conn.t() def proxy(conn, opts) do diff --git a/lib/philter/config.ex b/lib/philter/config.ex index 4117843..0ed6dac 100644 --- a/lib/philter/config.ex +++ b/lib/philter/config.ex @@ -24,15 +24,14 @@ defmodule Philter.Config do ## Options - `:finch_name` - **Deprecated and ignored.** The transport uses no connection - pool. Still accepted so existing configuration does not crash (default: - `Philter.Finch`) + pool. Still accepted so existing configuration does not crash - `:receive_timeout` - Timeout in ms for receiving response (default: 15_000) - `:max_payload_size` - Max size in bytes for full body accumulation (default: 1_048_576 / 1MB) - `:persistable_content_types` - Content types eligible for full body storage (default: see below) - `:log_level` - Logger level for lifecycle events, or `false` to disable (default: `:debug`) - `:block_private_networks` - Reject upstreams that resolve to private, loopback, link-local or otherwise internal ranges (SSRF egress guard, default: `true`) - - `:allowed_hosts` - Hosts that bypass the egress block check entirely. Exact + - `:allowed_hosts` - Hosts that are still resolved but skip the egress block check. Exact match after downcase + trailing-dot strip (default: `[]`) - `:dns_timeout` - Milliseconds to bound upstream DNS resolution (default: 5_000) - `:connect_timeout` - Milliseconds to bound the connection phase to a validated diff --git a/lib/philter/handler.ex b/lib/philter/handler.ex index f09418d..ce4133e 100644 --- a/lib/philter/handler.ex +++ b/lib/philter/handler.ex @@ -77,8 +77,9 @@ defmodule Philter.Handler do Per-phase timing breakdown for a proxy request. When `collect_timing: true` is set, phase fields are measured directly around - the Mint transport calls. When timing capture is off, phase fields are `nil` - and `reused_connection?` is `nil`. + the Mint transport calls and `reused_connection?` is `false` (there is no + connection pool). When timing capture is off, phase fields and + `reused_connection?` are `nil`. """ @type timing :: %{ required(:total_us) => non_neg_integer(), @@ -96,7 +97,7 @@ defmodule Philter.Handler do Contains observations for both request and response bodies, plus any error that occurred during proxying. The `:status` field is `nil` when the error occurred before receiving a response from upstream (e.g., connection refused, - pool checkout timeout). + connect timeout). """ @type finished_result :: %{ required(:request_observation) => body_observation(), @@ -126,7 +127,8 @@ defmodule Philter.Handler do @doc """ Called when the response is complete (or an error occurred). - Always called, even on error. Check `:error` field for failures. + Called on success and on error (check the `:error` field), but not when + `c:handle_request_started/2` rejects the request. """ @callback handle_response_finished(finished_result(), state :: term()) :: {:ok, term()} diff --git a/lib/philter/proxy_plug.ex b/lib/philter/proxy_plug.ex index 930058f..f222e40 100644 --- a/lib/philter/proxy_plug.ex +++ b/lib/philter/proxy_plug.ex @@ -26,35 +26,16 @@ defmodule Philter.ProxyPlug do ## Options - * `:upstream` - Base URL of upstream server (required) - * `:handler` - Handler module or `{module, state}` tuple (optional) - * `:receive_timeout` - Response timeout in ms (default: `15_000`) - * `:max_payload_size` - Max body size for accumulation (default: `1_048_576`) - * `:persistable_content_types` - Content types to accumulate (default: JSON, XML, text) - * `:extra_headers` - Additional `[{name, value}]` headers to send upstream. Replaces any - existing header with the same name. Cannot be combined with `:headers`. - * `:strip_headers` - List of header names to remove from the outbound request. - Cannot be combined with `:headers`. - * `:block_private_networks` - Reject upstreams resolving to private/internal - addresses, an SSRF egress guard (default: `true`). See `Philter.Egress`. - * `:allowed_hosts` - Hosts that bypass the egress block check (default: `[]`). - * `:dns_timeout` - Milliseconds to bound upstream DNS resolution (default: `5_000`). - * `:finch_name` - **Deprecated and ignored.** The transport uses no connection pool. - - See `Philter.Config` for global defaults and application configuration. + Takes the same options as `Philter.proxy/2`; `:upstream` is required. See + `Philter.Config` for global defaults and application configuration. ## Accessing Observations - After proxying, observations are available in `conn.private`: - - plug :fetch_observations - - defp fetch_observations(conn, _opts) do - req_obs = conn.private[:philter_request_observation] - resp_obs = conn.private[:philter_response_observation] - # req_obs and resp_obs contain: hash, size, preview, timing - conn - end + `forward` hands the request to this plug and nothing in the router runs + afterwards, so read observations from a handler's + `c:Philter.Handler.handle_response_finished/2` callback, which receives the + request and response observations, status, error and timing. See + `Philter.Handler`. ## Comparison with Philter.proxy/2 diff --git a/lib/philter/transport.ex b/lib/philter/transport.ex index 2fdbedd..139aa26 100644 --- a/lib/philter/transport.ex +++ b/lib/philter/transport.ex @@ -18,11 +18,6 @@ defmodule Philter.Transport do # sending the moment the response has started. (A zero-timeout `recv/3` in # passive mode is unusable here — Mint treats its timeout as a fatal transport # error and closes the socket.) - # - # Mint 1.8+ types its connection as an open union of opaque HTTP1/HTTP2 - # structs, so Dialyzer reads every hand-off back to Mint's API here as an - # opaque-term violation and cascades no-return warnings through the module. - # Those spurious warnings are filtered in .dialyzer_ignore.exs. @type request :: %{ scheme: :http | :https, diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex deleted file mode 100644 index 15cfbe4..0000000 --- a/test/support/conn_case.ex +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Philter.ConnCase do - @moduledoc """ - Test case template for Plug-based testing. - - This module provides helpers for testing Plug-based applications - without requiring a Phoenix application to be running. - - ## Usage - - defmodule MyTest do - use Philter.ConnCase - - test "my test" do - conn = conn(:get, "/path") - # ... - end - end - """ - use ExUnit.CaseTemplate - - using do - quote do - use Plug.Test - import Philter.ConnCase - end - end - - setup _tags do - {:ok, conn: Plug.Test.conn(:get, "/")} - end -end From 3560599c9d4b6003a3030185a00b915abf298ed3 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Sun, 4 Oct 2026 08:17:59 +0200 Subject: [PATCH 6/8] Keep one log capture handler installed for the whole test run Adding and removing a :logger handler per capture raced with Logger.flush/0 on Elixir 1.17, which lists handlers then reads each one's config; a handler removed by another async test in between crashed it with {:not_found, id}. The handler now stays installed and routes on the logging process's dictionary, which works because :logger runs handlers in the caller. --- test/support/log_capture.ex | 45 ++++++++++++++++++++----------------- test/test_helper.exs | 1 + 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/test/support/log_capture.ex b/test/support/log_capture.ex index caa0d1f..a7bf2df 100644 --- a/test/support/log_capture.ex +++ b/test/support/log_capture.ex @@ -10,34 +10,41 @@ defmodule Philter.LogCapture do `=~` as the only safe assertion under `async: true`. That makes "nothing was logged" unassertable with the built-in capture. This - attaches its own handler filtered to the emitting pid, so the capture holds - only what the calling process logged. Philter logs entirely from the process - that calls `Philter.proxy/2`, so filtering on `self()` catches all of it. + module keeps one `:logger` handler installed for the whole run. `:logger` + calls handlers in the process that logged, so the handler checks that + process's dictionary and only forwards lines from processes that are + capturing. Philter logs entirely from the process that calls + `Philter.proxy/2`, so this catches all of it. + + Adding and removing a handler per capture would race with `Logger.flush/0` + on older Elixir versions, which lists the handlers and then reads each one's + config, so a handler removed by another test in between makes it crash. """ + @handler_id :philter_own_log + @capturing {__MODULE__, :capturing} + + @doc """ + Installs the shared handler. Call once from `test_helper.exs`. + """ + @spec install() :: :ok + def install do + :ok = :logger.add_handler(@handler_id, __MODULE__, %{level: :all}) + end + @doc """ Runs `fun`, returning `{result, log}` where `log` holds only the lines the calling process emitted. """ @spec with_own_log((-> result)) :: {result, String.t()} when result: var def with_own_log(fun) when is_function(fun, 0) do - owner = self() - id = :"philter_own_log_#{System.unique_integer([:positive])}" - - :ok = - :logger.add_handler(id, __MODULE__, %{ - level: :all, - config: %{owner: owner}, - filters: [own_pid: {&__MODULE__.__filter_pid__/2, owner}], - filter_default: :stop - }) + Process.put(@capturing, true) try do result = fun.() - :ok = Logger.flush() {result, drain([])} after - :logger.remove_handler(id) + Process.delete(@capturing) # If fun raised, its lines are still queued; a later capture in this same # test would otherwise drain them as its own. drain([]) @@ -54,12 +61,8 @@ defmodule Philter.LogCapture do end @doc false - def __filter_pid__(%{meta: %{pid: pid}} = event, pid), do: event - def __filter_pid__(_event, _owner), do: :stop - - @doc false - def log(%{level: level, msg: msg}, %{config: %{owner: owner}}) do - send(owner, {__MODULE__, "[#{level}] #{format(msg)}"}) + def log(%{level: level, msg: msg}, _config) do + if Process.get(@capturing), do: send(self(), {__MODULE__, "[#{level}] #{format(msg)}"}) end defp format({:string, chardata}), do: IO.iodata_to_binary(chardata) diff --git a/test/test_helper.exs b/test/test_helper.exs index 92eec6d..015758d 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,5 @@ ExUnit.start() +Philter.LogCapture.install() # The SSRF egress guard is on by default and blocks loopback. Bypass binds to # 127.0.0.1 (reached via the "localhost" upstream URL), so allow-list both here From c893fc3ca2e03d2f8d8c46b0396724aeec855d7b Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Sun, 4 Oct 2026 08:22:29 +0200 Subject: [PATCH 7/8] Drop Elixir 1.16 and OTP 26 support cowlib 2.20, pulled in by bypass for tests, uses maybe expressions that need OTP 27. OTP 26 is outside OTP's own support window, and Elixir 1.16 can't run on OTP 27, so the minimum becomes Elixir 1.17 on OTP 27. --- .github/workflows/ci.yml | 4 ---- CHANGELOG.md | 2 +- CLAUDE.md | 4 ++-- mix.exs | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dbfb6f..4dfee3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,6 @@ jobs: fail-fast: false matrix: include: - - elixir: '1.16' - otp: '26' - - elixir: '1.17' - otp: '26' - elixir: '1.17' otp: '27' - elixir: '1.18' diff --git a/CHANGELOG.md b/CHANGELOG.md index 0724f07..33ab901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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, which no longer receives security patches. Philter now requires Elixir `~> 1.16`. +- Dropped support for Elixir 1.15 and 1.16 and for OTP 25 and 26, which are outside their security-patch windows. 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 diff --git a/CLAUDE.md b/CLAUDE.md index 49b9926..342412f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 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.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.16`. +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. @@ -24,7 +24,7 @@ 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.16–1.20 with OTP 26–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. +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 diff --git a/mix.exs b/mix.exs index f44c2e1..f8957ec 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Philter.MixProject do [ app: :philter, version: @version, - elixir: "~> 1.16", + elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), From 0e8633af3f340a0b32f02e4e10fd3f8d5b59cfcd Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Sun, 4 Oct 2026 08:22:44 +0200 Subject: [PATCH 8/8] Correct the changelog reason for dropping Elixir 1.16 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ab901..bad4e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 1.16 and for OTP 25 and 26, which are outside their security-patch windows. Philter now requires Elixir `~> 1.17` on OTP 27 or later. +- 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