Security

The security rules that apply across Xeplr OS packages and Xeplr Analytics, and the reason for each.

This page collects the rules documented in the package READMEs. Each one exists because the alternative fails quietly: it returns no error, logs nothing, and usually looks like a healthy system.

REDIS_PREFIX is required and unique per app

Every key @xeplr/auth writes to Redis goes under REDIS_PREFIX. That includes sessions, the access-token whitelist, SSE tickets and the access cache. @xeplr/utils falls back to xeplr: when the prefix is unset, so two apps on one Redis would read and write the same keys. Any developer machine running more than one app is in that situation.

Most keys contain a token or user id and never collide. The access cache does collide, because its keys are named by content (access:public, access:api:<name>). One app fills them from its database and the other serves them as its own:

  • the drawer shows another product’s menus and drops its own;
  • an API the other app never registered is cached as open to everyone, so a guarded route answers any signed-in caller for up to ten minutes, with no error and no log line.

So @xeplr/auth refuses to start when REDIS_PREFIX is unset, blank, or exactly xeplr:. It refuses xeplr: even when written out, because that is exactly what an env file copied from another project carries.

REDIS_PREFIX=myapp:        # one value per app, the same in every process of that app

Read-only connections for SQL you did not write

Any query whose SQL text came from outside your own code needs a read-only connection. That includes report previews and user queries.

const pool = await driver.connect({ ...connection, readOnly: true })
Driver What readOnly does in @xeplr/actions
PostgreSQL every query runs inside BEGIN TRANSACTION READ ONLY, always rolled back, over the extended protocol. That protocol allows one statement only, so …; COMMIT; DROP TABLE t is refused, and nothing inside can switch the transaction back to read-write
MySQL every connection the pool opens is set to TRANSACTION READ ONLY, and multipleStatements stays off
SQL Server readOnlyIntent, which routes to a readable secondary where one exists. This is not enforcement. SQL Server runs whole batches and has no session read-only mode
DuckDB read-only unless the call says access: 'rw'. No environment variable can grant write

On SQL Server, give such SQL a login with only db_datareader. Xeplr Analytics connects read-only for previews, samples, the report grid and report and dashboard aggregation. Its source logins on SQL Server must hold only db_datareader, because the login is the only thing that stops a write there.

Values are bound, never written into SQL. Procedure parameter names are written into the call, so they must be plain names (letters, digits and _).

Dataset SQL is compiled strictly

Xeplr Analytics compiles saved datasets, reports and dashboards on the server from their ids. The browser sends ids and filter values, never SQL text for a saved document. Filter values for filters the document does not declare are discarded. A client that could post SQL could read any table on the connection.

When the server compiles a dataset, it runs in strict mode with the connection’s dialect:

What goes into the statement How it is made safe
filter and ranking values escaped for the dialect: quotes doubled, backslashes also doubled on MySQL, NUL refused
operators, join types, sort directions, window functions taken from fixed lists. An operator off the list throws Unsupported filter operator
table, alias and column names, function names outside the catalog letters, digits, _ and $ only, with dots between schema parts
formula text (custom formulas, formula joins and filters) not validated. Its author is trusted to write it, which is why the connection that runs it should be read-only

An unknown operator is refused, not skipped. Operators are written into SQL, and saved configuration arrives through an API, so = 1 OR 1=1 -- would otherwise be stored SQL injection. A skipped filter would also return more rows than were asked for.

Connections travel as ids everywhere (moves, models, jobs, uploads). Credentials are resolved on the server and decrypted only at the point of use, so a caller cannot point work at a database they were not granted.

Job triggers may override only the window

POST /jobs/:id/trigger and POST /jobs/run accept inputs, but those inputs may replace only the keys in overridableInputs, which defaults to ['window']. Any other key gets a 400 that names it. sql, where, table, procedure, connections and the rest stay as the job was saved.

A job’s inputs include its SQL and its connection. If any caller of /trigger could replace them, “run this job” would become “run this SQL there”. Server code calling executeJob directly is not limited.

Related rules in @xeplr/jobs:

  • spawnProgram and dbMove are never offered as job actions by default. On a cron schedule, spawnProgram would be a remote shell.
  • Jobs store saved connection ids, never credentials. Job inputs reach the browser and are copied into every occurrence row.
  • Tenancy must be registered in the process that runs jobs. Without it, the tenant filter does nothing and every tenant’s jobs are visible to every other.
  • xeplr-jobs-server uses the @xeplr/base-apis auth gate unless you pass auth: false. It exposes “run this action now”, so leaving it unauthenticated would let anyone run registered actions.

Factory routes are access-guarded

Every @xeplr/factory route is a row in the auth apis catalog (factory:view, factory:write, factory:design). With factory.router({ access: true }), each route answers only a caller whose req.access.apis names it. A request with no req.access at all is refused, because that means the router was mounted where no auth gate ran.

  • A request names a screen, never a table or a column. Table and column names come only from a published screen and are checked against the table’s real columns before any query.
  • Values go through @xeplr/schema-handler with the screen’s own rules, so the server accepts exactly what the form does.
  • Keys the UI sends that are not fields of the screen are always dropped, so a request cannot write a column the form does not show.
  • Access is per route, not yet per screen or table. Whoever may save records may save them on any published screen.
  • In an app made by @xeplr/cli, designing, creating forms and publishing are Super Admin only, because publishing changes database tables.

Unregistered APIs are open

@xeplr/auth’s accessMiddleware treats an API that is not in the apis catalog, or that has no roles mapped, as open. Register every route that must be guarded. Most /auth/api/admin routes require only a valid token, so put accessMiddleware() or requireRole() in front of them. The menu-items routes are the exception and check for Super Admin themselves.

Tenant headers are trusted by @xeplr/db’s mtMiddleware. mtMembershipMiddleware is what checks that the caller belongs to the company or workspace a header names.

The browser is not a security boundary

  • ProtectedRoute, AccessGuard and hidden menu items only shape the UI. The server’s middleware decides what an API returns.
  • Front-end hooks in @xeplr/ui-factory shape what the person sees and sends. Anything that must hold goes in server hooks.
  • SQL generation runs on the server for exactly this reason. A browser cannot enforce authorisation.

The auth gate fails closed

@xeplr/base-apiscreateApp validates every non-public request against the sign-in service (AUTH_URL), and refuses to boot without an auth decision. If the sign-in service is down, nothing is served: an API answers 503 instead of serving unauthenticated. An address you want open is named explicitly, so the whole API cannot be left public by forgetting a step.

Nothing is guessed

Database names, connections and storage paths have no defaults. A missing value stops the process at startup and names the setting. A wrong-but-present value would migrate, serve traffic and read as empty data, so each embedded product gets its own database (DB_JOBS, DB_WORKFLOW) with no fallback. The same rule keeps one product’s rows out of another’s.

Secrets:

  • ENCRYPTION_KEY decrypts every encrypted connection. Encrypt connections on the machine that uses them, and do not copy them from another install.
  • AUTH_JWT_SECRET has an insecure default in code, and the sign-in banner shows ✗ INSECURE DEFAULT while it is unset.
  • Keep env files at mode 0600 and out of git.

Email is checked at start

The sign-in service tests email at startup and shows the result in its banner: ✓ smtp — host:port, connected or ✗ NOT WORKING — reason. A failure is never fatal: sign-in, refresh and everything behind a token keep working. Anything that sends a link does not: registration answers 500 and leaves an unactivated account behind, invites throw, and forgot-password answers 500 for an address that exists.

Last updated 16 September 2026