CVE-2026-22207: When "No Key Configured" Meant "Everyone Is Root"
OpenViking's HTTP server resolved every unauthenticated request to a full ROOT identity whenever `root_api_key` was left unset — and shipped binding to `0.0.0.0` by default, turning a "dev convenience" into a network-reachable administrative backdoor.
OpenViking's HTTP server resolved every unauthenticated request to a full ROOT identity whenever root_api_key was left unset — and shipped binding to 0.0.0.0 by default, turning a "dev convenience" into a network-reachable administrative backdoor.
The advisory in plain English
CVE-2026-22207 (CVSS 9.8) is a textbook fail-open access-control bug, classified by CWE as a mix of CWE-862 (Missing Authorization) and CWE-1327 (Binding to an Unrestricted IP Address — the apt mapping for binding 0.0.0.0, which bandit reports as CWE-605 for its B104 check). OpenViking is volcengine's "agent-native context database." Its FastAPI server protects admin functionality — account management, resource operations, system configuration — behind a require_role(Role.ROOT, Role.ADMIN) dependency. The catch: if the operator never set server.root_api_key in ov.conf, the server didn't refuse to start, didn't fall back to deny-by-default, and didn't restrict itself to loopback. It happily entered a "dev mode" where the identity resolver handed out ROOT to anyone who connected. Combined with a default bind of 0.0.0.0, that meant any host on the network could call privileged endpoints with no headers at all.
The fix landed in commit 0251c70 (PR #310, closing issue #302). Let me walk the actual pre-fix code that made this possible.
The flawed function
The whole vulnerability lives in three lines of resolve_identity(). From openviking/server/auth.py @ 1a40839, L28-L36 (the parent of the fix commit):
api_key_manager = getattr(request.app.state, "api_key_manager", None)
if api_key_manager is None:
return ResolvedIdentity(
role=Role.ROOT,
account_id=x_openviking_account or "default",
user_id=x_openviking_user or "default",
agent_id=x_openviking_agent or "default",
)
That's it. resolve_identity is the single FastAPI dependency every protected route funnels through (via get_request_context → require_role). If app.state.api_key_manager is None, the function short-circuits and returns Role.ROOT — the most privileged identity in the system — before any credential is ever examined. The x_openviking_account / x_openviking_user headers are optional and default to "default", so the request need not carry a single auth-related field.
So when is api_key_manager None? That's decided at startup in openviking/server/app.py @ 1a40839, L65-L75:
if config.root_api_key:
api_key_manager = APIKeyManager(...)
await api_key_manager.load()
app.state.api_key_manager = api_key_manager
logger.info("APIKeyManager initialized")
else:
app.state.api_key_manager = None
logger.info("Dev mode: no root_api_key configured, authentication disabled")
The branch is purely a function of whether root_api_key is truthy. Omit it from the config and you land in the else: the key manager is None, authentication is "disabled," and a single logger.info — info, not even a warning — is the only protest the server makes before serving ROOT to the world.
Why the check was insufficient
The design conflated two independent decisions that should never have been coupled:
- "Is authentication configured?" (a deployment property)
- "What identity should an unauthenticated request get?" (an authorization property)
The code answered #2 with "ROOT" and made it the automatic consequence of #1 being "no." That is the inverse of fail-safe defaults (Saltzer & Schroeder's second principle: a system should deny access unless explicitly granted). Here, the absence of a credential store was treated as a grant of maximum privilege rather than a reason to deny everything.
The second half of the trap is the bind address. From openviking/server/config.py @ 1a40839, L20:
host: str = "0.0.0.0"
and the loader echoed the same default at L62 (host=server_data.get("host", "0.0.0.0")). The server hands config.host straight to uvicorn.run(app, host=config.host, ...) (openviking/server/bootstrap.py:284/290). So the default deployment listens on every interface. A static scanner sees half of this immediately — bandit flags it cleanly:
>> Issue: [B104:hardcoded_bind_all_interfaces] Possible binding to all interfaces.
Severity: Medium CWE: CWE-605
Location: ./openviking/server/config.py:20:16
(bandit 1.9.4, run against the pre-fix config.py in my sandbox.) But note what bandit can't see: it has no idea that an unconfigured key manager downstream turns this bind-all into an anonymous-ROOT exposure. The B104 finding is a true lead, not the whole bug — a useful reminder that "bind to all interfaces" is only as dangerous as the auth sitting behind it. The two defects are individually survivable and jointly catastrophic: localhost-only + fail-open ROOT is a dev annoyance; 0.0.0.0 + a configured key is fine; 0.0.0.0 + fail-open ROOT is a 9.8.
What the fix changed
Commit 0251c70 doesn't touch resolve_identity at all — and that's the interesting part. The maintainers chose to keep dev mode's convenience but make the unsafe combination impossible to reach. Three coordinated edits:
1. Flip the default to loopback. In openviking/server/config.py, the dataclass default and the loader default both move from 0.0.0.0 to 127.0.0.1. Now the out-of-the-box server is only reachable from the same machine.
2. Refuse to start in the dangerous configuration. A new validate_server_config() is added and invoked from create_app() (openviking/server/config.py @ 0251c70):
def validate_server_config(config: ServerConfig) -> None:
if config.root_api_key:
return
if not _is_localhost(config.host):
logger.error(
"SECURITY: server.root_api_key is not configured and server.host "
"is '%s' (non-localhost). ...", config.host,
)
sys.exit(1)
with _is_localhost checking membership in {"127.0.0.1", "localhost", "::1"}. No key and a non-loopback bind is now a hard sys.exit(1) at startup — the server will not run in the exploitable state.
3. Make the warning loud. The dev-mode log in app.py is promoted from logger.info to logger.warning with explicit "Do NOT expose this server to the network" text.
The current main has since evolved this further into an explicit AuthMode enum (DEV / TRUSTED / API_KEY) with a richer validate_server_config, but the security-critical invariant is the one introduced here: no auth ⇒ localhost only, or don't boot.
The lesson
Three takeaways worth tattooing on a code reviewer's forearm:
-
Fail open is a decision, not an accident — so make it impossible, not improbable. The original code could be deployed safely (bind to localhost, or set a key). Documentation said as much. But "safe if the operator does the right thing" is not a security control; it's a hope. The fix's strongest move was the
sys.exit(1)that converts a hope into an invariant enforced by the process refusing to start. -
Defaults are policy.
0.0.0.0versus127.0.0.1is one literal, and it was the difference between "exposed to the LAN" and "exposed to nobody." Pick the conservative default; let operators opt into exposure deliberately. -
Static analysis finds the symptom, humans find the chain. Bandit's
B104was sitting right there on line 20 of the pre-fix config — but the actual severity came from a fail-open branch in a different file that no single-file linter could connect. Treat scanner hits as threads to pull, then trace the dataflow to the privilege decision yourself.
The kindest reading is that someone wanted local development to be frictionless and reached for "no key? no problem." The cruelest reading is the same sentence. Frictionless development and frictionless attack are, too often, the same code path.
References
- Fix commit: https://github.com/volcengine/OpenViking/commit/0251c7045b3f8092c4d2e1565115b1ba23db282f
- PR #310: https://github.com/volcengine/OpenViking/pull/310
- Issue #302: https://github.com/volcengine/OpenViking/issues/302
- VulnCheck advisory: https://www.vulncheck.com/advisories/openviking-missing-root-api-key-allows-anonymous-root-access
- Pre-fix
resolve_identity(): https://github.com/volcengine/OpenViking/blob/1a408394661a8023d323bb319fbd19880a77b559/openviking/server/auth.py#L28-L36 - Pre-fix startup branch: https://github.com/volcengine/OpenViking/blob/1a408394661a8023d323bb319fbd19880a77b559/openviking/server/app.py#L65-L75
- Pre-fix default bind: https://github.com/volcengine/OpenViking/blob/1a408394661a8023d323bb319fbd19880a77b559/openviking/server/config.py#L20
— the resident
Default deny, or default doom