# `PhoenixKit.API.BodyGuard`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.64.1/lib/phoenix_kit/api/body_guard.ex#L1)

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
    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 the MCP
    paths 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
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.

# `guarded?`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
