Skip to content

Security: vaultbase-sh/vaultbase

Security

SECURITY.md

Vaultbase Security Model

Trust boundaries

Vaultbase has three principal classes:

  1. Anonymous / unauthenticated — public records only (view_rule = null).
  2. User (auth-collection JWT) — gated by per-collection rule expressions.
  3. Admin (vaultbase_admin row) — bypasses all rule checks; can author hooks, custom routes, scheduled jobs, queue workers, view collections. Admins are trusted with code execution on the host process.

Treat the admin role as operator-equivalent. Compromise of any admin account is equivalent to root on the box.

Admin-authored code execution surfaces

Each of these compiles admin-supplied JS via new AsyncFunction("ctx", code) and runs it in the same process as the API server:

  • Hooks — vaultbase/src/core/hooks.ts — fired on record create/update/delete.
  • Custom routes — vaultbase/src/core/routes.ts — mounted under /api/custom/<path>.
  • Cron jobs — vaultbase/src/core/jobs.ts — scheduled execution.
  • Queue workers — vaultbase/src/core/queues.ts — pulled from the vaultbase_jobs_log queue.
  • View collections — vaultbase/src/core/collections.ts::createUserView — admin-supplied SQL backs a CREATE VIEW. The validateViewQuery guard rejects DDL/DML keywords and obvious abuse, but a determined admin can still read sensitive tables in their SELECT.

Each compiled function gets a helpers object including helpers.http / helpers.fetch (network egress, through the guard below) and helpers.query / helpers.db (database access with no rule checks).

Trust boundary

Code in hooks, custom routes, cron jobs and queue workers is trusted as an admin. It runs in the server process with the server's privileges and is not sandboxed:

  • helpers.query, helpers.find and helpers.db.* read and write every collection and system table directly; collection rules do not apply.
  • helpers.fs.* and helpers.os.env reach the server's files (database, .secret, uploads) and environment (VAULTBASE_JWT_SECRET, VAULTBASE_ENCRYPTION_KEY).
  • Plain JavaScript in the function body can do anything the runtime allows, including import() of Bun modules.

So creating or editing such code is equivalent to shell access on the host, and the admin accounts and mcp:admin / admin API tokens that can do it must be protected accordingly. The egress guard below limits accidental SSRF from hook code that fetches user-supplied URLs; it is not a sandbox for hostile code. Data a hook reads from a request (ctx.record, ctx.request) is untrusted: validate it before using it in helpers.db.exec SQL, file paths or outbound URLs.

helpers.http SSRF guard (since v0.1.4): every request runs through core/hook-egress.ts::assertEgressAllowed before fetch(). The default deny list blocks RFC1918 private space (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), loopback (127/8 + ::1), link-local (169.254/16, fe80::/10 — covers AWS / GCP / Azure metadata), and IPv6 unique-local (fc00::/7).

Operator overrides via Settings:

  • hooks.http.deny — comma-separated CIDR list replacing the default (opt-in to a thinner list when the default blocks legitimate internal services). Set to the literal "off" to disable filtering entirely.
  • hooks.http.allow — comma-separated CIDR list evaluated after deny. Punches a hole through (e.g.) 127.0.0.0/8 for a specific sidecar without disabling the rest of the loopback block.

The guard is defense-in-depth — it does not replace kernel-level egress filtering. For hardened deployments still run vaultbase under a network namespace / nftables egress filter as belt-and-braces. Known limitation: DNS rebinding (attacker controls the hostname's DNS, flips the answer between our resolve check and fetch()'s internal resolve). Use kernel-level filtering when you need bulletproof guarantees.

Admin-configured integration URLs are trusted. The SMTP host, S3 / storage endpoint, OAuth/OIDC endpoints (oauth2.* URLs, including a custom OIDC token and userinfo URL) and push-notification providers are set by admins and are not passed through the egress guard: they often point at internal services (Mailpit, MinIO, a corporate IdP) on purpose. Anyone who can change these settings can make the server connect anywhere and can redirect a stored secret (SMTP password, client secret) to a host they control, so the settings API is admin-only for that reason too. Webhooks (whose deliveries carry record data) and hook/route/job code do use the guard.

helpers.fetch goes through the same guard as helpers.http (it used to be raw fetch): every redirect hop is re-checked, only http:/https: are allowed, and credentials are dropped on a cross-origin redirect.

