Skip to content

fix(security): close user & agent enumeration oracles — uniform 404 + email-request parity + MCP owner-disclosure removal (#186)#1455

Merged
vybe merged 11 commits into
devfrom
AndriiPasternak31/issue-186
Jul 6, 2026
Merged

fix(security): close user & agent enumeration oracles — uniform 404 + email-request parity + MCP owner-disclosure removal (#186)#1455
vybe merged 11 commits into
devfrom
AndriiPasternak31/issue-186

Conversation

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Fixes #186

Closes the two enumeration oracles from UnderDefense pentest 3.3.3 (Low, CVSS 2.0): user (email) enumeration on POST /api/auth/email/request and agent enumeration across 30+ endpoints — plus an MCP-layer owner-username disclosure the review surfaced as the most serious item.

Design decision

Uniform 404 (house convention, per the Session-Tab pattern) rather than the issue's literal "403 for both" — both close the oracle equally; 404 also hides that access control even applies. Documented as the self-uniform rule in architecture Invariant #8: a handler must never return an existence-404 followed by an access-403 in the same function. Access-first inline handlers that already 403 uniformly stay 403.

The fix — 5 tiers

  • Tier 1 — Dependency helpers (dependencies.py): all four (get_authorized_agent/get_owned_agent/…_by_name) return a uniform 404 for both non-existent and inaccessible/unowned agents. Existence + access are evaluated before branching (equal query-count → equal timing), and _enforce_connector_scope runs first. Covers the bulk of the 30+ endpoints.
  • Tier 2 — Email request (routers/auth.py): identical generic body + status for whitelisted / non-whitelisted / rate-limited; over-limit returns the generic 200 (no 429 differential, suppression WARN-logged); verification email dispatched fire-and-forget so latency no longer splits fast/slow.
  • Tier 3 — Router-level oracles that bypass the deps: avatar.py (→ OwnedAgentByName), nevermined.py (uniform-404 helpers, equal-query-count read path), event_subscriptions.py (source-agent 400/403 → uniform 403), schedules.py webhook endpoints (→ AuthorizedAgent).
  • Tier 4 — agent_config.py GETs (capabilities/timeout/public-channel-model/guardrails): were Depends(get_current_user) only — authz hole + 404-vs-200 oracle. Now AuthorizedAgentByName. /capabilities is named in the issue.
  • Tier 5 — MCP third surface (Invariant feat: SMARTS trading pipeline with Telegram notifications and Miro visualization #13): chat.ts checkAgentAccess returns one uniform reason and removes the owner-username interpolation (genuine disclosure). Consumer classifiers adjusted for the 403→404 flip: reports.ts/messages.ts treat the dep 404 as not-authorized; nevermined.ts reports configured:false for an inaccessible agent (enumeration-safe).

Intended hardening called out

  • Avatar ops now subject to _enforce_connector_scope(owner_op=True) — connector keys that could generate avatars get 403.
  • A shared reader hitting an owner-only endpoint now gets 404 not 403 (accepted UX cost of uniform-404).
  • nevermined.ts semantic change: an inaccessible agent reports "not configured" (indistinguishable from genuinely-unconfigured — by design).
  • DELETE /{agent_name} intentionally stays 403 (already self-uniform; folding to 404 risks system-agent semantics).

Docs

Tests

  • New tests/unit/test_186_enumeration_uniformity.py — real-DB byte-identical 404 across all four deps (Authorized/Owned × name/agent_name), nevermined sibling helpers (uniform 404, admin-on-nonexistent, owner-only write), email body/no-429, Tier-4 wiring guard. 23 passed locally.
  • Flipped 403→404 asserts in test_access_control.py, test_public_links.py, test_email_auth.py, and dep-override unit tests (test_28_voip, test_brain_orb104 passed). Admin-gated asserts verified to stay 403.

Verification

  • Unit: new + modified unit suites green (real-DB harness, local venv).
  • Integration suites (test_access_control/test_email_auth/test_public_links) require a live stack — to run via /verify-local + sibling-stack curl probes + the MCP tools/list/callTool probe (MCP TS layer is not covered by verify-local) before flipping ready-for-review.

Draft — validated via /validate-pr (all green). Held for the second-reviewer pass (SOC 2 separation of duties).

AndriiPasternak31 and others added 7 commits July 4, 2026 19:51
…186)

Tier 1: collapse the four agent-access dependency helpers to a uniform
404 for both non-existent and inaccessible agents. Evaluate existence and
access booleans before branching so query-count (hence timing) is equal,
and run the connector-scope check before the existence lookup so a
connector key gets a uniform 403 across all non-bound names.

Tier 2: POST /api/auth/email/request now returns a byte-identical body +
status for whitelisted, non-whitelisted, and rate-limited emails, and
dispatches the code email fire-and-forget so the whitelisted path's
latency matches the immediate-return paths. Over-limit is WARN-logged
server-side instead of a 429 (fail-loud for ops, no client-visible
membership oracle).

Closes the differential-response enumeration oracles from UnderDefense
pentest 3.3.3 at the highest-leverage surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tier 3 — collapse ad-hoc 404-then-403 sequences that bypass the
dependency helpers:
- avatar.py generate/regenerate/delete → OwnedAgentByName (uniform 404;
  now also connector-scope gated, intended hardening).
- nevermined.py _require_read/_require_write_access → uniform 404.
- event_subscriptions.py create → collapse source-agent 400-vs-403 into a
  single uniform 403 for cross-agent subscriptions.
- schedules.py webhook generate/status/revoke → AuthorizedAgent so the
  schedule-404 is only reachable by an authorized accessor.

Tier 4 — close the open-GET authz hole + 404-vs-200 existence oracle on
agent_config.py capabilities/timeout/public-channel-model/guardrails GETs
(raw agent_name + get_current_user, no access check) by binding them to
AuthorizedAgentByName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tier 5 (Invariant #13). chat.ts checkAgentAccess: collapse the
'not found' vs 'owned by X (not shared)' branches into a single uniform
'Agent X not found or not accessible' reason and REMOVE the owner-username
interpolation — a user-scoped caller could otherwise learn who owns any
agent (the most serious item the review found: a disclosure, not just a
status differential).

Consumer classifiers adjusted for the backend 403->404 flip:
- reports.ts: treat a 404 on the dep-gated report endpoint as
  not_authorized (alongside 403).
- messages.ts: match the dep's exact 'Agent not found' detail as
  not_authorized before the generic recipient-404 branch, so an
  agent-access denial isn't misclassified as recipient_not_found.
- nevermined.ts: document that an inaccessible agent now reports
  configured:false (intentional, enumeration-safe).

agents.ts needs no change — its agent-scoped permission denials name
caller/target only (no owner leak) and are already uniform w.r.t.
existence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New tests/unit/test_186_enumeration_uniformity.py: real-DB (db_harness)
  assertions that all four agent-access deps return a byte-identical 404
  for non-existent vs inaccessible agents (incl. admin-on-nonexistent and
  owner positive control); real-handler email-request body/no-429
  uniformity; and a static guard that the four Tier-4 agent_config GETs
  now bind get_authorized_agent_by_name. 16 tests, all green.
- test_access_control.py: flip the 15 dep-routed owner/read asserts from
  403 to 404; admin-gated asserts stay 403.
- test_28_voip / test_brain_orb: dep-override denial tests updated to the
  404 contract.
- test_public_links.py: owner skip-guards tolerate 404.
- test_email_auth.py: tighten the unknown-email assert to ==200 (dead 429
  branch removed) and assert no expires_in_seconds leaks.

Verified in a py3.12 venv: new file 16/16, test_28+test_brain_orb 104/104.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- architecture.md Invariant #8: add the self-uniform (never 404-then-403)
  rule and the uniform-404 dependency behavior + MCP mirror.
- requirements/auth.md §2.1: email-request identical body/status/timing.
- requirements/security.md §20.7: new subsection recording #186.
- feature-flows/email-authentication.md: revision-history row for the
  request-code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- feature-flows.md: Recent Updates row for #186.
- agent-permissions.md: uniform-404 dependency behavior, MCP checkAgentAccess
  uniform reason + owner-username removal, refreshed error-handling table +
  self-uniform contract note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unt read path (#186)

Close the sibling-path gap for the nevermined router's own module-level
access helpers (they predate the dependency helpers):

- _require_read_access now evaluates existence AND access unconditionally
  (no short-circuit) so the query count — hence timing — is identical for the
  non-existent and the existing-but-inaccessible case, matching
  _require_write_access and the dependencies.py helpers (#186 equal-query-count
  discipline).
- Add real-DB tests asserting both helpers raise a byte-identical uniform 404
  for a missing vs inaccessible agent, admin-on-nonexistent still 404s, and the
  write helper keeps owner-only enforcement (shared reader reads but cannot
  write) — guards the sibling codepath the dependency-helper tests don't reach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review July 4, 2026 21:42
@AndriiPasternak31
AndriiPasternak31 requested review from dolho and vybe July 4, 2026 21:43
@AndriiPasternak31 AndriiPasternak31 self-assigned this Jul 4, 2026
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

…#186)

Two order-dependent CI failures on this branch, both test/analysis-level (no
product behaviour change to the enumeration fix):

1. backend-unit-test regression diff (seed 99999): test_186's _agent_exists
   probe resolved a truthy leaked Mock. A sibling unit test leaves
   services/services.docker_service as Mocks in sys.modules; _no_docker's
   dotted-string monkeypatch landed on a different auto-mock than the one
   `from services.docker_service import get_agent_by_name` resolves, so a
   non-existent agent read as existing and the admin-nonexistent 404 never fired.
   Capture the real modules at collection and pin them into sys.modules
   (monkeypatch.setitem, auto-restored) before stubbing the probe. Verified green
   across all three CI seeds.

2. CodeQL: 18 new py/path-injection alerts in avatar.py. Moving the inline
   db.get_agent_owner() check into the OwnedAgentByName dependency removed the
   barrier CodeQL saw on agent_name before AVATAR_DIR / f"{agent_name}...".
   Add _safe_avatar_component(): an explicit single-path-component basename guard
   at the top of generate/regenerate/delete — defense-in-depth (traversal is not
   reachable given OwnedAgentByName + charset-restricted names, but the constraint
   now lives in a callee CodeQL can't trace) with a uniform 404 that preserves the
   #186 no-existence-oracle contract. +10 barrier unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/backend/routers/avatar.py Fixed
The first attempt (_safe_avatar_component, an os.path.basename guard in a helper)
did not clear the 18 py/path-injection alerts: basename is not a CodeQL-modeled
sanitizer and the guard sat in a callee, so CodeQL saw tainted-in/tainted-out —
the same cross-function-barrier blind spot that hiding the check in
OwnedAgentByName created.

Replace it with _avatar_path(agent_name, suffix): os.path.normpath(join(base, …))
then require the result stay under AVATAR_DIR (the CodeQL SafeAccessCheck barrier
documented in the py/path-injection help). A barrier guard severs taint flow even
from inside a helper, since the guarded return only runs when contained. Route the
11 path sites in the four flagged functions (generate/regenerate/delete +
_generate_emotions_background) through it; the unflagged GET handlers are left as-is
to keep this scoped to #186. Produces byte-identical paths for real (charset-
restricted) agent names; uniform 404 on escape preserves the no-existence-oracle
contract. Barrier tests updated to assert containment/escape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/backend/routers/avatar.py Fixed
AndriiPasternak31 and others added 2 commits July 5, 2026 21:15
Prior barrier used `full != base and not full.startswith(base + os.sep)`; the
`base + os.sep` expression and compound condition didn't match CodeQL's
SafeAccessCheck guard model, so the 18 py/path-injection alerts persisted. Align
_avatar_path with the exact pattern from the py/path-injection query help:

    fullpath = os.path.normpath(os.path.join(base, name))
    if not fullpath.startswith(base):
        raise ...

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr: conventional commits, Fixes #186 (P2 type-bug, security/pentest), uniform-404 applied consistently across dependency family + router sweep + MCP layer, CodeQL path-injection alerts driven to zero, real-DB regression suite (test_186_enumeration_uniformity.py), no allow/deny boundary moved except the intentional Tier-4 hardening. Follow-ups noted (channel-panel 403 UX sweep, _avatar_path startswith(base+sep) tightening) — non-blocking. Dev-merge conflict in feature-flows.md resolved keep-both.

@vybe
vybe enabled auto-merge (squash) July 6, 2026 08:46
@vybe
vybe merged commit 34ddf71 into dev Jul 6, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants