Reproduction steps:
- A victim module authenticates a provider via OAuth (Salesforce/HubSpot/Google/…);
its credential — including a cached live access token — is saved at
keys.local.json["victimmod::salesforce"].
- Install any attacker module (trust default "any"). In its handler it calls the
injected helper:
oauth_refresh("victimmod::salesforce")
— it does NOT declare that provider, and does NOT use the "::" namespaced-key
trick of finding #24.
- Run repro_oauth_refresh_unscoped_cross_module_token_theft_v099.py against a
clean v0.99 extraction. Output:
scoped vault_get('victimmod::salesforce') -> None (scoping refuses it)
oauth_refresh('victimmod::salesforce') -> access_token: 00Dxx_VICTIM… (STOLEN)
Expected:
The per-module vault scoping (routes/modules.py:379) must apply to EVERY path that
hands a module a credential, including the OAuth helper — a module may refresh
only providers it declared, and may never obtain another module's tokens.
Actual:
The module namespace is given the RAW studio_server._oauth_refresh as itsoauth_refresh helper (routes/modules.py:1053) — NOT a per-module wrapper. Inside
it, the credential is resolved with the UNSCOPED _vault_get(provider)
(studio_server.py:4918 -> credential_resolver.resolve), the same function the
scoped shim exists to replace. So a module calls oauth_refresh("victimmod::salesforce")
for ANY provider name and gets back that credential's live access token (and
instance_url); on a cache hit it returns the cached token with ZERO network
round-trip. The repro shows the module's OWN scoped vault_get correctly refusing
"victimmod::salesforce" while oauth_refresh hands over the victim's token — so this
is a SECOND bypass of the Finding-1 scoping, broader than #24 (no declaration and
no namespaced-key needed; it applies to every OAuth provider). The refresh path
also writes back to the victim's slot (_vault_save at studio_server.py:4967), so a
forced refresh mutates a sibling's credential entry too.
Suggested fix:
Make oauth_refresh a PER-MODULE helper, exactly like vault_get: bind it at module
load so it resolves the credential through that module's scoped shim
(_make_module_vault_get) and can only refresh/write providers the module itself
declared. More generally, NO helper handed into a module namespace may call the
raw _vault_get / _vault_save / credential_resolver.resolve with a module-supplied
provider name — every credential path a module can reach must go through the same
per-module scope (and reserved-key / "::" filter, cf. #23/#24/#25).