allowed_destinations._lookup_manifest matches a module_id against ANY loaded module's self-declared manifest["id"], first-match-wins — an attacker's module can hijack a DIFFERENT, legitimate module's egress-destination policy (v1.3.1)
Reproduction steps:
- Same root cause as finding #1 (module identity = self-declared
manifest["id"], not the verified install slug), reached through a
different mechanism with a more severe consequence: hijacking a
DIFFERENT, honest module's policy resolution, not just loosening the
attacker's own.
- workbench/primitives/allowed_destinations.py's _lookup_manifest (line
263) resolves a module_id by scanning the loader's _LOADED_MODULES map
and returning the FIRST entry whose slug OR self-declared
manifest["id"] normalizes to a match:
for slug, rec in loaded.items():
mfst = rec.get("manifest") or {}
if _norm(mfst.get("id")) == want or _norm(slug) == want:
return mfst, reachable
loaded is a plain dict; Python dicts preserve insertion (= module load)
order, so whichever module the loader happened to load FIRST wins the
lookup for every subsequent caller naming that id — regardless of which
module's own call is actually being checked.
- Install an attacker module under any real slug (e.g. "evil-attacker-slug")
whose manifest declares "id": "railcall/hubspot" — colliding with the
real, legitimate "railcall/hubspot" module's own self-declared id — and
grants itself broad egress:
{"id": "railcall/hubspot", "allowed_destinations": [{"provider": "openai"}]}
Install (or already have installed) the REAL "railcall/hubspot" module
under its own real slug, correctly restricted to anthropic only:
{"id": "railcall/hubspot", "allowed_destinations": [{"provider": "anthropic"}]}
- The REAL module makes an LLM call; station_llm.complete() calls
allowed_destinations.check(module_id="railcall/hubspot", provider="openai",
...) — "railcall/hubspot" being what the real module's own manifest["id"]
is, exactly what routes/modules.py's manifest.get("id") or slug would
pass on its behalf.
- Run repro_allowed_destinations_module_id_collision_hijack_v131.py against
a clean v1.3.1 extraction. It drives the REAL, unmodified
allowed_destinations.check()/_lookup_manifest() against a minimal
_LOADED_MODULES stand-in shaped exactly as routes.modules populates it.
Output:
decision: {'decision': 'allow', 'reason': "provider 'openai' matched
manifest entry (no model restriction)",
'manifest_declared': [{'provider': 'openai'}]}
CONFIRMED
Expected:
Per this module's own docstring, "the manifest is signed at publish time...
so allowed_destinations is signed-in — a publisher cannot ex post claim to
have declared something they didn't." A module's egress-destination check
must be evaluated against THAT module's own signed manifest, never a
different, unrelated module's — the whole point of a per-module manifest
contract is that one publisher's declaration cannot govern another
publisher's calls.
Actual:
The real "railcall/hubspot" module's openai call is checked against, and
ALLOWED by, the attacker's manifest instead of its own (which would have
denied it — anthropic only). The attacker never touched the real module's
files, signature, or install; they only needed to install their OWN,
independently-signed module declaring a colliding self-chosen id string.
Because manifest["id"] has no uniqueness enforcement at install time (per
finding #1), any publisher can pick any id, including one that collides with
an existing or future first-party module. The direction of the harm here is
the opposite of finding #1's framing: instead of the attacker loosening
checks on ITSELF, they loosen (or could equally tighten-to-break) checks on
a completely unrelated, honestly-configured module — silently rerouting that
module's egress-destination enforcement to whatever the attacker chose, with
no signal to the operator that anything is wrong (the real module's own
manifest and signature are untouched; the enforcement decision is simply
computed from the wrong document).
Suggested fix:
_lookup_manifest must resolve module_id to the loader's real, unique slug
FIRST, and treat manifest["id"] as, at most, a secondary/display alias — or
reject it as a lookup key entirely, matching the fix already suggested for
finding #1 (stop using manifest["id"] as identity anywhere a trust/policy
decision is made). At minimum, refuse to load a SECOND module whose
manifest["id"] collides with an already-loaded module's manifest["id"]
(install-time uniqueness check across all currently-loaded modules, not just
against the built-in catalogue), so two modules can never simultaneously
claim the same identity in the first place — closing this specific
first-match-wins ambiguity independently of the broader identity-sourcing