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:
spawnProgramanddbMoveare never offered as job actions by default. On a cron schedule,spawnProgramwould 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-serveruses the@xeplr/base-apisauth gate unless you passauth: 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-handlerwith 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,AccessGuardand hidden menu items only shape the UI. The server’s middleware decides what an API returns.- Front-end hooks in
@xeplr/ui-factoryshape 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-apis’ createApp 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_KEYdecrypts every encrypted connection. Encrypt connections on the machine that uses them, and do not copy them from another install.AUTH_JWT_SECREThas an insecure default in code, and the sign-in banner shows✗ INSECURE DEFAULTwhile 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