---
title: "Security conformance"
description: "Trace each security requirement to an executable test and its evidence."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Security conformance

This is a **normative checklist**, not advice. Every requirement maps to at
least one executable test below. `packages/core/tests/security/mapping.spec.ts`
fails the build if a requirement loses coverage, if a named test ID does not
exist, or if the suite contains an unmapped test ID.

**This page is machine-checked. Do not edit it by hand without running
`pnpm --filter @adonia/core test`.**

## Where the tests live

| Suite | Runs against | Command |
| --- | --- | --- |
| `packages/core/tests/security/*.spec.ts` | fakes only — no app boot, no database, no `@adonisjs/*` at runtime (§21) | `pnpm --filter @adonia/core test` |
| `examples/blog-admin/tests/functional/adonia_security.spec.ts` | the real v7 app: committed router, Shield, a live query builder | `pnpm --filter blog-admin test` |

A probe lives in `blog-admin` only when it is **not provable** hermetically —
the committed route table, Shield's CSRF middleware, and compiled SQL. Everything
else is in core, where it runs in milliseconds on every push.

## Requirements and evidence

The `S<n>` ids below are this document's stable requirement numbering. The mapping test
asserts that every section names executable tests and that the document and suite contain
the same test IDs.

### S1 — "Every route in the panel group carries the panel guard + access middleware — there are no unauthenticated Adonia routes except `login`."

| Test ID | Asserts |
| --- | --- |
| `SEC-ROUTE-00` | the sweep is derived from `panelRouteSpecs` and non-empty |
| `SEC-ROUTE-01` | every route in the §10.1 table redirects an HTML guest to the panel login page, rendering nothing |
| `SEC-ROUTE-02` | the same routes answer a JSON guest `401 {code, message}` — never a redirect |
| `SEC-ROUTE-03` | the same routes answer `403` when the panel's `access()` verdict is false |
| `SEC-ROUTE-04` | the registrar puts **exactly** the swept set behind `adonia.panel-access` |
| `SEC-ROUTE-05` | the only un-gated routes are `login`, `login.attempt`, `logout`, and all three are `AuthController` |
| `SEC-ROUTE-06` | a panel with no login flow leaves nothing outside the gate |
| `SEC-ROUTE-07` | a panel with no `guard()` still runs the access verdict |
| `SEC-HTTP-01` | over real HTTP, every registered route refuses a guest with a redirect to `/admin/login` |
| `SEC-HTTP-02` | over real HTTP, a JSON guest gets `401 E_ADONIA_UNAUTHORIZED` |
| `SEC-HTTP-03` | the login page itself is reachable by a guest (the carve-out is real) |

### S2 — "Mass assignment is impossible by construction: fill sets derive from the compiled schema for the exact `(mode, visibility-state)` of the submission."

| Test ID | Asserts |
| --- | --- |
| `SEC-MASS-01` | each forged key — unknown, `id`, `createdAt`, `isAdmin`, `canSee`-hidden, `visibleWhen`-hidden, `requiresAbility`-hidden, `readonlyOn`, `disabled()`, `virtual()`, `__proto__`, `constructor`, `prototype` — is absent from the fill set, in both modes |
| `SEC-MASS-02` | the fill set is **exactly** the declared, projected keys (the positive half) |
| `SEC-MASS-03` | `applyFill` writes only fill-set keys the payload carried |
| `SEC-MASS-04` | prototype keys pollute neither the record nor `Object.prototype` |
| `SEC-MASS-05` | `POST /:resource` end to end persists the declared keys and nothing else |
| `SEC-MASS-06` | `PUT /:resource/:id` leaves a `readonlyOn` attribute at its stored value |
| `SEC-MASS-07` | no global prototype pollution after a controller write |
| `SEC-MASS-08` | the same key becomes fillable when its `visibleWhen` turns true |
| `SEC-MASS-09` | client-supplied "visibility" flags cannot un-hide a field |

### S3 — "Sort/filter/search/page inputs validate against declared allowlists."

| Test ID | Asserts |
| --- | --- |
| `SEC-ALLOW-01` | 34 junk values × 7 parameters: never throws, and the parsed state satisfies every §8.3 invariant |
| `SEC-ALLOW-02` | a junk-only query builds a statement **byte-identical** to the empty query's |
| `SEC-ALLOW-03` | junk in every parameter at once still builds the baseline statement |
| `SEC-ALLOW-04` | a junk `?q=` reaches SQL only as a binding, never as text |
| `SEC-ALLOW-05` | fuzzing introduces no SQL-text fragment a legitimate request cannot produce |
| `SEC-ALLOW-06` | junk smuggled beside a valid token drops only the junk |
| `SEC-ALLOW-07` | an undeclared model column (`password`) is sortable/filterable/selectable by nobody |
| `SEC-ALLOW-08` | bracket keys named after prototype slots create no properties |
| `SEC-ALLOW-09` | a legitimate request **does** change the statement (the positive control) |
| `SEC-HTTP-07` | against a live builder, no junk parameter changes the compiled SQL |
| `SEC-HTTP-08` | against a live builder, a junk search term appears only as a binding |

