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 machine API (every path
of PhoenixKit.API.Paths.entries/0: <prefix>/api/admin/v1,
<prefix>/api/public/v1, <prefix>/mcp/admin, <prefix>/mcp/public and the
deprecated aliases of the public two, 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 the public paths, 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 shapePlug.Parsers.JSONuses; - 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 the MCP paths that 400 is a JSON-RPC-32700parse error (application/json,id: null).
The 413 and, on the REST paths, the 400 are application/problem+json,
see PhoenixKit.API.Problem. On
the public REST paths 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.
On a deprecated alias (PhoenixKit.API.Legacy) they carry its Deprecation header and
successor Link, as the answers of the API behind it do (PhoenixKit.API.Paths.put_deprecation/2).
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 machine 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
@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.