Skip to content

fix(teams): close mixed-case-scheme bypass in graph SSRF/token-leak guard#178

Merged
patrick-chinchill merged 3 commits into
mainfrom
fix/teams-graph-ssrf-mixed-case-scheme
Jun 23, 2026
Merged

fix(teams): close mixed-case-scheme bypass in graph SSRF/token-leak guard#178
patrick-chinchill merged 3 commits into
mainfrom
fix/teams-graph-ssrf-mixed-case-scheme

Conversation

@patrick-chinchill

@patrick-chinchill patrick-chinchill commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

call_teams_graph_api (teams/graph/__init__.py) routed absolute-vs-relative URLs on path_or_url.startswith("http") — a case-sensitive prefix test. A value like HTTPS://evil.example/x (or HtTpS://, or the scheme-relative //evil.example/x) fails that test, so it fell into the relative-path branch where urljoin still resolved it to the absolute attacker host — and the Graph-scoped bearer token was attached without ever consulting is_trusted_graph_url. The routing test diverged from the actual allowlist.

is_trusted_graph_url itself was already correct (urlparse + scheme == "https" + exact-host frozenset, fail-closed) — it simply was never reached for these inputs.

Fix

Route on the parsed form: anything urlparse reads as having a scheme or a netloc is treated as absolute and forced through is_trusted_graph_url (fail closed). Only a truly relative path is joined onto the trusted base. The relative-join path — including sovereign-cloud options.graph_url overrides — is unchanged.

Verified the new routing across attack + legitimate inputs (mixed-case scheme, scheme-relative, lookalike-suffix host all rejected; legit absolute Graph URLs pass; relative paths incl. OneDrive root:/…:/content still join onto graph.microsoft.com).

Tests