### S4 — "Uploads verify mime (magic-bytes, not extension), size, and (images) dimensions server-side after Drive temp storage; private disks serve via signed URLs with panel-session-gated generation."

| Test ID | Asserts |
| --- | --- |
| `SEC-UPLOAD-01` | the full 7×7 (real bytes × claimed extension) matrix: the verdict follows the bytes, and an accepted key carries the sniffed extension |
| `SEC-UPLOAD-02` | the rejection message names no sniffed type (no format oracle) |
| `SEC-UPLOAD-03` | a part declaring `Content-Type: image/png` while carrying a shell is rejected, `no-store` set |
| `SEC-UPLOAD-04` | a part declaring `application/x-php` while carrying a PNG is accepted |
| `SEC-UPLOAD-05` | the byte ceiling is enforced at its exact boundary |
| `SEC-UPLOAD-06` | size is checked before dimensions (decode-bomb ordering) |
| `SEC-UPLOAD-07` | every dimension rule refuses its violation and leaves no bytes |
| `SEC-UPLOAD-08` | 100 rejections leave a byte-empty disk, each having stored then deleted |
| `SEC-UPLOAD-09` | a rejection never deletes an accepted object beside it |
| `SEC-UPLOAD-10` | supersession queued by a request that never settles deletes nothing |
| `SEC-UPLOAD-11` | a failing delete is reported, never thrown |
| `SEC-UPLOAD-12` | a temp key minted by a permissive field is REFUSED by a stricter one — the §19 checks are not launderable across fields or resources |
| `SEC-UPLOAD-13` | a key whose stamp segment was hand-edited to match the target field names no stored object, so promotion fails closed with a 422 |
| `SEC-UPLOAD-14` | a driver that cannot rename, and a signer that refuses, both leave the disk byte-empty — the no-orphan guarantee covers every failure after the write, not only a verification verdict |
| `SEC-UPLOAD-15` | a superseded object that could not be deleted is logged with `module: 'adonia'` and its keys, never silently dropped |

A temp Drive key is `adonia/tmp/<uuid>/<stamp>/<filename>`. The `stamp` is a
digest of the exact `UploadRules` that accepted the bytes (field key, accepted
types, byte ceiling, dimension rules), and `FileField`'s dehydrate refuses any
temp key whose stamp is not its own. It needs no secret: the stamp is a
segment of the path the object is actually stored at, so re-labelling a key
with another field's stamp names an object that does not exist. Rows 12–14
live in `packages/core/tests/uploads_query_repair.spec.ts`; row 15 is beside
them.

The signed-URL half of S4 (`private disks serve via signed URLs with
panel-session-gated generation`) is B2-7's own acceptance group,
`uploads | private disks serve signed URLs only (§19)` in
`packages/core/tests/uploads.spec.ts`: unsigned refused, signed works, expired
refused, against a `FakeDisk` whose signing is a real HMAC. It is referenced
rather than duplicated here.

### S5 — "Rich text sanitizes on dehydrate with a configurable allowlist."

| Test ID | Asserts |
| --- | --- |
| `SEC-XSS-00` | the corpus reached the suite intact |
| `SEC-XSS-01` | the whole XSS corpus, run through `validate → dehydrateFill → applyFill`, persists with no executable vector |
| `SEC-XSS-02` | the persisted value is **exactly** the sanitizer's output for the field's policy |
| `SEC-XSS-03` | sanitization survives a repeater state boundary |
| `SEC-XSS-04` | a per-field `sanitize()` policy narrows what persists |
| `SEC-XSS-05` | widening the app policy cannot re-admit `script`/`iframe`/`object` |
| `SEC-XSS-06` | a non-rich-text field is left byte-for-byte alone (negative control) |
| `SEC-XSS-07` | an author-supplied `dehydrate()` replaces the sanitizer — the documented escape hatch, pinned |

### S6 — "All mutations re-authorize inside their transaction."

| Test ID | Asserts |
| --- | --- |
| `SEC-AUTHZ-01` | one denied ability, one resource: refused at the route, absent from the descriptor, and no transaction committed |
| `SEC-AUTHZ-02` | the same ability granted commits, keeps its node, and reports `true` |
| `SEC-AUTHZ-03` | an ability revoked **after** the route check rolls the transaction back (TOCTOU) |

### S7 — "XSRF: Shield's `enableXsrfCookie` MUST be on; with domain-mounted panels the installer verifies the XSRF/session cookie domain covers the panel host (`adonia:doctor` check)."

| Test ID | Asserts |
| --- | --- |
| `SEC-HTTP-04` | a signed-in mutation with a valid payload and **no** CSRF token persists nothing |
| `SEC-HTTP-05` | the same mutation with a forged token persists nothing |
| `SEC-HTTP-06` | the same mutation with a valid token succeeds (the control) |

