Skip to content

feat(teams): graph primitive subpath + SSRF allowlist divergence (chat@4.31 8c71411)#160

Merged
patrick-chinchill merged 1 commit into
mainfrom
feat/4.31-teams-graph-primitive
Jun 19, 2026
Merged

feat(teams): graph primitive subpath + SSRF allowlist divergence (chat@4.31 8c71411)#160
patrick-chinchill merged 1 commit into
mainfrom
feat/4.31-teams-graph-primitive

Conversation

@patrick-chinchill

Copy link
Copy Markdown
Collaborator

Summary

Ports the Microsoft Graph primitives subpath for Teams — NEW in chat@4.31.0 (packages/adapter-teams/src/graph/, commit 8c71411), exposed upstream as @chat-adapter/teams/graph. This is Wave-B Round-2 of the 4.31 Teams sync.

New src/chat_sdk/adapters/teams/graph/ subpackage (single-file __init__.py, mirroring the slack/api and Round-1 teams/api layout template). SDK-free: plain dicts + lazy httpx/stdlib, no microsoft_teams imports.

Reuses the Round-1 api primitive (now on main) via the allowed cross-subpath import: resolve_teams_access_token, read_response_body, TeamsApiError (and the lazy-httpx default fetch + response-protocol helpers).

Ported (all 7 graph/index.test.ts it blocks + boundary)

  • call_teams_graph_api — Microsoft Graph .default scope; absolute URL used as-is, relative path joined onto the base Graph URL
  • paginate_teams_graph — follows @odata.nextLink verbatim (never re-encoded)
  • get_teams_channel — channel metadata; id falls back to channel_id, display_name omitted when absent
  • list_teams_chat_messages / list_teams_channel_messages / list_teams_message_replies$top query-param join
  • get_teams_channel_message, to_graph_message
  • extract_text_from_graph_message — ordered regex HTML→text pass; entity-decode & last

SSRF divergence (Python-first, no upstream counterpart)

call_teams_graph_api gates an absolute path_or_url and every followed @odata.nextLink to the graph.microsoft.com host before resolving/attaching the Bearer token — raising ValueError for any other host. Upstream attaches the token to whatever URL the server hands back. A dedicated test asserts a hostile @odata.nextLink is rejected before the injected fetch is ever awaited (the token never leaves the process). Divergence-doc rows are consolidated in T7 (this lane does not touch docs/UPSTREAM_SYNC.md or CHANGELOG.md).

Tests — tests/test_teams_graph_primitive.py (16, all green)

7 faithful index ports + boundary source-scan + no-eager-httpx import check + direct text-extraction coverage (incl. the &-last ordering) + the nextLink-host SSRF rejection. Injectable AsyncMock fetch (not MagicMock). Mutation-checked: disabling the SSRF gate and reordering the entity decode both fail the relevant tests.

Gauntlet (from worktree)

  • ruff check — All checks passed
  • ruff format --check — 270 files already formatted
  • audit_test_quality.py — 0 hard failures
  • pyrefly check — 0 errors
  • pytest tests/ — 4978 passed, 4 skipped

Lane scope

Only two new files added; teams/__init__.py (lazy subpath registration deferred to T7), adapter.py, CHANGELOG.md, docs/UPSTREAM_SYNC.md, and other primitives are untouched.

…t@4.31 8c71411)

Port the Microsoft Graph primitives subpath (NEW in chat@4.31.0) for the
Teams adapter as a runtime-free, SDK-free package mirroring slack/api and
teams/api: call_teams_graph_api (Graph .default scope), paginate_teams_graph
(follows @odata.nextLink as-is), get_teams_channel, list chat/channel
messages + replies, get_teams_channel_message, and the ordered
extract_text_from_graph_message HTML->text pass (entity-decode & last).

Reuses the Round-1 api subpath (resolve_teams_access_token,
read_response_body, TeamsApiError) via the allowed cross-subpath import.
Injectable async fetch (AsyncMock in tests) defaults to the api subpath's
lazy-httpx transport.

Python-first divergence: gate an absolute path_or_url and every followed
@odata.nextLink to the graph.microsoft.com host BEFORE attaching the Bearer
token (SSRF / token-leak guard), raising ValueError for any other host.
Upstream attaches the token to whatever URL it is handed.

Ports all 7 graph/index.test.ts it-blocks + the boundary source-scan, plus
direct text-extraction coverage, the nextLink-host SSRF rejection test, and
a no-eager-httpx import check.
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

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 42 seconds. 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 adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

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: b70388ce-5c65-4729-b284-15eeb10e084f

📥 Commits

Reviewing files that changed from the base of the PR and between 19acaef and d0c26e9.

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

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 and usage tips.

