RequestSeal.Plug.Capture (RequestSeal v0.5.2)

Copy Markdown View Source

Capture a bounded request before destructive parsing or origin/IP rewriting.

Original ingress facts remain separate from the selected origin. Transport facts stay declared and describe ingress, never the forwarded origin.

Headers retain Plug's occurrence order and protocol provenance. Plug has already normalized header names; HTTP/2 servers can join cookie crumbs. Request trailers are :unavailable; policies requiring them reject. The target is origin-form reconstructed from request_path and query_string. It supports path derivation, not exact target evidence. Verify rejects required or covered @request-target, @target-uri, and @query with :unsupported_component; SignResponse also rejects those request components. See RequestSeal.Plug for the tested per-transport rule. OPTIONS * is retained.

After a successful capture, the Delivery adapter replays retained bytes to all readers, including multipart. Custom wrapper readers must return replayed bytes unchanged; their subsequent transformations cannot be observed by the adapter. Retention remains caller-owned.

Summary

Functions

Reads the request body once and retains the request as an immutable message.

Validates capture options once, at plug initialization.

Records that the RequestSeal reader ran, then reads the body through Plug.

Types

t()

@type t() :: %RequestSeal.Plug.Capture{
  ingress: %{
    scheme: atom(),
    host: binary(),
    port: non_neg_integer(),
    remote_ip: :inet.ip_address(),
    protocol: atom()
  },
  message: RequestSeal.Message.t(),
  origin: %{
    scheme: binary(),
    authority: binary(),
    source: :connection | :declared | :forwarded
  }
}

Functions

call(conn, opts)

Reads the request body once and retains the request as an immutable message.

One Plug.Conn.read_body/2 call with length: max_body_bytes captures the bytes. On success the request-local Delivery adapter replaces the connection adapter so later readers receive the same bytes, and the capture is stored in private state for RequestSeal.Plug.capture/1.

Parameters

  • conn - a Plug.Conn whose body has not been read or parsed.
  • opts - options returned by init/1.

Returns

  • Plug.Conn - on success, the connection with the capture stored.
  • Plug.Conn - on rejection, halted with an empty body and status 413 for :limit or 400 for every other reason. Only HTTP/1.0 and HTTP/1.1 rejections carry connection: close; HTTP/2 rejections omit connection fields. Private state's :error holds a %RequestSeal.Adapter.Error{stage: :capture, attempt: 0} with reason: :parser_order (body_params already fetched; checked before any read), :invalid_request (CONNECT, an empty host, an existing capture, a malformed or oversized forwarded field, an invalid forwarded scheme or authority, a value RequestSeal.Message.new/1 rejects, or an unexpected exception), :untrusted_proxy (the socket peer is outside trusted_peers), :limit (the read returned :more or more than max_body_bytes; no second read occurs), and :body_unavailable (the server returned a read error). Meanings: RequestSeal.Adapter.Error.

Examples

iex> opts =
...>   RequestSeal.Plug.Capture.init(origin: :connection, max_body_bytes: 4, read_timeout: 5_000)
iex> conn =
...>   RequestSeal.Plug.Capture.call(
...>     Plug.Test.conn(:post, "https://telemetry.example.com/ingest", "12345"),
...>     opts
...>   )
iex> {conn.status, conn.halted, conn.private.request_seal.error.reason}
{413, true, :limit}
iex> Plug.Conn.get_resp_header(conn, "connection")
["close"]

RequestSeal.Plug.capture/1 shows a successful capture.

init(opts)

Validates capture options once, at plug initialization.

Parameters

  • opts - keyword list of the options below. All are required; unknown or duplicate options reject.

Options

  • :origin - how the request origin (scheme and authority) is selected. Every selected origin lowercases scheme and host and omits the default port (80 for HTTP, 443 for HTTPS).
    • :connection - the connection's scheme, host, and port.
    • {:declared, scheme, authority} - a caller-trusted scheme and authority, validated and normalized at init.
    • {:forwarded, %{trusted_peers: cidrs, field: field}} - cidrs is a list of 1..1,024 {ip_tuple, prefix} ranges; field is :forwarded or :x_forwarded. The last forwarded element is used only after the actual socket peer is found in a trusted range.
  • :max_body_bytes - nonnegative integer bound on the captured body.
  • :read_timeout - 1..300,000 milliseconds for the single body read.

Returns

  • keyword list - the validated options, with a declared origin normalized.

Raises RequestSeal.Adapter.Error with reason :invalid_options and stage :capture on any invalid option.

Examples

iex> RequestSeal.Plug.Capture.init(
...>   origin: {:declared, "HTTPS", "Docs.Example.com:443"},
...>   max_body_bytes: 1_024,
...>   read_timeout: 5_000
...> )
[origin: {:declared, "https", "docs.example.com"}, max_body_bytes: 1024, read_timeout: 5000]
iex> try do
...>   RequestSeal.Plug.Capture.init(origin: :connection, max_body_bytes: -1, read_timeout: 5_000)
...> rescue
...>   error in RequestSeal.Adapter.Error -> {error.reason, error.stage}
...> end
{:invalid_options, :capture}

read_body(conn, opts)

@spec read_body(Plug.Conn.t(), keyword()) ::
  {:ok | :more, binary(), Plug.Conn.t()} | {:error, term()}

Records that the RequestSeal reader ran, then reads the body through Plug.

Configure it as a parser reader with body_reader: {RequestSeal.Plug.Capture, :read_body, []}. After a capture, reads come from the retained replay: each returns at most :length bytes (default 8,000,000), :more until the replay is drained, then :ok with empty bytes. Without a capture the call is a plain Plug.Conn.read_body/2. RequestSeal.Plug.Verify uses the recorded invocation to require a fully drained, matching replay.

Parameters

Returns

  • {:ok, binary, conn} - the last bytes of the body, or empty bytes once drained.
  • {:more, binary, conn} - a chunk; more bytes remain.
  • {:error, %RequestSeal.Adapter.Error{reason: :invalid_options}} - a negative or non-integer replay :length; the replay does not advance.
  • {:error, term} - any other error from the underlying Plug adapter when no capture is installed.

Examples

iex> opts =
...>   RequestSeal.Plug.Capture.init(origin: :connection, max_body_bytes: 1_024, read_timeout: 5_000)
iex> conn =
...>   RequestSeal.Plug.Capture.call(
...>     Plug.Test.conn(:post, "https://docs.example.com/upload", "abcdef"),
...>     opts
...>   )
iex> {:more, first, conn} = RequestSeal.Plug.Capture.read_body(conn, length: 4)
iex> {:ok, rest, conn} = RequestSeal.Plug.Capture.read_body(conn, length: 4)
iex> {first, rest}
{"abcd", "ef"}
iex> {:ok, "", conn} = RequestSeal.Plug.Capture.read_body(conn, [])
iex> {:error, error} = RequestSeal.Plug.Capture.read_body(conn, length: -1)
iex> error.reason
:invalid_options