Streaming HTTP proxy library for Elixir with O(1) memory body observation and deny-by-default SSRF egress filtering.
Philter — an alchemical potion or charm; from Greek philtron (φίλτρον), "love potion."
- Zero buffering: streams requests and responses without accumulating bodies in memory
- Body observation: SHA256 hash, size and a UTF-8 safe preview captured as bytes flow through
- Egress filtering: upstreams that resolve to private, loopback or cloud-metadata addresses are blocked by default, with DNS-rebinding protection
- Plug integration: use as a Plug or call directly from controllers
- Lifecycle callbacks: hook request start, first byte and completion for logging or persistence
- Configurable: application-level defaults with per-request overrides
- Self-contained: manages its own connections, nothing to add to your supervision tree
Add philter to your list of dependencies in mix.exs:
def deps do
[
{:philter, "~> 0.4.0"}
]
endPhilter talks to upstreams directly over Mint and opens a fresh HTTP/1 connection per request. There is no connection pool to configure or supervise, so installation is just the dependency.
Call it from a controller:
def proxy(conn, _params) do
Philter.proxy(conn, upstream: "https://api.example.com")
endOr forward a route prefix in your router:
forward "/api", Philter.ProxyPlug, upstream: "https://api.example.com"Note that Philter refuses upstreams that resolve to private or internal
addresses by default. If your upstream is internal, including localhost in
development, add it to :allowed_hosts (see
Egress filtering below).
- Configuration is resolved (per-request options over application config over defaults) and your handler's
handle_request_started/2runs, which may reject the request outright. - The upstream hostname is resolved and every resolved address is validated against the egress policy. A blocked address returns
403, a DNS timeout504, and an unresolvable host502. - 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.
- On completion
handle_response_finished/2is called (always, even on error) and the observations are stored inconn.private.
Upstream connection failures surface as 502 Bad Gateway and upstream timeouts
as 504 Gateway Timeout.
Philter captures observations about request and response bodies without buffering them:
conn = Philter.proxy(conn, upstream: "https://api.example.com")
# Access observations from conn.private
req_obs = conn.private[:philter_request_observation]
resp_obs = conn.private[:philter_response_observation]
# Each observation contains:
# - :hash - SHA256 hash of the body
# - :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
accumulated when the content type matches :persistable_content_types and the
body stays under :max_payload_size.
Implement Philter.Handler to hook into the proxy lifecycle:
defmodule MyApp.ProxyHandler do
use Philter.Handler
@impl true
def handle_request_started(metadata, state) do
Logger.info("Proxying #{metadata.method} #{metadata.upstream_url}")
{:ok, state}
end
@impl true
def handle_response_started(metadata, state) do
Logger.info("TTFB: #{metadata.time_to_first_byte_us}us")
{:ok, state}
end
@impl true
def handle_response_finished(result, state) do
Logger.info("Completed: #{result.status} in #{result.timing.total_us}us")
# result contains :request_observation and :response_observation
{:ok, state}
end
end
# Use it:
Philter.proxy(conn,
upstream: "https://api.example.com",
handler: {MyApp.ProxyHandler, %{}}
)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.
Every option below can be set globally under config :philter and overridden
per request. Precedence is: per-request option, then application config, then
the built-in default.
| Option | Default | Description |
|---|---|---|
:receive_timeout |
15_000 |
Response timeout in milliseconds |
:connect_timeout |
5_000 |
Milliseconds to bound the connection phase to a validated upstream address |
:dns_timeout |
5_000 |
Milliseconds to bound upstream DNS resolution |
:max_payload_size |
1_048_576 |
Max body size for full accumulation (1MB) |
:persistable_content_types |
JSON/XML/text | Content types eligible for body storage, wildcards like text/* supported |
:block_private_networks |
true |
Reject upstreams resolving to private/internal ranges (SSRF egress guard) |
:allowed_hosts |
[] |
Hosts that bypass the egress block check (escape hatch) |
:log_level |
:debug |
Logger level for lifecycle events, or false to disable |
:transport_opts |
[] |
Extra Mint transport options, e.g. a custom CA bundle. Cannot disable TLS verification |
Set application-wide defaults, including the egress policy:
# config/config.exs
config :philter,
receive_timeout: 30_000,
max_payload_size: 5_242_880,
persistable_content_types: ["application/json", "text/*"],
block_private_networks: true,
allowed_hosts: ["api.internal"],
dns_timeout: 2_000Or override per request:
Philter.proxy(conn,
upstream: "https://api.example.com",
receive_timeout: 60_000,
max_payload_size: 5_242_880
)Some options only make sense per call and are passed directly to
Philter.proxy/2 or Philter.ProxyPlug: :upstream (required), :path,
:handler, :headers, :extra_headers, :strip_headers and
:collect_timing. See the Philter.proxy/2 docs for the full list.
Philter is often placed in front of caller-supplied upstream URLs, which makes Server-Side Request Forgery (SSRF) a real risk: a malicious caller could point the proxy at internal services or a cloud metadata endpoint. Philter defends against this by default.
- Deny-by-default. With
block_private_networks: true(the default), Philter rejects any upstream whose hostname resolves to a private, loopback, link-local, CGNAT or otherwise internal address. This covers RFC1918 (10/8,172.16/12,192.168/16), loopback (127/8,::1), link-local including the cloud metadata address169.254.169.254(IMDS), IPv6 unique local (fc00::/7) and link-local (fe80::/10), and reserved ranges. IPv6 forms that embed an IPv4 address (IPv4-mapped, IPv4-compatible, NAT64, 6to4, Teredo) are unwrapped and re-checked. SeePhilter.Egressfor the full list. - Blocking is on the resolved IP, not the URL. Because Philter validates the addresses the hostname actually resolves to, octal/hex/decimal IP-encoding tricks in the URL do not help an attacker.
- Resolve-and-pin (DNS-rebinding protection). Philter resolves the hostname once, validates every answer, and connects the socket to a validated IP without ever re-resolving, while still using the original hostname for the Host header, TLS SNI and certificate verification. A name that resolves "clean" then flips to an internal IP cannot slip through.
- Only
httpandhttpsupstreams are accepted. Any other scheme is refused with502. - What a rejection looks like. A blocked address returns
403with a static body (Request blocked by egress policy); the resolved IP is logged server-side only and never returned to the client. A DNS timeout returns504; an unresolvable host returns502.
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):
Philter.proxy(conn,
upstream: "http://api.internal:4000",
allowed_hosts: ["api.internal"]
)For an allowance that applies everywhere, set it in application config instead:
# config/dev.exs
config :philter, allowed_hosts: ["localhost"]Egress filtering blocks internal targets; it does not stop Philter being used as a relay to public hosts. An operator exposing Philter to untrusted callers can still be abused for reconnaissance or to launder attacks against third parties, which could get the deploying server's IP flagged or blocklisted. Deny-by-default does not prevent this. Rate limiting, authentication and attribution are the operator's responsibility and are out of scope for Philter.
Full documentation: https://hexdocs.pm/philter
Apache-2.0