Security model
cartapel is designed to sit in front of a production database, so its defaults are conservative: it never writes to your database on its own, every value is a bound parameter, and reads that could leak data are gated by roles.
Secret key
cartapel signs its session cookies with an app secret. It is required — cartapel refuses to start without one.
Resolution order at startup:
CARTAPEL_SECRET_KEYenvironment variable.[cartapel].secret_keyinconfig/cartapel.hcl(env-interpolated, e.g.secret_key = "env:CARTAPEL_SECRET_KEY").
Prefer the environment variable or ${...} interpolation — never commit a literal key. Rotating the key invalidates all existing sessions (everyone re-logs-in once).
export CARTAPEL_SECRET_KEY="$(openssl rand -hex 32)"Sessions & cookies
- The session cookie (
cartapel_session) isHttpOnly,SameSite=Laxand (by default)Secure, with a 30-day lifetime. Sessions live in cartapel's SQLite store; logout deletes the server-side session. - Its value is HMAC-SHA256-signed with the secret key. The signature is verified in constant time before any database session lookup, so a tampered or unsigned cookie is treated as no session.
- Every API route except login and
/healthrequires a valid session (returns 401 otherwise). Withpublic_roleconfigured, a sessionless request acts as that role instead — see Roles & permissions. - Behind HTTPS, keep
--secure-cookieson (the default). For local plain-HTTP development, pass--secure-cookies=false.
CSRF
Every mutating request (POST / PUT / PATCH / DELETE) must carry the X-Cartapel: 1 header — a custom header a cross-site form cannot set. The bundled SPA sends it automatically; the api object injected into custom widgets and pages sends it for you too.
Authentication
- Passwords are hashed with argon2id.
- Login is rate-limited per IP: 10 failed attempts in a 15-minute window, then 429 until the window expires.
Column masking
A column can be masked three ways, and all are protected end to end:
- Automatically — a column whose name looks secret-shaped (contains
token,secret,password,passwd,api_key,apikeyorprivate_key) is masked by default for everyone, admins included. Declaring afieldblock on the column hands control back to you. - Per field —
masked = truein a table config. The author's call — it also binds admins. - Per role —
maskedinconfig/auth.hcl, hiding the column from that role only. (With several roles, it holds only when every view-granting role masks it.)
Whichever way a column is masked:
- Its value is returned pre-masked (e.g.
a3f…), never in the clear. - It is skipped in global search and in the record title.
- It is rejected as a sort key and as a filter key.
- It is masked in CSV/JSON exports.
Use it for tokens, wallets, secrets and PII you want visible to some roles but not others.
Row-level filters
A role's row_filter predicate is ANDed into the WHERE clause of every query that touches the table — list, count, search, and the WHERE of bulk updates and imports. A scoped user can never read or write a row outside their filter. When a filter, search or row-filter applies, count(*) is still computed exactly. With several roles, per-role filters OR together — a view-granting role with no filter lifts the restriction for that table. See Roles & permissions.
SQL safety
- Every SQL identifier (table, column) is validated against the introspected schema. An unknown column is a 400; a masked column used where it may not be is a 403. No request string is ever interpolated as an identifier.
- Every value is a bound parameter cast to the column's real Postgres type.
- Raw-SQL fragments (
filter_defpredicates, computed-columnsql,row_filter, dashboard and named-query SQL) exist only in your config files — which live in your repo and are trusted like code, not accepted from the browser. - Dashboard and named-query SQL run in
READ ONLYtransactions with a statement timeout, so a widget can never mutate data or run away.
The admin gate
Access management, config editing, config-version history, the discover flow and the audit log are admin-only — every one of those endpoints returns 403 for a non-admin caller.
Static-asset path confinement
Custom widget and page assets are served from the config bundle at {base}/static/*, but only safely:
- Directory traversal (
..), backslashes, empty path segments and dotfile segments are rejected up front; the resolved path is then canonicalized and must stay inside the canonical config dir, so out-of-tree symlinks fail too. - Only an extension allowlist is served:
js,mjs,css,svg,png,webp,jpg,jpeg,gif,ico— plusts/tsx, served as plain text for the page-module loader. - Config and secret material (
.hcl,.toml,.env,.ini, dotfiles) is never served.
Config-write paths apply the same discipline: they reject /, \, .. and the reserved stems (config, _group, page, queries).
Webhook actions
A kind = "webhook" action proxies the selected primary keys to a URL you configure — an escape hatch into your real backend rather than a direct DB write. When the CARTAPEL_WEBHOOK_SECRET env var is set, each outbound request carries an X-Cartapel-Signature header — the hex HMAC-SHA256 of the exact JSON body — for your backend to verify. Webhooks can be disabled outright with disable_webhooks — see Hardening toggles.
Audit log
Every write — create, update, delete, bulk action, config change, user/role change — is recorded in cartapel's SQLite audit log with actor, timestamp and, for row edits, a before/after diff. Per-record history is available on each detail view; the full log is an admin-only view.
One-click revert
An audited field edit (an update, or a previous revert) can be reverted in one click from the record's history or the audit log. Guardrails:
- Admin-only — and the revert still passes through the normal update path, so table permissions, row filters and masking all apply.
- Refused on drift (409) — if any affected column has changed since the audited edit, the revert is rejected with a conflict. Staleness is enforced inside the
UPDATE's ownWHEREclause, never check-then-act. - A revert is itself audited as a
revertentry with its own diff — so it can be reverted in turn, and the UI offers an immediate undo toast.
Hardening toggles
Two opt-in switches in config/cartapel.hcl narrow the surface further:
disable_sql_preview = true— disables the dashboard builder's ad-hoc SQL preview, blocking arbitrary read-SQL even for admins.disable_webhooks = true— disables outbound webhook actions entirely (an SSRF surface).
Both default to false. See the globals table in the configuration overview.