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/loginreturns a short-lived access token and sets an HttpOnly refresh cookie (maljan_refresh, scoped to path/api/v1/auth,SameSite=Lax,SecureperCOOKIE_SECURE). Send the access token asAuthorization: Bearer <token>.POST /auth/refreshexchanges the cookie for a new pair,POST /auth/logoutclears it. Refresh tokens are registered and consumed byjti, 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 atPOST /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_atis 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_METHODSandCORS_ALLOW_HEADERSdefault to a local console. Credentials are allowed, so the origin list must be exact in production.X-API-Keyis in the default header allowlist because a browser preflight would otherwise strip it. - Cookie — the refresh cookie is HttpOnly and path-scoped, and its
Secureflag followsCOOKIE_SECURE, which defaults to the inverse ofDEBUG. A deployment that leaves it false outside debug is warned about at startup. - Security headers —
SecurityHeadersMiddlewareinstalls CSP,X-Frame-Options,X-Content-Type-Options,Referrer-PolicyandPermissions-Policyon every response; HSTS is added outside debug. - OpenAPI —
/docs,/redocand/openapi.jsonare served only whenDEBUGis true, so a production deployment publishes no schema. - Exposure — every compose port binds to
BIND_ADDRESS,127.0.0.1by 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.