Corvid is a single-user, self-hosted tool aimed at security analysts, so it's reasonable to expect it to hold itself to a decent security bar. This document explains how to report a problem, and keeps a record of the notable issues that have already been found and fixed.
Corvid doesn't maintain long-term release branches — only the latest commit on main receives
security fixes. If you're running an older checkout, update before reporting an issue you
haven't reproduced against current main.
Please don't open a public GitHub issue for anything that looks exploitable.
Instead, report it privately:
- Preferred: GitHub Security Advisories for this repository.
- Alternative: email z0rats.alex@gmail.com with a description of the issue, steps to reproduce, and its potential impact.
This is a solo-maintained project, so response times aren't guaranteed, but reports are taken seriously and triaged as they come in. If a fix is warranted, credit is given in the fix's commit message and, for anything significant, in the section below — unless you'd rather stay anonymous.
Things worth reporting: authentication/authorization bypass, SSRF (this app fetches
attacker-influenceable URLs server-side in several places — see ssrf_guard.py), secret/API-key
disclosure, injection (SQL, template, command), path traversal, and anything that lets one
Corvid instance be used to attack infrastructure the operator doesn't control.
Generally out of scope: issues that only reproduce with
SECURITY_ALLOW_PRIVATE_NETWORK_TARGETS=true (an explicit, documented opt-out meant for
dev/testing only), vulnerabilities in the third-party OSINT libraries Corvid shells out to or
vendors (maigret, social-analyzer, mailcat-osint, etc. — please report those upstream),
and denial-of-service reports against a tool that's meant to run on infrastructure you already
control.
Corvid started as a fork with no dedicated security review, so a pass was done to find and close the gaps below. None of these had a public advisory filed (they were caught internally rather than reported), but they're recorded here for transparency since they affected real deployments.
Every /api/* route, plus /docs and /openapi.json, was reachable by anyone who could reach
the container's port — there was no login and no token check anywhere in the request path. On a
tool that stores third-party API keys and investigation history, that's a straight path to
reading or triggering lookups on someone else's behalf. Fixed by gating every router behind a
single shared bearer token (core/security/access_control.py), generated on first run and
required on every request thereafter; only the health check and the WebSocket handshake (which
authenticates itself separately, since browsers can't set a custom header during a WS upgrade)
are exempt.
Third-party provider keys (VirusTotal, Shodan, etc.) were written to the database exactly as
entered. Anyone with read access to the SQLite file — a backup, a misconfigured volume mount,
another process on the host — got every configured key for free. Fixed with transparent
Fernet encryption at the SQLAlchemy column level (EncryptedString in
api_keys_settings_models.py); the encryption key itself is generated once and persisted with
restrictive file permissions, separate from the database.
The original guard resolved and validated a URL's hostname once, up front, then handed the
original URL to httpx with redirect-following left on. Two gaps followed from that: a
malicious server could return a 3xx pointing at an internal address and the client would
happily follow it without re-checking, and even the initial request wasn't pinned to the IP that
was actually validated, leaving a window for DNS rebinding between the check and the connect.
Fixed by pinning every request to its validated IP and manually walking redirects
one hop at a time, re-validating the target on each hop instead of trusting httpx's built-in
redirect handling.
Several lookup providers (Hunter.io, Pulsedive, Shodan, Google Safe Browsing, IPQualityScore)
put their API key directly in the request URL rather than a header. A broad
except Exception: logger.error(..., str(e)) around any of those calls would render the full
URL — key included — straight into data/logs/*.log. Fixed with a logging filter
(log_redaction.py) that pattern-matches and scrubs key-shaped substrings out of every log
record before it's written, regardless of which provider or code path produced it.
Swagger UI and the raw OpenAPI schema were only ever gated by ENVIRONMENT != "production",
not by anything resembling a credential — in any non-production deployment (including one
exposed by mistake) they were open to whoever found the URL, handing over the full internal
route map. Fixed by disabling FastAPI's built-in docs routes unconditionally and re-registering
equivalents that sit behind the same access-token dependency as everything else.
The backend image had no USER directive, so the app process — including code that parses
untrusted file uploads (.eml files) and shells out to third-party OSINT CLIs — ran as root
inside its container. Fixed by adding a dedicated non-root user; the entrypoint briefly starts
as root only long enough to fix ownership of the bind-mounted data directory, then drops
privileges before exec-ing into uvicorn.
The alerts WebSocket checked an optional API_WS_SECRET_TOKEN that defaulted to empty, meaning
auth was silently disabled unless an operator went out of their way to set it — an easy default
to miss, and inconsistent with the rest of the API. Fixed by requiring the same mandatory access
token as every other route, checked via the same comparison path.