Skip to content

Security conformance

Trace each security requirement to an executable test and its evidence.

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)
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.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close