SMF data describes an organisation's production workload in detail: job names, user identifiers, dataset names, capacity and cost. Treat a zSMF Explorer installation as holding sensitive operational data.
- Passwords are hashed with Argon2id (
argon2-cffidefaults). Plaintext is never stored, logged or returned. - Password verification always runs the hasher — against a dummy hash when the user does not exist — so a missing account and a wrong password take comparable time and return an identical message.
- Access tokens are short-lived HS256 JWTs (
ACCESS_TOKEN_TTL, default 15 minutes) carrying issuer, audience, expiry and the refresh-session id.exp,iat,subandsidare all required at decode time, and the token must be of typeaccess. - Every request re-checks the referenced session: revoked, expired, or a user that has been deactivated all fail closed.
- Refresh tokens are 48-byte random values, stored only as SHA-256 digests.
- Each refresh revokes the presented token and issues a new one in the same family.
- Reuse detection: presenting an already-revoked token revokes the entire
family and records a
refresh_reuseaudit event. A stolen token cannot be replayed alongside the legitimate session. POST /auth/logoutrevokes one session;POST /auth/logout-allrevokes every session for the user. Changing a role or password revokes all sessions.
- Five consecutive failures set a 15-minute lock. The login response does not distinguish a lock from a wrong password.
- Rate limits are applied per client address for login (30/min) and refresh (60/min), and per user for uploads (120/min) and queries (120/min).
- With
TASK_MODE=celerythe limiter is Redis-backed and shared across API instances; if Redis is unreachable the limiter fails closed (503) rather than silently allowing unlimited attempts.
- The access token is held in memory only, never in
localStorageorsessionStorage, so injected script cannot read it and nothing is left behind on a shared machine. It expires in 15 minutes by default. - The refresh token is delivered as an
HttpOnlycookie, scoped to/api/v1/auth. JavaScript cannot read it at all, and it is not attached to any other API request. - On boot the app exchanges that cookie for a new access token, so reloading the page keeps the session without persisting a credential anywhere readable. The router waits for that exchange rather than treating a missing access token as "signed out".
- The response body still carries a refresh token for programmatic clients that have no cookie jar. Browsers ignore it; an automated test asserts that no value matching it reaches browser storage.
Accepting a cookie makes the refresh and logout endpoints reachable by cross-site request forgery, so two independent defences apply:
SameSiteon the refresh cookie (laxby default). A cross-site POST does not carry it at all in the default single-origin deployment.- A double-submit token.
POST /auth/loginand every rotation return acsrf_tokenand set a matching readablezsmf_csrfcookie. A cookie-driven refresh or logout must echo it inX-CSRF-Token, or the request is refused with403. A cross-site page can make the browser send a cookie but cannot read it, so it cannot produce the header. This is the defence that matters when the UI and API are on different origins andSameSite=Noneis required.
An explicit token in the request body skips the CSRF check, and safely so: forgery works by making the browser attach credentials it already holds, and an attacker cannot place a token it cannot read into a request body.
Failed refreshes clear both cookies, so a browser is never left retrying a
token that can never work again. SESSION_COOKIE_SECURE must be true in
production, and SameSite=None implies Secure because browsers reject it
otherwise.
A brand-new instance serves an unauthenticated setup wizard, because there is no account to authenticate with yet. It is bounded by one rule:
The setup endpoints work only while the users table is empty. The moment an
account exists, POST /setup/database and POST /setup/administrator return
409 and the UI redirects /setup to sign-in. Creating the first administrator
is checked again inside the transaction, so two concurrent requests cannot both
succeed.
Additional constraints:
- only
DATABASE_URL,JWT_SECRETandAUTO_CREATE_TABLESmay be written, and a value containing a line break is refused so one setting cannot inject another; - a SQLite path must resolve inside the application directory —
../../etcand absolute paths elsewhere are refused; - only SQLite and PostgreSQL are accepted, and a bare
postgresql://is pinned to the shipped driver, so no other SQLAlchemy dialect can be loaded; - PostgreSQL credentials are percent-encoded, so a password containing
@or/cannot redirect the connection to another host; - connection failures are reported with credentials stripped from the driver's message; the connection string itself is never returned by any endpoint;
- when
DATABASE_URLcomes from the environment — the normal Docker and production path — the wizard refuses to replace it and says so; - the shipped development
JWT_SECRETis replaced with a generated one, so a completed install never runs on a known signing key; - setup requests are rate-limited per client address.
Because the wizard is by definition unauthenticated, complete setup before
exposing a fresh install to an untrusted network. For unattended deployments
set BOOTSTRAP_USERNAME and BOOTSTRAP_PASSWORD instead: the first
administrator is then created at startup and the wizard is already disabled by
the time the service accepts requests. Both paths are audited
(setup_database, setup_administrator, bootstrap).
| Role | Permissions |
|---|---|
Admin |
Manage users and connections, delete datasets, read settings and the audit log, plus everything below |
Analyst |
Create datasets, upload, ingest, query, save views, export |
Viewer |
Read existing datasets, dashboards and records |
Roles are enforced server-side on every route; the UI hiding a control is a convenience, not the control.
Additional invariants:
- At least one active administrator must remain. Demoting or deactivating
the last one is refused (
409), with the admin rows locked in a consistent order to avoid a race between two concurrent requests. - Saved views and exports are scoped to their owner; an administrator may also see them.
- Uploads are scoped to their owner.
- Every dataset in a query must be authorised and
ready.
| Vector | Control |
|---|---|
| SQL injection | Users never supply SQL. Identifiers are pattern-checked and quoted; values are bound parameters. |
| Arbitrary file reads via DuckDB | Extension autoloading and autoinstall are disabled, so read_csv/read_parquet are unreachable. |
| Path traversal in identifiers | safe_component restricts dataset ids to [A-Za-z0-9][A-Za-z0-9_.-]{0,127}. |
| Path traversal in object keys | checked_key rejects absolute paths, .., backslashes and colons; resolved paths must stay under the storage root. |
| Path traversal in filenames | An uploaded filename must be a basename. Stored files are named by generated identifier, never by client input. |
| Zip slip | Archive member names are rejected if absolute, containing .., or containing a backslash or colon. |
| Decompression bombs | A shared expansion budget bounds total expanded bytes and the expansion ratio; the ZIP footer is inspected before the member table is allocated; member count and declared expanded size are capped; encrypted archives are refused. |
| Upload size | MAX_UPLOAD_SIZE per file, enforced when the upload is created and while chunks are appended. |
| Oversized responses | Data Gatherer reads are capped at 32 MiB; query results are capped by QUERY_ROW_LIMIT; hex previews are capped at 64 KiB. |
| Untrusted binary content | Uploaded data is parsed as data. It is never executed, deserialised into objects, or used to build a path. |
| Formula injection in exports | CSV values beginning =, +, @ or - are prefixed with an apostrophe so a spreadsheet does not execute them. |
| Request validation | Pydantic models with extra="forbid" and explicit bounds on every list, string and number. |
Optional, and locked down:
- the endpoint must be HTTPS, with no embedded credentials, query or
fragment, and its path must end with
/zosmf/zosdg/smf; - the hostname must appear in
CONNECTION_ALLOWED_HOSTS, which prevents the server being used to probe arbitrary internal hosts; - TLS verification is on by default; an enterprise CA certificate can be supplied per connection;
- disabling verification additionally requires
ALLOW_INSECURE_TLS, which is refused outright in production, and the UI shows an explicit warning; trust_env=False, so an ambient proxy or certificate environment variable cannot redirect the connection, and redirects are not followed;- dataset names are validated against z/OS naming rules;
- passwords are supplied per request and never stored. They are not written to the database, not logged, and not returned by any endpoint.
Every API response carries:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Cache-Control: no-store
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Request-ID: <uuid>
Strict-Transport-Security: max-age=31536000; includeSubDomains (production only)
The bundled nginx configuration adds a restrictive Content-Security-Policy
(default-src 'self', object-src 'none', frame-ancestors 'none').
CORS is an explicit allow-list (CORS_ORIGINS) with credentials disabled;
tokens travel in the Authorization header, not in cookies, so there is no
CSRF surface on the API. In the default deployment nginx serves the UI and
proxies /api, making the browser same-origin.
Structured JSON logs carry the request id, method, route, status and duration.
Never logged: passwords, access tokens, refresh tokens, z/OS credentials,
raw Authorization headers. httpx and httpcore are explicitly quieted so an
upstream library cannot print a request with credentials.
Error responses are deliberately generic. Internal exception text can contain
local paths, so ingestion failures surface as
Ingestion failed (<ExceptionType>); inspect dataset diagnostics or source framing, with detail kept in dataset diagnostics.
Endpoints do not return filesystem paths. The dataset listing carries no
storage_key.
Append-oriented, administrator-readable, covering: login, login_failed,
refresh_reuse, bootstrap, user_create, user_update, dataset_create,
dataset_delete_requested, dataset_delete, ingestion_complete,
connection_create, connection_update, connection_delete,
connection_test, connection_read, query, export, saved_analysis,
saved_analysis_delete.
Query events record duration, dataset ids and row counts — never the credentials of the user, and never a host password.
ENVIRONMENT=production refuses to start unless:
JWT_SECRETis unique and at least 32 characters — the development default is rejected by name;TASK_MODE=celeryandDATABASE_URLis PostgreSQL;ALLOW_INSECURE_TLSis off;AUTO_CREATE_TABLESis off, so schema changes go through Alembic.
The service fails to start rather than running with an unsafe default.
- Provide secrets as environment variables or an orchestrator secret store.
.env.exampleis a template of placeholders, and.envis git-ignored. - Change
JWT_SECRET,POSTGRES_PASSWORD,OBJECT_STORAGE_SECRET_KEYandBOOTSTRAP_PASSWORDbefore any deployment. - Generate a JWT secret with
python -c "import secrets; print(secrets.token_urlsafe(48))". - Rotating
JWT_SECRETinvalidates every access token; sessions recover on the next refresh, or users sign in again.
BOOTSTRAP_PASSWORD creates the first administrator only while the users
table is empty. Because passwords are never logged, this is the one place a
first credential can be supplied; change it immediately after first sign-in.
CI runs pip-audit --strict and npm audit --audit-level=high on every push
and pull request, alongside ruff, mypy, eslint, tsc, the unit suites and
the end-to-end flow.
Report privately to the repository owner rather than opening a public issue. Include the affected endpoint or component, a reproduction, and the impact you believe it has.