Skip to content

Security

Maljan handles live malware and the credentials of the services that analyse it. This document describes how callers are authenticated, what a role may do, how secrets are stored, what leaves the system in an export, and how to report a vulnerability.

Authentication

Two credentials are accepted, and an explicit one wins.

  • JWT bearer — POST /api/v1/auth/login returns a short-lived access token and sets an HttpOnly refresh cookie (maljan_refresh, scoped to path /api/v1/auth, SameSite=Lax, Secure per COOKIE_SECURE). Send the access token as Authorization: Bearer <token>. POST /auth/refresh exchanges the cookie for a new pair, POST /auth/logout clears it. Refresh tokens are registered and consumed by jti, so a used one cannot be replayed. Access-token and refresh-token lifetimes are settings (jwt_access_token_expire_minutes, jwt_refresh_token_expire_days).
  • API key — X-API-Key. Keys are minted at POST /api/v1/audit/api-keys, stored only as a SHA-256 hash, and can carry an expiry. Unknown, revoked and expired keys are all refused with the same generic 401, so the header cannot be used as an oracle; last_used_at is stamped on every accepted call, which is how a stale or leaked key is spotted. A key is shown once, at creation.

If a request carries an X-API-Key header it is judged on that key rather than falling through to the bearer path.

Token signing and rotation. Tokens are signed with JWT_SECRET_KEY under JWT_ALGORITHM, carry the JWT_ISSUER/JWT_AUDIENCE claims, and are stamped with the kid in JWT_KEY_ID. To rotate, move the old secret to JWT_PREVIOUS_SECRET_KEY with its JWT_PREVIOUS_KEY_ID and set the new one; both are accepted while the grace period lasts, so a token minted before the rotation keeps working until it expires. Write the end of that period down in JWT_PREVIOUS_SECRET_NOT_AFTER: past that moment a token signed with the previous secret is refused, which is what stops a retired secret being honoured for the life of the deployment because nobody remembered to clear it. The setting is optional and a grace secret without one is accepted with no end, so an upgrade changes nothing for a rotation already under way; the startup check says so at every start until the moment is set or the previous pair is removed. GET /api/v1/system/status reports the rotation to an admin caller under jwt_grace_secret. The runbook is in deployment.md.

The development bypass. AUTH_DISABLED skips every token check and attributes each request to a seeded admin user. It is refused at startup whenever DEBUG is false, it is forced off under pytest, and the frontend's NEXT_PUBLIC_AUTH_DISABLED defaults to secure and is excluded from the Docker build context. Never enable it outside a trusted local environment.

WebSocket. /ws/analysis/{job_id} authenticates the same access token and refuses anything that is not one, closing with code 1008 rather than serving events. It reads the account the token names before it says anything about the job and refuses a missing or deactivated one, and it reads that account again every 60 seconds while it streams, so a deactivation closes the socket instead of waiting out the access token.

Roles

Two roles are enforced: admin and analyst (the role a registration gets). Admin gates the configuration surface — the settings schema, values, patches, resets, export and import — plus the audit log. API keys are not admin-gated: any signed-in user mints, lists and revokes only their own, and a key acts as the account that minted it. A refusal names the caller's actual role so the console can hide admin-only navigation rather than guess. There is no endpoint that grants admin; the first one is promoted in the database.

readonly is a third value of the role column and is enforced nowhere: no route distinguishes it from analyst, so an account holding it can upload a sample, submit an analysis job — spending LLM and sandbox budget — and cancel its own jobs. Read it as a label, not as a permission. It is recorded here rather than removed because a row already carrying the value would not load against an enum without it, and dropping the value silently would widen those accounts rather than narrow them; enforcing it is a behaviour change with a migration behind it, not a documentation fix. Do not hand out readonly expecting it to restrict anything: the boundary that does hold is admin against everyone else, and is_active against a closed account.

Secret storage

Secret settings are encrypted with Fernet under SETTINGS_ENCRYPTION_KEY and stored as enc:v1:<token>; the process refuses to start without a valid key, so there is no read-only fallback mode in which secrets sit in plaintext. A credential nested inside a composite setting — an MCP server's auth_token, a frontier arm's api_key — is stored in its own encrypted row rather than inside the composite, and a startup repair moves any that an older version left inline.

Stored secrets are never echoed back. Values are returned to the console as a mask plus a short hint, run summaries mask them, and DSNs are redacted before they are shown in the read-only Deployment group.

What an export leaves out

A JSON export is a file on an operator's disk, so it carries no credential at all: secret entries are skipped, masked values nested inside composites are stripped at any depth, and every value in an MCP server's env map is masked while its variable names remain. Each omission is listed in secrets_omitted, so the operator can see what must be re-entered on the other side. A mask that comes back on import means "keep the stored value", never "set the literal mask" — the failure mode that the old .env export had.

Transport and browser surface

  • CORS — CORS_ORIGINS, CORS_ALLOW_METHODS and CORS_ALLOW_HEADERS default to a local console. Credentials are allowed, so the origin list must be exact in production. X-API-Key is in the default header allowlist because a browser preflight would otherwise strip it.
  • Cookie — the refresh cookie is HttpOnly and path-scoped, and its Secure flag follows COOKIE_SECURE, which defaults to the inverse of DEBUG. A deployment that leaves it false outside debug is warned about at startup.
  • Security headers — SecurityHeadersMiddleware installs CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy on every response; HSTS is added outside debug.
  • OpenAPI — /docs, /redoc and /openapi.json are served only when DEBUG is true, so a production deployment publishes no schema.
  • Exposure — every compose port binds to BIND_ADDRESS, 127.0.0.1 by default. Widening it puts a malware-handling stack on the network; do it only behind a proxy or firewall you control.

Handling samples

Uploaded samples are live malware. They are stored in MinIO and mirrored into SAMPLES_DIR, which the Ghidra container mounts read-only. Keep both directories excluded from on-access scanners, off shared storage and off developer machines that are not meant to hold samples. Detonation happens in whichever sandbox is configured, never on the Maljan host.

What the VirusTotal server sends

The virustotal tool server is off until an operator registers an agent token, and what it sends once on depends entirely on which of its tools are ticked.

With the default tick list — get_file_report, get_url_report, get_domain_report, get_ip_report, get_analysis, get_submission — only indicators leave the host: a hash, a URL, a domain, an IP, an analysis or submission id. The sample itself never does. A hash lookup still tells VirusTotal that this deployment is interested in that file, which is itself worth thinking about for a targeted investigation.

Ticking submit_file changes the kind of disclosure, not the degree. The sample's bytes are uploaded to VirusTotal, where they are shared with VirusTotal's customers and partners under their own terms, and cannot be recalled. For a sample belonging to a client, a sample carrying customer data, or anything under an NDA, that is a decision to make deliberately and usually with the owner. This is why no default ticks it and why the setup guide says so next to the button.

The agent token itself is stored like every other tool-server credential: its own encrypted row under core.mcp.servers.virustotal.auth_token, never in the server map's JSON row, never echoed to the browser, and masked in an export.

Reporting a vulnerability

Use GitHub's private vulnerability reporting at https://github.com/Root0ne/Maljan/security/advisories/new. Do not open a public issue for an unfixed vulnerability. Include the version or commit, the configuration that reproduces it, and the impact you observed. Response targets, scope and supported versions are in the repository's SECURITY.md.