Reproduction steps:
- routes/dispatch_integration.py's _handle_integration_save (bound to the
legacy POST /api/integration route, "single-key BYOK save... writes
keys.local.json" per the file's own module docstring) takes id from the
request body with only a non-empty-string check:
line 265: iid = body.get("id")
line 266: if not iid or not isinstance(iid, str): return 400
and, when a bare key (or fields) is supplied instead of a per-field
field/value pair:
line 287-293:
has = bool(key) or bool(fields)
keys = jload(keypath, {}) # keypath = WS/keys.local.json
if has:
keys[iid] = fields if fields else key
else:
keys.pop(iid, None)
save_secret(keypath, keys)
No allowlist (_VAULT_ALLOWLIST_BUILTIN/_known_provider_ids), no
is_reserved_vault_key check, no "::" namespace check — unlike
routes/vault.py's endpoint, this one applies NO provider validation at all,
not even the (bypassable) module-manifest-merged one finding #1 exploited.
- Any authenticated session (same low bar as every other endpoint in this
class — _require_session() only, also reachable via CSRF on the loopback
server per the platform's established pattern) POSTs:
POST /api/integration {"id":"_railcall_signing_seed",
"key":"<attacker-chosen hex>"}
- Run repro_integration_save_reserved_seed_hijack_v131.py against a clean
v1.3.1 extraction. It drives the REAL, unmodified
routes.dispatch_integration._handle_integration_save. Output:
response: (200, {'ok': True, 'prompt_community': False})
keys.local.json contents: {'_railcall_signing_seed': 'ATTACKER_FORGED_SEED_HEX_VALUE'}
CONFIRMED
Expected:
Same guarantee as finding #1: the signing seed is documented as "redacted/
filtered everywhere" (railcall_signing.py) and must never be reachable through
ANY operator-facing credential-save endpoint, however it names its target.
Actual:
_handle_integration_save writes directly to keys.local.json[iid] with zero
provider validation of any kind. It doesn't even need finding #1's
"install a module that declares the reserved provider" step — any session
holder can name the reserved slot directly in the request body. Fixing
finding #1 alone (adding an is_reserved_vault_key check to routes/vault.py
and/or filtering module-declared providers) does NOT close this endpoint —
/api/integration is a wholly separate code path that never consults
vault.py's allowlist logic, writing keys.local.json directly. Combined with
finding #1, at least two independent, session-gated-only endpoints reach full
permanent signing-identity takeover (the same "next restart republishes
signing_pubkey.json to match the attacker's seed, forged signatures then
verify" mechanics apply identically once this slot is overwritten).
Suggested fix:
Apply the same guard finding #1 needs, here too — reject before the write:
from railcall_signing import is_reserved_vault_key
if is_reserved_vault_key(iid):
return handler._send(200, {"ok": False, "error": "reserved provider"})
at the top of _handle_integration_save (and its sibling _handle_credential_save
at line 105, which has the identical missing-allowlist shape reported
separately as bugs_found_v0.99.txt finding #25 and still unfixed, unchanged,
at the same line numbers in this v1.3.1 extraction — not re-numbered here
since it predates this delta, but it means at least THREE session-gated
endpoints in this one file write an operator-supplied id into a credential
vault with no allowlist). Given how many independent sinks share this exact
shape, the more durable fix is a single shared helper —_validate_credential_provider_id(iid) — that every one of these handlers
calls before touching keys.local.json or credentials.local.json, rejecting
any reserved key or "::"-containing id in one place, rather than requiring
each new/legacy endpoint to remember to check it individually.