The two static checks are `adonia:doctor`'s, and are covered by the devtools
suite rather than duplicated here: `shield-xsrf`
(`packages/devtools/src/doctor/shield_xsrf.ts`) fails when `csrf.enabled` or
`csrf.enableXsrfCookie` is off or absent, and `cookie-domain`
(`packages/devtools/src/doctor/cookie_domain.ts`) fails when a domain-mounted
panel's host is not covered by the session/XSRF cookie domain.

### S8 — "Descriptors never include: model attributes outside the schema, ability-failing nodes, Drive credentials, or internal paths."

| Test ID | Asserts |
| --- | --- |
| `SEC-LEAK-01` | an index row's key set is **derived** from the declaration: `id`, `can`, declared columns, nothing else |
| `SEC-LEAK-02` | each of seven secret attributes (`password`, `rememberMeToken`, an API key, a 2FA secret, two Drive credentials, an absolute filesystem path) appears on no index row — neither as a key nor as a value |
| `SEC-LEAK-03` | the whole records envelope leaks none of them |
| `SEC-LEAK-04` | `?columns=` can only narrow; secrets are in no allowlist |
| `SEC-LEAK-05` | no `create`/`edit`/`detail` tree names or carries an undeclared attribute |
| `SEC-LEAK-06` | the index descriptor names only declared columns and filters |
| `SEC-LEAK-07` | a detail record envelope is the attribute bag plus `id` and `can` — nothing invented |
| `SEC-LEAK-08` | the projection boundary is pinned: `serializeRecord` ships the bag it is handed (see the gap note below) |
| `SEC-AUTHZ-06` | every node key in a compiled tree is one the projection kept — walked from the output, not from a list |
| `SEC-AUTHZ-07` | a `canSee`-failing **section** is omitted with its subtree, label included |
| `SEC-AUTHZ-08` | omission reaches state, validator and fill set in the same pass |
| `SEC-AUTHZ-04` | per-record `can` never claims more than the authorizer grants — every row × every ability |
| `SEC-AUTHZ-05` | the serialized row carries that projection verbatim, with every §12 key present |

### S9 — "Action `payload` persistence scrubs `sensitive()` fields."

| Test ID | Asserts |
| --- | --- |
| `SEC-SENSITIVE-01` | `sensitive()` is carried on the field and reaches `FieldMeta`, so the P3-2 recorder has something to read |
| `SEC-SENSITIVE-02` | a `sensitive()` file/image field refuses a public disk, so its bytes are only reachable through a signed URL |

Actions land in Phase 3 (`IMPLEMENTATION_PLAN` P3-2 owns the event recorder).
There is no `payload` to scrub yet, so the two tests pin the **flag** the
recorder will read and the one behaviour it already governs. When P3-2 lands,
the scrubbing assertion belongs in this file under this heading.

### S10 — "Rate limiting on `login.attempt` and `search` via `@adonisjs/limiter` when installed (config-off otherwise, documented)."

| Test ID | Asserts |
| --- | --- |
| `SEC-LIMIT-01` | Adonia ships **no** rate-limit configuration and requires no limiter peer — the "config-off otherwise" state, so a missing peer can never fail a boot |
| `SEC-LIMIT-02` | the two endpoints §19 names (`login.attempt`, `search`) exist in the route table under exactly those names, so the future integration cannot silently cover only one |

**Gap, stated plainly:** the `when installed` half is unimplemented. No plan row
owns `@adonisjs/limiter` integration today, and F2-1 is a test slice — writing
the integration is out of its scope. `SEC-LIMIT-01/02` assert the conformant
half (off, documented, both targets identified); they do **not** assert throttling,
because there is none to assert.

## Known gaps

1. **Rate limiting (S10)** — see above. Needs a plan row.
2. **Detail-record projection (S8 / `SEC-LEAK-08`)** — `serializeRecord` serializes
   the attribute bag it is handed. A resource whose model carries a secret column
   and whose detail query does not project it away will ship that column in
   `props.record`. The descriptor never names it and no field renders it, so §19's
   descriptor clause holds; the *record envelope* is a separate surface and the
   framework does not currently filter it. `SEC-LEAK-08` pins the current contract
   so a change is deliberate. A fix (honouring Lucid's `serializeAs: null`, or
   projecting `loadRecordFor` to the schema's attributes) is a follow-up.
3. **Action payload scrubbing (S9)** — Phase 3.

## Adding a test

1. Give it an ID in an existing family (`SEC-<AREA>-<nn>`) and put the ID at the
   start of the test title.
2. Add a row to the table for the §19 sentence it defends.
3. Run `pnpm --filter @adonia/core test`. `mapping.spec.ts` fails on an ID that
   exists in one place and not the other, in either direction.

Source: https://adonia.pages.dev/security-conformance/index.mdx
