Skip to content

fix(proxy): stop allow_domain endpoint route from shadowing credential catch-all - #1132

Merged
SequeI merged 5 commits into
nolabs-ai:mainfrom
panga:fix/credential-catchall-shadowing
Jun 17, 2026
Merged

fix(proxy): stop allow_domain endpoint route from shadowing credential catch-all#1132
SequeI merged 5 commits into
nolabs-ai:mainfrom
panga:fix/credential-catchall-shadowing

Conversation

@panga

@panga panga commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Linked Issue

Closes #1131

Summary

Fixes a route-selection bug where a credential catch-all (custom_credentials
with no endpoint_rules) was shadowed by a credential-less _ep_ endpoint-authorization
route (from an allow_domain entry with endpoints) on the same upstream. On a path the
_ep_ route authorized, the proxy selected the credential-less _ep_ match ahead of the
catch-all and forwarded the request without injecting the managed credential.

The fix changes the selection priority so a credential-bearing catch-all wins over a
credential-less _ep_ match on authorized paths. Selection priority is now:

  1. a matched route carrying a managed credential;
  2. a credential-bearing catch-all (the new step — prevents the shadowing);
  3. any other match (bare endpoint authorization);
  4. the catch-all (un-credentialed passthrough).

As part of the fix, the route-selection logic (the _ep_ hard-deny gate, the ambiguity
check, and the priority ordering) was extracted from tls_intercept/handle.rs into a single
select_route function + RouteSelection enum in route.rs. This gives the decision one
source of truth shared with its unit tests — the previous test was a hand-copied mirror that
didn't even model the _ep_ gate, so it could not have caught this bug.

What is intentionally unchanged

  • _ep_ routes are kept. They enforce auth-less default-deny on paths; removing or merging
    them would let paths the credential's endpoint_rules don't match through un-credentialed.
  • The endpoint hard-deny gate (has_endpoint_only_route && !endpoint_authorized → 403) and
    the ambiguity check (multiple credential routes → 403) are byte-for-byte equivalent to the
    prior behavior, including the audit denial_category, reason strings, and HTTP status codes.
  • The host gate is untouched: a custom_credentials-only host that is not in allow_domain
    stays denied at the host filter.

Agent Disclosure

  • This PR was generated by an AI agent (Claude Code).

Test Plan

  • Added test_route_selection_credential_catchall_not_shadowed in route.rs:
    • authorized path (GET /org/repo) → credential catch-all selected (token injected) — this
      fails on the old ordering and passes after the fix;
    • non-matching path (GET /other/repo) → EndpointDenied (default-deny preserved).
  • Updated test_route_selection_multi_org_profile to exercise the real select_route via a thin
    test adapter instead of a hand-copied mirror.

Manual repro: profile with github.com in allow_domain (org-scoped endpoints) + a
custom_credentials github_api catch-all → authorized path now injects the token; a
non-authorized path is still 403.

Checklist

  • An issue exists and is linked above
  • All commits are signed-off, using DCO
  • All new code follows the project's coding standards (CLAUDE.md) and is covered by tests
  • Public-facing changes are paired with documentation updates
  • Release note has been added to CHANGELOG.md if needed

Agent Compliance Check

  • I am not prohibited from contributing under this policy
  • An issue already exists
  • I disclosed that I am an agent in the issue discussion
  • I described my intent and approach in the issue discussion
  • I reviewed repository coding and security rules for the affected area
  • I provided required attribution for reused or adapted code
  • I did not use forbidden patterns such as unwrap/expect
  • I used NonoError where required
  • I validated and canonicalized all relevant paths
  • This PR matches the approved or disclosed issue scope

@github-actions github-actions Bot added bug Something isn't working nono-proxy size/large labels Jun 12, 2026
@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

PR Review Summary

Size

Metric Value
Lines added +246
Lines removed -119
Total changed 365
Classification Large (> 300 lines)

Affected crates

  • crates/nono-proxydownstream consumers depend on this crate. API or behaviour changes will affect external callers; treat any breaking change with extra scrutiny.

Blast radius — Contained

This PR touches: source code


Updated automatically on each push to this PR.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request refactors the route selection logic out of tls_intercept/handle.rs into a dedicated select_route function in route.rs, introducing a RouteSelection enum and adding corresponding unit tests. Feedback on the changes identifies a latent shadowing bug in select_route when multiple catch-all routes exist, where a credential-bearing catch-all could be shadowed by a credential-less catch-all, or ambiguity between multiple credential-bearing catch-alls could be silently ignored. A refactored implementation is suggested to properly classify candidates and resolve these edge cases.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/nono-proxy/src/route.rs Outdated
A credential catch-all (custom_credentials with no endpoint_rules) was
shadowed by a credential-less _ep_ endpoint-authorization route on the
same upstream: on a path the _ep_ route authorized, selection picked the
credential-less _ep_ match ahead of the catch-all and forwarded the
request without injecting the managed credential.

Partition candidate routes into four buckets (matched_cred,
matched_passthrough, catchall_cred, catchall_passthrough). The active
credential layer is matched_cred when any credential route matched,
otherwise catchall_cred, so a credential catch-all stays in play when only
credential-less _ep_ routes matched. Selection then prefers, in order:
(1) the single credential route from the active layer, (2) a matched
credential-less route (bare endpoint authorization), (3) a credential-less
catch-all (passthrough). This injects the token on authorized paths instead
of dropping it, and also makes two credential catch-alls for one upstream
ambiguous (403) rather than a silent pick. The _ep_ hard-deny gate is
unchanged, so non-matching paths are still hard-denied (default-deny
preserved) and the host gate is untouched.

