Skip to content

fix(teams): fall back to content.downloadUrl for SharePoint/OneDrive attachments (supersedes #136)#167

Merged
patrick-chinchill merged 1 commit into
mainfrom
fix/teams-attachment-downloadurl-v2
Jun 20, 2026
Merged

fix(teams): fall back to content.downloadUrl for SharePoint/OneDrive attachments (supersedes #136)#167
patrick-chinchill merged 1 commit into
mainfrom
fix/teams-attachment-downloadurl-v2

Conversation

@patrick-chinchill

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

Copy link
Copy Markdown
Collaborator

Problem

A SharePoint/OneDrive file shared in a personal or group chat arrives as a application/vnd.microsoft.teams.file.download.info attachment that carries both:

  • a top-level contentUrl — the SharePoint/OneDrive item (e.g. https://contoso.sharepoint.com/.../report.pdf), which returns 403 to an anonymous GET because it needs a SharePoint auth context, and
  • a nested content.downloadUrl — a short-lived pre-authenticated link the Bot Framework docs explicitly say to issue an HTTP GET against.

Upstream Teams (adapter-teams/src/index.ts:833, const url = att.contentUrl — its createAttachment param type doesn't even include content) and our pre-fix adapter read contentUrl only, so the attachment's fetch_data 403s and the file is undownloadable.

Fix

_create_attachment now prefers content.downloadUrl for the file.download.info attachment type. Every other attachment (inline images, etc.) keeps the upstream contentUrl-first path (content.downloadUrl is only used as a fallback there when contentUrl is missing/falsy):

content = att.get("content")
download_url = content.get("downloadUrl") if isinstance(content, dict) else None
if content_type == "application/vnd.microsoft.teams.file.download.info" and download_url:
    url = download_url
else:
    url = att.get("contentUrl") or download_url

The resulting URL flows through the unchanged _build_teams_fetch_data / rehydrate_attachment SSRF allowlist. SharePoint/OneDrive for Business download hosts are already covered by *.sharepoint.com / *.onedrive.com in that allowlist, so no host was added.

Divergence

Justified Python-only divergence — hard-UX-failure (attachment cannot be downloaded), allowed by the divergence policy. Documented in docs/UPSTREAM_SYNC.md Known Non-Parity; to be filed as an upstream issue against vercel/chat. Supersedes stale/changes-requested PR #136 (rebuilt cleanly on current main, content-type-scoped rather than a blanket downloadUrl or contentUrl).

Tests

TestFileDownloadInfoAttachment (tests/test_teams_adapter.py):

  • downloadUrl preferred over a 403-ing contentUrl
  • downloadUrl used when contentUrl is absent
  • a regular (inline-image) attachment with a competing content.downloadUrl is left unchanged
  • the pre-signed downloadUrl is what the SSRF-gated fetch closure actually GETs
  • the downloadUrl path is still gated by the SSRF allowlist (untrusted host fails closed, session never awaited)

Each was verified to fail on a plausible mutation: contentUrl-only revert, over-eager downloadUrl-for-all-types, and a dropped SSRF gate.

Gauntlet

  • ruff check — clean
  • ruff format --check — clean
  • audit_test_quality.py — 0 hard failures
  • pyrefly check — 0 errors
  • pytest — 5085 passed, 4 skipped (pre-existing)
  • verify_test_fidelity.py — 100% matched, 0 missing

Summary by CodeRabbit

  • Bug Fixes

    • Improved Teams file attachment handling by prioritizing pre-signed download URLs over standard URLs for more reliable access.
  • Documentation

    • Updated documentation regarding Teams file attachment URL selection behavior.

…attachments

A SharePoint/OneDrive file shared in a personal or group chat arrives as a
`application/vnd.microsoft.teams.file.download.info` attachment that carries
BOTH a top-level `contentUrl` (the SharePoint/OneDrive item, which returns 403
to an anonymous GET) and a nested `content.downloadUrl` — a short-lived
pre-signed link the Bot Framework docs say to issue an HTTP GET against.

Upstream Teams (`adapter-teams/src/index.ts:833`, `const url = att.contentUrl`)
and our pre-fix adapter read `contentUrl` only, so the attachment is
undownloadable (`fetch_data` 403s). `_create_attachment` now prefers
`content.downloadUrl` for that attachment type; every other attachment
(inline images, etc.) keeps the upstream `contentUrl`-first path.

The resulting URL flows through the unchanged `_build_teams_fetch_data` /
`rehydrate_attachment` SSRF allowlist — SharePoint/OneDrive download hosts are
already covered by `*.sharepoint.com` / `*.onedrive.com`, so no host was added.

Justified Python-only divergence (hard-UX-failure: attachment cannot be
downloaded). Documented in docs/UPSTREAM_SYNC.md Known Non-Parity; to be filed
upstream. Supersedes stale PR #136.

Tests: TestFileDownloadInfoAttachment — downloadUrl preferred over a 403-ing
contentUrl, downloadUrl used when contentUrl absent, regular attachment with a
competing downloadUrl unchanged, downloadUrl flows through the trusted fetch,
and the downloadUrl path is still gated by the SSRF allowlist. Each fails on a
plausible mutation (verified against contentUrl-only revert, over-eager
downloadUrl-for-all, and dropped-SSRF-gate mutations).
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e282052-b6e9-4a46-bae5-09040a73d6ec

📥 Commits

Reviewing files that changed from the base of the PR and between 3d51efe and 06a790a.

📒 Files selected for processing (3)
  • docs/UPSTREAM_SYNC.md
  • src/chat_sdk/adapters/teams/adapter.py
  • tests/test_teams_adapter.py

📝 Walkthrough

Walkthrough

TeamsAdapter._create_attachment now selects content.downloadUrl (the nested pre-signed URL) over the top-level contentUrl when the attachment type is application/vnd.microsoft.teams.file.download.info. Five new regression tests cover URL preference, absent contentUrl, non-regression for other attachment types, trusted fetch routing, and SSRF gating. A non-parity entry is added to docs/UPSTREAM_SYNC.md.

Changes

Teams file.download.info URL selection

Layer / File(s) Summary
URL selection logic and non-parity doc
src/chat_sdk/adapters/teams/adapter.py, docs/UPSTREAM_SYNC.md
_create_attachment adds a branch for application/vnd.microsoft.teams.file.download.info that picks content.downloadUrl when present, falling back to contentUrl for all other types; UPSTREAM_SYNC.md records this as a known TypeScript SDK divergence.
Regression test suite
tests/test_teams_adapter.py
Adds _FILE_DOWNLOAD_INFO constant, _file_download_activity() fixture builder, and five test cases: prefers downloadUrl over 403 contentUrl, uses downloadUrl when contentUrl is absent, leaves non-file.download.info attachments unchanged, routes trusted fetch to downloadUrl via HTTP GET, and rejects untrusted downloadUrl hosts with ValidationError.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • Chinchill-AI/chat-sdk-python#67: Implements the TeamsAdapter._create_attachment URL handling and rehydrate_attachment/fetch_metadata lazy-download infrastructure that this PR's content.downloadUrl selection is layered on top of.

Poem

🐇 Hop hop, the URL's wrong, said the Teams file with a sigh,
The contentUrl returns 403, oh my oh my!
So I peek inside the content, find downloadUrl so bright,
A pre-signed little shortcut — now the fetch goes just right.
SSRF allowlist still guards the gate, no tricks get past this bunny! 🔒

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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 a Python-first divergence in the Teams adapter to prefer the pre-signed content.downloadUrl over the top-level contentUrl for application/vnd.microsoft.teams.file.download.info attachments, resolving a 403 error issue when downloading SharePoint/OneDrive files shared in chats. It also updates the upstream synchronization documentation and adds comprehensive unit tests. The reviewer suggests defensively validating that download_url and content_url are strings to prevent potential AttributeError exceptions when parsing malformed payloads, and using explicit is not None checks.

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 +1248 to +1253
content = att.get("content")
download_url = content.get("downloadUrl") if isinstance(content, dict) else None
if content_type == "application/vnd.microsoft.teams.file.download.info" and download_url:
url = download_url
else:
url = att.get("contentUrl") or download_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.

medium

To ensure robustness and prevent potential unhandled AttributeError exceptions, we should defensively verify that both download_url and content_url are strings. If a malformed payload contains a non-string value (such as a dictionary or list) for these fields, passing them to urlparse inside _is_trusted_teams_download_url will raise an AttributeError (e.g., 'dict' object has no attribute 'find'), which is not caught by the except (ValueError, TypeError): block. Additionally, when checking for these optional string values, use is not None instead of a truthiness check to avoid silently ignoring falsy but valid values (like empty strings).

        content = att.get("content")
        download_url = content.get("downloadUrl") if isinstance(content, dict) else None
        if not isinstance(download_url, str):
            download_url = None
        content_url = att.get("contentUrl")
        if not isinstance(content_url, str):
            content_url = None

        if content_type == "application/vnd.microsoft.teams.file.download.info" and download_url is not None:
            url = download_url
        else:
            url = content_url if content_url is not None else download_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.

@patrick-chinchill patrick-chinchill marked this pull request as ready for review June 20, 2026 05:38
@patrick-chinchill patrick-chinchill merged commit bf2963a into main Jun 20, 2026
8 checks passed
@patrick-chinchill patrick-chinchill deleted the fix/teams-attachment-downloadurl-v2 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