Philter (Philter v0.4.0)

Copy Markdown View Source

Streaming HTTP proxy with request/response observation.

Philter forwards HTTP requests to upstream servers while capturing body observations (hash, size, timing, preview) without buffering. Supports conditional body accumulation for content types you want to persist.

Philter — an alchemical potion or charm; from Greek philtron (φίλτρον), "love potion." Here it evokes both filtering (the proxy inspects and forwards HTTP traffic) and the Elixir ecosystem's alchemical tradition.

HTTP Client

Philter uses a Mint-direct transport that resolves the upstream hostname, validates the resolved addresses against the SSRF egress policy (see Philter.Egress), and pins the connection to a validated IP. No Finch pool is needed; the :finch_name option is deprecated and ignored.

Quick Start

# In a Phoenix controller
def proxy(conn, _params) do
  Philter.proxy(conn, upstream: "http://api.example.com")
end

# Or as a Plug in your router
forward "/api", Philter.ProxyPlug, upstream: "http://api.example.com"

Configuration

Set defaults in your config and override per-request. See Philter.Config for details.

# config/config.exs
config :philter,
  receive_timeout: 30_000,
  max_payload_size: 5_242_880

Handler Callbacks

Implement Philter.Handler to hook into the proxy lifecycle:

  • handle_request_started/2 - Called before sending to upstream
  • handle_response_started/2 - Called on first byte received (TTFB)
  • handle_response_finished/2 - Called when complete, with body observations

Example:

defmodule MyHandler do
  use Philter.Handler

  @impl true
  def handle_response_finished(result, state) do
    # result contains :request_observation and :response_observation
    # each with :hash, :size, :body, :preview
    {:ok, state}
  end
end

Summary

Functions

Proxies an HTTP request to an upstream server.

Types

proxy_opts()

@type proxy_opts() :: [
  upstream: String.t(),
  path: String.t() | (Plug.Conn.t() -> String.t()),
  handler: module() | {module(), term()},
  headers: [{String.t(), String.t()}],
  extra_headers: [{String.t(), String.t()}],
  strip_headers: [String.t()],
  finch_name: atom(),
  receive_timeout: pos_integer(),
  max_payload_size: pos_integer(),
  persistable_content_types: [String.t()],
  log_level: Logger.level() | false,
  collect_timing: boolean(),
  block_private_networks: boolean(),
  allowed_hosts: [String.t()],
  dns_timeout: pos_integer(),
  connect_timeout: pos_integer(),
  transport_opts: keyword()
]

Functions

proxy(conn, opts)

@spec proxy(Plug.Conn.t(), proxy_opts()) :: Plug.Conn.t()

Proxies an HTTP request to an upstream server.

Streams the request to upstream and the response back to the client. Call this after authentication or other pre-processing (unlike Philter.ProxyPlug).

Examples

# Basic proxy
Philter.proxy(conn, upstream: "http://api.example.com")

# With handler for logging/persistence
Philter.proxy(conn,
  upstream: "http://api.example.com",
  handler: {MyHandler, %{user_id: user.id}}
)

# Override timeout for slow endpoints
Philter.proxy(conn,
  upstream: "http://api.example.com",
  receive_timeout: 60_000
)

Options

  • :upstream - Base URL of the upstream server. Required.

  • :handler - Handler module or {module, state} tuple for lifecycle callbacks. See Philter.Handler for the callback interface.

  • :headers - Pre-assembled outbound request headers as [{name, value}] tuples. When provided, these replace conn.req_headers (no hop-by-hop filtering) and an explicit "host" entry (case-insensitive) is preserved as-is; if no "host" is included, the upstream host is appended as a default. When omitted, conn.req_headers are filtered (hop-by-hop removed, keys lowercased) and the host header is always rewritten to match upstream. Cannot be combined with :extra_headers or :strip_headers.

  • :extra_headers - Additional [{name, value}] headers to merge into the outbound request. Applied after hop-by-hop filtering and host rewriting. If an extra header matches an existing header name (case-insensitive), the existing header is replaced. Cannot be combined with :headers.

  • :strip_headers - List of header names (case-insensitive) to remove from the outbound request. Applied after hop-by-hop filtering and host rewriting but before :extra_headers. Cannot be combined with :headers.

    When both :strip_headers and :extra_headers are used, the processing order is: filter hop-by-hop headers → rewrite host → strip → merge extra.

  • :finch_name - Deprecated and ignored. The transport uses no connection pool. Accepted so existing callers do not crash.

  • :receive_timeout - Response timeout in milliseconds. Default: 15_000.

  • :max_payload_size - Max body size in bytes for full accumulation. Bodies exceeding this are still hashed and previewed. Default: 1_048_576 (1MB).

  • :path - Override the request path sent to upstream. Can be a string or a function (Plug.Conn.t() -> String.t()). Default: conn.request_path.

  • :persistable_content_types - Content types eligible for body accumulation. Supports wildcards like "text/*". Default: JSON, XML, and text types.

  • :log_level - Logger level for lifecycle events (:debug, :info, etc.) or false to disable all logging. Default: :debug.

  • :collect_timing - When true, captures a per-phase timing breakdown (connect_us, send_us, recv_us) measured directly around the Mint transport calls. queue_us and idle_time_us are always nil and reused_connection? is always false (no connection pool). Phase fields in timing are nil when disabled. Default: false.

  • :block_private_networks - When true (default), reject upstreams whose 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 escape hatch, e.g. a deliberately internal upstream). Exact match after downcase and trailing-dot strip. Default: [].

  • :dns_timeout - Milliseconds to bound upstream DNS resolution. On timeout the request fails with 504. Default: 5_000.

  • :connect_timeout - Milliseconds to bound the connection phase to a validated upstream address. Default: 5_000.

  • :transport_opts - Extra Mint transport options merged into the connection (e.g. cacertfile: to trust a custom CA bundle). Cannot be used to disable TLS certificate verification. Default: [].

Return Value

Returns the conn with response sent. Observations are stored in:

  • conn.private[:philter_request_observation] - Request body observation
  • conn.private[:philter_response_observation] - Response body observation

Each observation contains :hash, :size, :body (if accumulated), and :preview.

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.