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
- Rate limiting (S10) — see above. Needs a plan row.
- Detail-record projection (S8 /
SEC-LEAK-08) —serializeRecordserializes 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 inprops.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-08pins the current contract so a change is deliberate. A fix (honouring Lucid’sserializeAs: null, or projectingloadRecordForto the schema’s attributes) is a follow-up. - Action payload scrubbing (S9) — Phase 3.
Adding a test
- Give it an ID in an existing family (
SEC-<AREA>-<nn>) and put the ID at the start of the test title. - Add a row to the table for the §19 sentence it defends.
- Run
pnpm --filter @adonia/core test.mapping.spec.tsfails on an ID that exists in one place and not the other, in either direction.