Reproduction steps:
- A victim module "victimmod" has saved its own named credential (e.g. Stripe),
stored at credentials.local.json["victimmod::stripe"] with default = the
victim's key.
- As any session-holder, POST /api/integration/credential with an arbitrary id
equal to the victim's slot and set_default:
{"id":"victimmod::stripe","label":"x",
"fields":{"STRIPE_SECRET_KEY":"sk_live_ATTACKER"},"set_default":true}
- Resolve the provider again.
- Also POST with id:"_railcall_signing_seed".
- Run repro_credential_save_unvalidated_id_injection_v099.py against a clean
v0.99 extraction (drives the REAL _handle_credential_save + resolve). Output:
before: resolve('victimmod::stripe') -> {'STRIPE_SECRET_KEY':'sk_live_VICTIM'}
attacker save -> ok:True
after: resolve('victimmod::stripe') -> {'STRIPE_SECRET_KEY':'sk_live_ATTACKER'}
reserved '_railcall_signing_seed' slot save accepted: True
Expected:
A credential save must target a KNOWN integration/provider the operator is
configuring, and must never be able to write another module's namespaced slot or
a reserved slot. The sibling save endpoint /api/vault/save already enforces this:
it looks the provider up in VAULT_ALLOWLIST and refuses anything else
(routes/vault.py:80).
Actual:
_handle_credential_save (routes/dispatch_integration.py:105) validates id only
as a non-empty string (line 119) and then writes vault[id] = slot into
credentials.local.json (line 150) — no allowlist, no reserved-key check, no "::"
namespace check. So a session-holder can:
(a) inject a credential into ANOTHER module's namespaced slot
"victimmod::stripe" with set_default:true; credential_resolver.resolve()
returns the default (line ~118 of credential_resolver), so the victim
module now makes its live API calls with the ATTACKER's key — charges land
in the attacker's account, exfiltrated data goes to the attacker, etc.
(the repro shows resolve() flip from sk_live_VICTIM to sk_live_ATTACKER);
(b) write the reserved "_railcall_signing_seed" slot (accepted with ok:true).
This is the same validate-vs-enforce asymmetry as the two save endpoints: one is
allowlist-gated, its sibling validates nothing. Reachable by a CSRF hit on the
loopback Studio server, a compromised session, or a module that read
WS/session_token off disk (findings #3/#23 show modules can read files) and POSTed
to the endpoint.
Suggested fix:
Validate id in _handle_credential_save exactly as /api/vault/save does: it must
be a known catalogue / installed-integration provider; reject any id in
signing.RESERVED_VAULT_KEYS and any id containing "::" (the platform, not the
request, owns the namespace separator). A credential save must never override a
DIFFERENT module's default credential or address a reserved slot. Apply the same
guard to _handle_credential_delete / _handle_credential_set_default, which also
write vault[iid] from a request id.