Philter

Hex.pmDocsCI

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."

Features

Installation

Add philter to your list of dependencies in mix.exs:

def deps do
[
{:philter, "~> 0.4.0"}
]
end

Philter 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.

Quick start

Call it from a controller:

def proxy(conn, _params) do
Philter.proxy(conn, upstream: "https://api.example.com")
end

Or 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).

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.
  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.

Upstream connection failures surface as 502 Bad Gateway and upstream timeouts as 504 Gateway Timeout.

Body observation

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.

Handler callbacks

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.

Configuration

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.

OptionDefaultDescription
:receive_timeout15_000Response timeout in milliseconds
:connect_timeout5_000Milliseconds to bound the connection phase to a validated upstream address
:dns_timeout5_000Milliseconds to bound upstream DNS resolution
:max_payload_size1_048_576Max body size for full accumulation (1MB)
:persistable_content_typesJSON/XML/textContent types eligible for body storage, wildcards like text/* supported
:block_private_networkstrueReject upstreams resolving to private/internal ranges (SSRF egress guard)
:allowed_hosts[]Hosts that bypass the egress block check (escape hatch)
:log_level:debugLogger 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_000

Or 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.

Egress filtering (SSRF protection)

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.

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):

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"]

Residual risk

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.

Documentation

Full documentation: https://hexdocs.pm/philter

License

Apache-2.0