Extract the selection logic (gate + ambiguity + priority) from
tls_intercept/handle.rs into select_route + RouteSelection in route.rs so
the decision has a single source of truth shared with its unit tests; the
prior test was a hand-copied mirror that did not model the _ep_ gate.
Derive Default on RouteConfig so test fixtures can use ..Default::default()
(no serde change: prefix/upstream remain required on deserialize).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Leonardo Zanivan <leonardo.zanivan@gmail.com>
@panga
panga force-pushed the fix/credential-catchall-shadowing branch from c0fb83f to 69387e8 Compare June 12, 2026 13:46
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Leonardo Zanivan <leonardo.zanivan@gmail.com>
@lukehinds
lukehinds self-requested a review June 17, 2026 07:32

@SequeI SequeI left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Left a small question about the logic for matched_creds, let me know if I am off base there. After that is resolved, we can get this merged :)

Comment on lines +378 to +379
if has_endpoint_only_route && !endpoint_authorized {
return RouteSelection::EndpointDenied;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This runs before we ever check matched_creds so it does not matter that a credential route already matched, we still bail out early. credential route match is invisible to this check

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right that the gate runs before we look at matched_cred, and that a matched credential route doesn't flip endpoint_authorized. That's intentional, and it's also the pre-refactor behavior.

The reasoning: an ep route (from an allow_domain entry with endpoints) is a network-level default-deny gate, not a per-credential concern. When such a route exists for the upstream, the request must match the host's allowed endpoint surface before we get to choose/inject a credential. A credential route matching the path authorizes credential injection on that path, but it does not by itself widen the host's endpoint allow-list — otherwise a custom_credentials catch-all could grant access to paths the operator deliberately scoped out via allow_domain endpoints, which is exactly the bypass this gate exists to prevent.

So the ordering is deliberate: gate on the host's endpoint policy first (credential match is intentionally invisible here), then select among credential routes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm, thanks!

@panga
panga requested a review from SequeI June 17, 2026 11:50
@SequeI
SequeI merged commit b0b2c74 into nolabs-ai:main Jun 17, 2026
13 checks passed
oscarmackjr-twg added a commit to OscarMackJr/nono that referenced this pull request Jun 23, 2026
- Add `allow_domain_endpoint_route_does_not_shadow_credential_route` to
  route.rs test module proving nolabs-ai#1132 shadow class is absent on the fork
- Two routes with the same upstream host (api.openai.com) but disjoint
  prefix keys ("openai" vs "_ep_api.openai.com") occupy separate HashMap
  slots and do not shadow each other
- Credential route (key "openai") retains open-access behaviour; endpoint
  route (key "_ep_api.openai.com") enforces its GET /v1/models rule
- Upstream host-ordered selection abstraction NOT imported (D-10 lock);
  grep gate confirms 0 occurrences
- RouteStore non-test code unchanged (equivalence/disproof only)

Signed-off-by: Oscar Mack Jr <oscar.mack.jr@gmail.com>
oscarmackjr-twg added a commit to OscarMackJr/nono that referenced this pull request Jun 23, 2026
…ergence ledger

Records the four equivalence findings (D-09 nolabs-ai#1077, D-01 nolabs-ai#1048/nolabs-ai#1091, D-02 nolabs-ai#1151,
D-10 nolabs-ai#1132), two won't-sync findings (D-05 nolabs-ai#1192, D-04 nolabs-ai#1199), and the one
deliberate fork-divergence (D-07 nolabs-ai#1197, fix 0c08e5d) with their guard-test fn
names so future syncs expect the Cluster F divergence and never blind-cherry-pick
the tls_intercept/RouteSelection/TlsInterceptIntent hunks (D-11).

Signed-off-by: Oscar Mack Jr <oscar.mack.jr@gmail.com>
klassm pushed a commit to klassm/nono that referenced this pull request Jul 3, 2026
…l catch-all (nolabs-ai#1132)

* fix(proxy): stop _ep_ route from shadowing credential catch-all

A credential catch-all (custom_credentials with no endpoint_rules) was
shadowed by a credential-less _ep_ endpoint-authorization route on the
same upstream: on a path the _ep_ route authorized, selection picked the
credential-less _ep_ match ahead of the catch-all and forwarded the
request without injecting the managed credential.

Partition candidate routes into four buckets (matched_cred,
matched_passthrough, catchall_cred, catchall_passthrough). The active
credential layer is matched_cred when any credential route matched,
otherwise catchall_cred, so a credential catch-all stays in play when only
credential-less _ep_ routes matched. Selection then prefers, in order:
(1) the single credential route from the active layer, (2) a matched
credential-less route (bare endpoint authorization), (3) a credential-less
catch-all (passthrough). This injects the token on authorized paths instead
of dropping it, and also makes two credential catch-alls for one upstream
ambiguous (403) rather than a silent pick. The _ep_ hard-deny gate is
unchanged, so non-matching paths are still hard-denied (default-deny
preserved) and the host gate is untouched.

Extract the selection logic (gate + ambiguity + priority) from
tls_intercept/handle.rs into select_route + RouteSelection in route.rs so
the decision has a single source of truth shared with its unit tests; the
prior test was a hand-copied mirror that did not model the _ep_ gate.
Derive Default on RouteConfig so test fixtures can use ..Default::default()
(no serde change: prefix/upstream remain required on deserialize).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Leonardo Zanivan <leonardo.zanivan@gmail.com>

* chore: trigger CI

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Leonardo Zanivan <leonardo.zanivan@gmail.com>

---------

Signed-off-by: Leonardo Zanivan <leonardo.zanivan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Luke Hinds <lukehinds@gmail.com>
Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working nono-proxy size/large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

credential catch-all is shadowed by an endpoint-only route, so the credential is not injected

3 participants