Added test_rejects_mixed_case_scheme_and_scheme_relative_attacker_urls (asserts the token is never fetched for HTTPS://, HtTpS://, //host). Full tests/test_teams_graph_primitive.py passes (17).

Severity / provenance

Found by a retroactive local-Codex review of the merged 4.30/4.31 train (the repo runs no Codex/Claude review bot) plus manual verification. Codex rated it HIGH; verification confirmed it's real but gated on an attacker-influenced @odata.nextLink/path, so MEDIUM — but it defeats the exact SSRF/token-leak guard this primitive advertises (and which is a Python-first hardening with no upstream counterpart, so not a parity concern).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Corrected Microsoft Graph API URL parsing to properly handle mixed-case schemes and scheme-relative URL formats.
  • Tests

    • Added regression test for Graph API URL validation handling.

…uard

call_teams_graph_api routed absolute-vs-relative on `path_or_url.startswith("http")`,
which is case-sensitive. A value like `HTTPS://evil.example/x` (or `HtTpS://`, or the
scheme-relative `//evil.example/x`) fails that prefix test, so it fell into the
"relative path" branch where `urljoin` still resolved it to the absolute attacker host
— and the Graph-scoped bearer token was attached WITHOUT ever consulting
is_trusted_graph_url. The routing test diverged from the actual allowlist.

Route on the parsed form instead: anything urlparse reads as having a scheme or a
netloc is treated as absolute and forced through is_trusted_graph_url (fail closed);
only a truly relative path is joined onto the trusted base. is_trusted_graph_url itself
was already correct (urlparse + scheme=='https' + exact-host) — it just wasn't reached.
The relative-join path (incl. sovereign-cloud `options.graph_url` overrides) is unchanged.

Found by a retroactive local-Codex review of the merged 4.30/4.31 train + manual
verification (Codex rated it HIGH; verified real but gated on an attacker-influenced
@odata.nextLink, so MEDIUM — it still defeats the guard the PR advertises).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@patrick-chinchill, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c62a584-9dc9-460b-99db-7a4a6cd063ba

📥 Commits

Reviewing files that changed from the base of the PR and between 3dd16f5 and 379cc9c.

📒 Files selected for processing (2)
  • src/chat_sdk/adapters/teams/graph/__init__.py
  • tests/test_teams_graph_primitive.py
📝 Walkthrough

Walkthrough

call_teams_graph_api replaces its "http" prefix heuristic with urlparse-based classification: inputs with a scheme or netloc are treated as absolute and checked against the trusted-host allowlist; inputs lacking both are joined onto the configured base URL. A regression test covers mixed-case and scheme-relative hostile URLs.

Changes

SSRF URL Classification Fix

Layer / File(s) Summary
urlparse-based URL classification in call_teams_graph_api
src/chat_sdk/adapters/teams/graph/__init__.py
Docstring updated to describe urlparse-driven routing; implementation replaces the "http"-prefix heuristic with urlparse so any input carrying a scheme or netloc is gated by the trusted-host allowlist before token attachment.
Regression test for hostile URL forms
tests/test_teams_graph_primitive.py
New async test asserts that mixed-case HTTPS variants and scheme-relative (//host/...) nextLink values each raise ValueError("untrusted host") and that the injected fetch is never awaited.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 A sneaky URL tried to slip right through,
With HTTPS mixed-case, a scheme-relative too.
But urlparse caught it—scheme and netloc found!
The trusted-host allowlist kept the garden sound.
No tokens leaked today, the warren stays secure.
One tidy little fix, and the tests all pass—pure! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the primary change: fixing a security bypass in the SSRF/token-leak guard for Teams Graph API by addressing mixed-case scheme handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@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 hardens the validation of Microsoft Graph API URLs by using urlparse to check for a scheme or netloc instead of relying on a simple startswith("http") check. This prevents bypasses using mixed-case schemes or scheme-relative URLs. Regression tests have been added to verify this behavior. The reviewer suggested wrapping the urlparse call in a try...except block to handle potential ValueError or TypeError exceptions, ensuring that malformed inputs fail closed and are cleanly rejected.

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 on lines +269 to +270
parsed = urlparse(path_or_url)
if parsed.scheme or parsed.netloc:

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.

medium

While urlparse is generally safe, it can raise ValueError (e.g., on URLs containing control characters in modern Python versions) or TypeError (if path_or_url is not a string at runtime). Wrapping the urlparse call in a try...except block and defaulting to treating it as an absolute URL (fail-closed) ensures that any malformed input is cleanly rejected with our custom descriptive error message rather than raising an unhandled internal exception.

    try:
        parsed = urlparse(path_or_url)
        is_absolute = bool(parsed.scheme or parsed.netloc)
    except (ValueError, TypeError):
        is_absolute = True

    if is_absolute:

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3dd16f5337

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# having a scheme or a netloc is treated as absolute and forced through the host
# allowlist (fail closed); only a truly relative path is joined onto the base.
parsed = urlparse(path_or_url)
if parsed.scheme or parsed.netloc:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve colon-style relative Graph paths

When callers set graph_url to a drive/container prefix and pass a valid Graph relative segment such as root:/foo.txt:/content, urlparse reports scheme='root', so this new branch rejects the request as an untrusted absolute URL instead of joining it to the configured base. That regresses colon-addressed Graph paths that were previously valid relative requests; the guard should distinguish real absolute HTTP(S)/network URLs from Graph path segments before failing closed.

Useful? React with 👍 / 👎.

…keep colon paths

Address PR review feedback on the SSRF routing fix:
- Gemini (robustness): wrap urlparse in try/except and fail closed (treat an
  unparseable URL, e.g. a bad IPv6 literal, as absolute so the allowlist rejects
  it) instead of letting urlparse raise out of the function.
- Codex (P2, colon paths): keep routing on `scheme or netloc`. A bare `root:/…`
  segment never joined via urljoin anyway (urljoin sees scheme `root` and returns
  it unchanged), so rejecting it is the safe behavior — routing it to the relative
  branch would attach the Graph token to a malformed `root:` URL. Realistic Graph
  colon paths put the colon AFTER a slash (`me/drive/root:/x:/content`), parse with
  no scheme, and still join onto the trusted base — added a test proving it.

Tests: +malformed-URL-fails-closed, +colon-after-slash-relative-path-joins (19 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@patrick-chinchill

Copy link
Copy Markdown
Collaborator Author

Thanks both — addressed in d247683:

@gemini-code-assist (urlparse can raise): adopted. urlparse is now wrapped in try/except (ValueError, TypeError) and fails closed — an unparseable input (e.g. https://[oops, a bad IPv6 literal — confirmed it raises ValueError) is treated as absolute and rejected by the allowlist rather than propagating. Added test_malformed_url_fails_closed_without_fetching_a_token.

@chatgpt-codex-connector (P2, preserve root:/…:/content colon paths): respectfully keeping the scheme or netloc routing. A bare root:/… ref never actually joined via urljoinurljoin(".../me/drive/", "root:/x:/content") returns "root:/x:/content" unchanged (it sees scheme='root'), so the pre-fix code would have attached the Graph token to a malformed root: request. Rejecting it is the safer behavior; routing it to the relative branch would re-introduce a token-on-malformed-URL path. Realistic Graph colon addresses put the colon after a slash (me/drive/root:/Reports/jan.csv:/content), which parses with no scheme and still joins onto the trusted base — added test_colon_in_relative_graph_path_still_joins_onto_the_trusted_base proving that path is unaffected.

Tests now 19/19 green.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Thanks for the update, @patrick-chinchill. The implementation looks robust, especially the decision to fail closed on unparseable inputs and the verification that relative paths with colons (like OneDrive paths) are still correctly handled. The added regression tests provide excellent coverage for these edge cases. This looks ready to go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@patrick-chinchill patrick-chinchill merged commit 1d62cd3 into main Jun 23, 2026
8 checks passed
@patrick-chinchill patrick-chinchill deleted the fix/teams-graph-ssrf-mixed-case-scheme branch June 23, 2026 20:57
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.

2 participants