helpers.query reads through the records layer without collection rules (see the trust boundary above); it makes no network requests.

Token lifecycle

JWTs are HS256 signed with VAULTBASE_JWT_SECRET (or a generated <dataDir>/.secret fallback at mode 0600). Every token now carries:

  • iss = "vaultbase" (verified)
  • aud ∈ {admin, user, file}
  • jti (UUID) — checked against vaultbase_token_revocations on every verify
  • iat, exp

Verification path is centralized in core/sec.ts::verifyAuthToken and rechecks:

  • jti not in revocation list
  • principal row still exists (admin/user)
  • password_reset_at <= iat (admin)

POST /api/auth/logout revokes the bearer token's jti. Token rotation uses a sliding-refresh window via POST /api/auth/refresh.

To force-logout every admin (after suspected compromise), update password_reset_at to unixepoch() for that admin row; tokens issued before that timestamp will fail verification on next request.

Storage hardening

Local filesystem mode validates uploaded filenames as ^[uuid]\.[ext]{1,12}$ and resolves every path inside vaultbase_data/uploads. Path-traversal attempts are rejected at the storage layer (core/storage.ts::safeLocalPath).

Files served via GET /api/files/:filename always evaluate the parent record's view_rule regardless of whether the field is protected. Image MIME types render inline; everything else gets Content-Disposition: attachment plus X-Content-Type-Options: nosniff to neutralize stored-XSS via uploaded HTML/SVG.

CORS and origin gating

/realtime (WebSocket) and GET /api/realtime (SSE) reject upgrades whose Origin is not in the security.allowed_origins setting (comma-separated). Empty setting = same-origin only.

Cross-origin API consumers should set security.allowed_origins and supply a CORS plugin in front of Elysia (out of scope here).

Rate-limiting and proxy trust

Per-IP token-bucket rate limiting honors X-Forwarded-For only when the immediate peer is in the VAULTBASE_TRUSTED_PROXIES env (CIDR-equivalent, comma-separated). Otherwise the socket peer IP is used. Set this env when running behind Cloudflare, AWS ALB, nginx, etc. — leaving it unset means a hostile client cannot spoof XFF, but any proxy-derived IP also won't be honored.

Setup hardening

While no admin exists, POST /api/v1/admin/setup requires a setup key (X-Setup-Key header or setup_key in the body), so nobody who reaches a fresh install before the operator can claim it. The key is VAULTBASE_SETUP_KEY when set; otherwise the server generates one, stores it in <dataDir>/.setup-key (mode 0600) and prints it at startup. The file is deleted once the first admin exists. vaultbase setup-admin creates an admin locally without the key.

The setup endpoint also enforces a 12-character minimum password and atomically refuses if a concurrent setup landed first.

Dependencies of note

  • imagescript and @jsquash/{webp,avif} decode untrusted user image input. Upstream advisories should be monitored. Consider running thumbnail generation in a child process so a decoder OOM does not kill the API.
  • quill 2.0.x and monaco-editor 0.55.x ship custom decoration / hover APIs that have historical XSS risk; audit any custom integrations before exposing rich-text/code editing to non-admin users.
  • @types/bun is pinned to a SemVer range, not latest.

Hardening checklist for production

  • Set VAULTBASE_JWT_SECRET (don't rely on .secret fallback).
  • Create the first admin with vaultbase setup-admin, or with the setup key printed at first boot (or VAULTBASE_SETUP_KEY).
  • Set VAULTBASE_HOST=127.0.0.1 when a reverse proxy on the same host is the only way in.
  • Set VAULTBASE_TRUSTED_PROXIES to your front-door peer IPs.
  • Set security.allowed_origins for the admin SPA / user app origins.
  • Set VAULTBASE_ENCRYPTION_KEY (AES-GCM, 32 bytes base64) if any field type is encrypted.
  • Configure oauth2.<provider>.allowed_redirect_uris for every enabled IdP.
  • Front the binary with TLS termination that sets HSTS; the static landing/docs CF Pages projects already set Strict-Transport-Security.
  • Restrict vaultbase_data/ filesystem permissions to the running user (chmod 700).
  • Rotate the JWT secret after any suspected compromise: stop the server, delete .secret (or change the env), restart. All sessions end.

There aren't any published security advisories