@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 introduces Microsoft Graph primitives for Teams as a runtime-free subpath, providing fetch-based utilities for calling the Graph REST API, handling pagination, and extracting plain text from HTML messages, along with comprehensive unit tests. The review feedback highlights a critical security vulnerability in call_teams_graph_api where a case-sensitive scheme check could allow SSRF and token leakage via uppercase schemes (e.g., HTTPS://). Additionally, a defensive programming improvement is suggested in to_graph_message to explicitly check if the user object is a Mapping to prevent potential runtime crashes.

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 +262 to +270
if path_or_url.startswith("http"):
if not is_trusted_graph_url(path_or_url):
raise ValueError(f"Refusing to call Microsoft Graph API on untrusted host: {path_or_url}")
url = path_or_url
else:
url = urljoin(
options.graph_url if options.graph_url is not None else _DEFAULT_GRAPH_URL,
_LEADING_SLASH_PATTERN.sub("", path_or_url),
)

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.

security-critical critical

Security Vulnerability: SSRF and Token Leakage via Case-Sensitivity Bypass

The current implementation of call_teams_graph_api checks if path_or_url starts with "http" using a case-sensitive check. If an attacker passes an absolute URL with an uppercase scheme (e.g., "HTTPS://evil.com/v1.0/next"), the check path_or_url.startswith("http") evaluates to False.

As a result, the code falls into the else block and resolves the URL using urljoin. Since urljoin handles uppercase schemes correctly, it returns the absolute URL "HTTPS://evil.com/v1.0/next" unchanged. Because the validation is_trusted_graph_url is only performed inside the if block, the untrusted URL is never validated, and the Graph-scoped bearer token is attached and sent to the untrusted host.

To fix this, normalize the scheme check to be case-insensitive, and always validate the final resolved url using is_trusted_graph_url before proceeding.

Suggested change
if path_or_url.startswith("http"):
if not is_trusted_graph_url(path_or_url):
raise ValueError(f"Refusing to call Microsoft Graph API on untrusted host: {path_or_url}")
url = path_or_url
else:
url = urljoin(
options.graph_url if options.graph_url is not None else _DEFAULT_GRAPH_URL,
_LEADING_SLASH_PATTERN.sub("", path_or_url),
)
if path_or_url.lower().startswith(("http://", "https://")):
url = path_or_url
else:
url = urljoin(
options.graph_url if options.graph_url is not None else _DEFAULT_GRAPH_URL,
_LEADING_SLASH_PATTERN.sub("", path_or_url),
)
if not is_trusted_graph_url(url):
raise ValueError(f"Refusing to call Microsoft Graph API on untrusted host: {url}")
References
  1. When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use is not None instead of a truthiness check to avoid silently ignoring them.

text=extract_text_from_graph_message(message),
raw=raw,
created_at=created_at if created_at else None,
from_=_to_graph_user(user) if user else None,

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

Defensive Programming: Type Guard for user Mapping

In to_graph_message, user is extracted from from_block.get("user"). While it is expected to be a dictionary/mapping, if the API returns a non-mapping truthy value (e.g., a string or list), the truthiness check if user will pass, but _to_graph_user(user) will raise an AttributeError when attempting to call .get() on it.

To prevent potential runtime crashes, use an explicit isinstance(user, Mapping) check instead of a simple truthiness check.

Suggested change
from_=_to_graph_user(user) if user else None,
from_=_to_graph_user(user) if isinstance(user, Mapping) else None,

"""

def test_graph_source_does_not_import_the_adapter_sdk_or_runtime(self) -> None:
import chat_sdk.adapters.teams.graph as graph_pkg
@patrick-chinchill patrick-chinchill marked this pull request as ready for review June 19, 2026 23:56
@patrick-chinchill patrick-chinchill merged commit 6ed219a into main Jun 19, 2026
9 checks passed

@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: d0c26e97e1

ℹ️ 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".

# which permits the ``.graph.microsoft.com`` suffix and the exact
# ``graph.microsoft.com`` host); a Graph *API* call only ever targets the bare
# ``graph.microsoft.com`` host, so we pin to it exactly.
_TRUSTED_GRAPH_HOSTS = frozenset({"graph.microsoft.com"})

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 Allow configured Graph cloud hosts for cursors

When a caller uses TeamsGraphOptions.graph_url for a Microsoft Graph national cloud such as https://graph.microsoft.us/v1.0/, the list helpers can fetch the first page because they join relative paths to that base, but Graph returns an absolute @odata.nextLink on the same host and paginate_teams_graph rejects it here. This makes paged results unusable in supported sovereign Teams/Graph deployments; the trust check should accept the configured Graph host or the supported Microsoft Graph cloud endpoints rather than only graph.microsoft.com.

Useful? React with 👍 / 👎.

url = path_or_url
else:
url = urljoin(
options.graph_url if options.graph_url is not None else _DEFAULT_GRAPH_URL,

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 Validate overridden Graph bases before sending tokens

If options.graph_url is set, this branch joins every relative helper path onto that URL without running is_trusted_graph_url, then resolves and attaches the bearer token. In deployments where the base URL is tenant/config driven, a typo or malicious value like https://evil.example/v1.0/ makes list_teams_chat_messages send Authorization: Bearer ... to that host, bypassing the token-leak guard that only covers absolute path_or_url/nextLink. Validate the resolved URL or configured base before resolving the token.

Useful? React with 👍 / 👎.

@patrick-chinchill patrick-chinchill deleted the feat/4.31-teams-graph-primitive branch June 21, 2026 19:09
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.

1 participant