fix(teams): close mixed-case-scheme bypass in graph SSRF/token-leak guard#178
Conversation
…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>
|
Warning Review limit reached
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 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 configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthrough
ChangesSSRF URL Classification Fix
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ 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. Comment |
There was a problem hiding this comment.
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.
| parsed = urlparse(path_or_url) | ||
| if parsed.scheme or parsed.netloc: |
There was a problem hiding this comment.
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:There was a problem hiding this comment.
💡 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: |
There was a problem hiding this comment.
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>
|
Thanks both — addressed in @gemini-code-assist (urlparse can raise): adopted. @chatgpt-codex-connector (P2, preserve Tests now 19/19 green. |
|
To use Codex here, create an environment for this repo. |
|
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>
What
call_teams_graph_api(teams/graph/__init__.py) routed absolute-vs-relative URLs onpath_or_url.startswith("http")— a case-sensitive prefix test. A value likeHTTPS://evil.example/x(orHtTpS://, or the scheme-relative//evil.example/x) fails that test, so it fell into the relative-path branch whereurljoinstill resolved it to the absolute attacker host — and the Graph-scoped bearer token was attached without ever consultingis_trusted_graph_url. The routing test diverged from the actual allowlist.is_trusted_graph_urlitself 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
urlparsereads as having a scheme or a netloc is treated as absolute and forced throughis_trusted_graph_url(fail closed). Only a truly relative path is joined onto the trusted base. The relative-join path — including sovereign-cloudoptions.graph_urloverrides — 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:/…:/contentstill join ontograph.microsoft.com).Tests
Added
test_rejects_mixed_case_scheme_and_scheme_relative_attacker_urls(asserts the token is never fetched forHTTPS://,HtTpS://,//host). Fulltests/test_teams_graph_primitive.pypasses (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
Tests