← Community
bugfixed

Module vault-scoping bypass: declaring a namespaced provider (victimslug::stripe) reads another module's saved credentials (v0.99)

ShwetaShweta#120d ago · 29 views
affected: station-v0.99fixed in: station-v1.4.0

Reproduction steps:

  1. A victim module declares a provider that collides with a built-in / another

module (e.g. "stripe"); the operator saves its secret, which is stored under
the NAMESPACED vault key keys.local.json["victimmod::stripe"] (auto-namespacing,
routes/modules.py:245).

  1. Install any attacker module (trust default "any", so a self-signed bundle

installs) whose manifest DECLARES the victim's namespaced key as its provider:
{"credential_spec": {"provider": "victimmod::stripe"}}
(module slugs are public, so the victim's namespaced key is known/guessable).

  1. The attacker's handler calls vault_get("victimmod::stripe").
  2. Run repro_module_cross_module_cred_theft_namespaced_v099.py against a clean

v0.99 extraction (drives the REAL _make_module_vault_get shim). Output:
bare 'stripe' -> None (scoping holds — namespaced to attacker's slot)
'victimmod::stripe' -> {'STRIPE_SECRET_KEY': 'sk_live_VICTIM…'} (STOLEN)
undeclared 'victimmod::stripe' -> None (scope check)

Expected:
The per-module vault scoping ("security Finding 1: a module may read ONLY the
providers it declared ... no two modules read each other's creds",
routes/modules.py:379) must hold: no installed module can read another module's
saved credentials.

Actual:
The scoping shim (_make_module_vault_get) allows ANY provider the attacker module
DECLARES (if pv in allowed: return _vault_get(pv), line 426). The
collision-namespacing that redirects a BARE colliding provider name to the
attacker's OWN slot (<attacker_slug>::<provider>) only triggers for a name that
is in the known-provider set — it does NOT trigger for an already-namespaced
<victim_slug>::<provider> key. _module_declared_providers (line 344) adds the
declared name verbatim, and nothing at install rejects a declared provider
containing the "::" namespace separator. So the attacker declares the victim's
namespaced key directly, it lands in the attacker's allowed scope, the shim
passes it straight to _vault_get -> credential_resolver.resolve ->
keys.local.json["victimmod::stripe"], and the victim's live secret is returned.
The repro shows the bare name correctly refused while the namespaced name lifts
the secret — so the Finding-1 fix is incomplete: any installed module can steal
every other module's saved credentials by naming their namespaced slots. (Unlike
the signing-seed finding this is NOT keychain-gated — module credentials always
live in keys.local.json.)

Suggested fix:

  1. At install, REJECT a manifest whose declared provider (credential_spec.provider,

allowed_destinations[].provider, or a command provider/requires) contains "::"
— a module may only declare bare provider names; the platform owns the
namespace separator.

  1. In _make_module_vault_get, never pass a declared name through to _vault_get as-is.

Resolve to the module's OWN namespaced slot (<this_slug>::<declared>) or the
bare name only when it is genuinely this module's, so a caller-chosen "::" key
can never address a sibling's slot.

  1. Defence in depth: credential_resolver.resolve() should refuse any provider name

containing "::" that does not match the calling module's slug prefix

5 pts

1 reply

Fixed in station-v1.4.0. The platform owns the :: namespace separator: _module_declared_providers drops any declared name containing ::, and the vault shim refuses any :: read outright. A module can no longer declare or read another module's namespaced slot.

Thanks for the report — credited.

Sign in to reply.