PhoenixKit.API.BodyGuard (phoenix_kit v2.63.0)

Copy Markdown View Source

Endpoint plug that bounds and parses the body of API requests and normalises the percent-encoded prefix of an API path (see "Encoded segments of the path" below).

Plug.Parsers runs in the host's endpoint, before the router, with an 8 MB default limit, and it answers a malformed JSON body with an exception page in whatever format Phoenix picks. A plug inside the API router cannot change either, so this plug sits in the endpoint, immediately before Plug.Parsers:

plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

plug PhoenixKit.API.BodyGuard

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  ...

mix phoenix_kit.install and mix phoenix_kit.update insert it.

What it does

For POST, PUT, PATCH and DELETE requests to the shop API (<prefix>/api/admin/v1, <prefix>/api/shop/v1, <prefix>/mcp/admin and <prefix>/mcp/shop, where <prefix> is the kit's URL prefix) while the API of that path's audience is reachable by the client (the switch is on, api_admin_enabled for /api/admin/v1 and /mcp/admin, api_public_enabled for /api/shop/v1 and /mcp/shop, and, for the public audience, the website password gate does not turn the client away) it reads the body, at most PhoenixKit.API.Config.max_body_bytes/0 (2 MB), and decodes it as JSON, whatever the content-type says. Then it fills body_params and params, which makes Plug.Parsers skip the request:

  • an empty body is %{};
  • a JSON object is that map;
  • any other JSON value (an array, a number) is %{"_json" => value}, the shape Plug.Parsers.JSON uses;
  • a body over the limit is answered 413 payload_too_large;
  • a body that is not JSON, or holds a number the decoder cannot take (an integer of a million digits), is answered 400 invalid_json; on /mcp/admin and /mcp/shop that 400 is a JSON-RPC -32700 parse error (application/json, id: null).

The 413 and, on the REST paths, the 400 are application/problem+json, see PhoenixKit.API.Problem. On /api/shop/v1 they also carry access-control-allow-origin: * and access-control-expose-headers, as every other answer of the public REST audience does (PhoenixKit.API.REST), so a browser agent can read them; the admin audience and MCP send no CORS header.

While the switch is off, or the website password gate is on and the client's address is not on its allow-list (the public audience only), the API answers every request with a bare 404 and never looks at the body (PhoenixKit.API.Auth), so the guard reads and judges nothing: no 2 MB read, no 413 or 400 where a 404 is due, and a gated site does not reveal that its public API is switched on. It only sets body_params to %{}, without reading, so that Plug.Parsers (next in the endpoint) skips the request instead of raising on a body nobody wants. The switch is read through PhoenixKit.API.Config and the gate through PhoenixKit.API.Auth.gated?/2, the same cached settings reads Auth uses; an unreadable switch counts as off.

Cost before authentication

The guard runs before PhoenixKit.API.Auth and before any rate bucket, so a client that has not authenticated can make the node read up to 2 MB per request and decode it as JSON (a deeply nested [[[...]]] body of that size takes about 0.25 s of CPU and, measured, about 40 times the body size in process heap, some 90 MB for a 2 MB body; an unterminated one is refused in about 0.3 s), and the 413 and 400 answers are not counted against the pre-auth failure bucket (PhoenixKit.API.RateLimit.record_auth_failure/1 is reached from Auth only). Accepted: Plug.Parsers of the stock endpoint has the same cost before authentication, so the guard does not make it worse, and it only acts while the switch of the audience is on. As with the key lookups in Auth, the throttle limits the answers, not the reads. A deployment that needs a bound puts it in front of the node (the reverse proxy's body size and request rate limits).

Every other path passes untouched, with its body unread: the matching is by whole path segments, so /apiary is not /api, bare /mcp is not /mcp/admin, and the storage routes under /api/upload and /api/files keep their own, larger, limits. The segments are percent-decoded before they are compared, the way the router decodes them (/api/%61dmin/v1 is /api/admin/v1); a segment with a malformed escape (%zz) is compared as it was sent.

Encoded segments of the path

Phoenix's forward strips its own prefix from the raw path_info, so a request whose guarded prefix is written percent-encoded (/api/%61dmin/v1/x, which the router matches as /api/admin/v1/x) would raise a MatchError there, a 500 for a crafted URL. The guard therefore replaces the segments of the matched prefix by their decoded form, for every method and whatever the switch says, and leaves the rest of path_info as sent. The request then takes the same road as the plain path.

The forwards of the shop API depend on this plug for that: a host whose endpoint does not have it yet (it has not run mix phoenix_kit.update) answers such a crafted URL with a 500 from inside the router. Every future forward under an API prefix (the MCP endpoints) inherits the dependency.

Summary

Functions

Whether conn is a request to a guarded API path.

Functions

guarded?(conn)

@spec guarded?(Plug.Conn.t()) :: boolean()

Whether conn is a request to a guarded API path.

The path is compared segment by segment, under the kit's URL prefix (PhoenixKit.Utils.Routes.prefix_base/0), an empty segment (/api//admin/v1) is dropped the way the router drops it, and each segment is percent-decoded the way the router decodes it.