Skip to content

Security: aktasozanberkcode/Mainframe-zSMF-Explorer

Security

docs/SECURITY.md

Security

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.


Authentication

  • Passwords are hashed with Argon2id (argon2-cffi defaults). 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, sub and sid are all required at decode time, and the token must be of type access.
  • Every request re-checks the referenced session: revoked, expired, or a user that has been deactivated all fail closed.

Refresh-token rotation

  • 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_reuse audit event. A stolen token cannot be replayed alongside the legitimate session.
  • POST /auth/logout revokes one session; POST /auth/logout-all revokes every session for the user. Changing a role or password revokes all sessions.

Lockout and rate limiting

  • 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=celery the limiter is Redis-backed and shared across API instances; if Redis is unreachable the limiter fails closed (503) rather than silently allowing unlimited attempts.

Tokens in the browser

  • The access token is held in memory only, never in localStorage or sessionStorage, 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 HttpOnly cookie, 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.

CSRF

Accepting a cookie makes the refresh and logout endpoints reachable by cross-site request forgery, so two independent defences apply:

  1. SameSite on the refresh cookie (lax by default). A cross-site POST does not carry it at all in the default single-origin deployment.
  2. A double-submit token. POST /auth/login and every rotation return a csrf_token and set a matching readable zsmf_csrf cookie. A cookie-driven refresh or logout must echo it in X-CSRF-Token, or the request is refused with 403. 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 and SameSite=None is 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.


First-run setup

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_SECRET and AUTO_CREATE_TABLES may 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 — ../../etc and 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_URL comes from the environment — the normal Docker and production path — the wizard refuses to replace it and says so;
  • the shipped development JWT_SECRET is 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).


Authorisation

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.

Input handling

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.

z/OS Data Gatherer connections

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.

Transport and headers

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.


Logging

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.


Audit log

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.


Production guards

ENVIRONMENT=production refuses to start unless:

  • JWT_SECRET is unique and at least 32 characters — the development default is rejected by name;
  • TASK_MODE=celery and DATABASE_URL is PostgreSQL;
  • ALLOW_INSECURE_TLS is off;
  • AUTO_CREATE_TABLES is off, so schema changes go through Alembic.

The service fails to start rather than running with an unsafe default.


Secrets

  • Provide secrets as environment variables or an orchestrator secret store. .env.example is a template of placeholders, and .env is git-ignored.
  • Change JWT_SECRET, POSTGRES_PASSWORD, OBJECT_STORAGE_SECRET_KEY and BOOTSTRAP_PASSWORD before any deployment.
  • Generate a JWT secret with python -c "import secrets; print(secrets.token_urlsafe(48))".
  • Rotating JWT_SECRET invalidates 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.


Dependency and code scanning

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.


Reporting a vulnerability

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.

There aren't any published security advisories