Skip to content

Commit 06a790a

Browse files
fix(teams): fall back to content.downloadUrl for SharePoint/OneDrive 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).
1 parent 3d51efe commit 06a790a

3 files changed

Lines changed: 177 additions & 1 deletion

File tree

docs/UPSTREAM_SYNC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@ stay explicit instead of being rediscovered in code review.
663663
| Redis lock token format | `{token_prefix}_{ms}_{secrets.token_hex(16)}` — always 32 hex chars, CSPRNG-sourced | `ioredis_${Date.now()}_${Math.random().toString(36).substring(2, 15)}` — base36, ≤13 chars, **not** CSPRNG | Interop via `IoRedisStateAdapter(token_prefix="ioredis")` still works for lock-release (release/extend compare by full-string equality, and each runtime only releases what it issued), but the token byte-shape diverges. Intentional — CSPRNG should not be regressed to `Math.random()` for cosmetic byte-for-byte compatibility. |
664664
| `StreamingPlan.is_supported()` / `get_fallback_text()` | Raise `RuntimeError` to fail loudly if a generic posting path (e.g. `ChannelImpl.post`, `post_postable_object`) tries to consume a `StreamingPlan` as a normal `PostableObject` | Silently return `True` / `""``ChannelImpl.post` would route through `postPostableObject` and post an empty-string fallback | Prevents `StreamingPlan` being silently routed through non-stream-aware posting paths where upstream would post a blank message or attempt a wrong-shape `adapter.post_object("stream", ...)` call. Internal dispatch is guarded by the `kind == "stream"` short-circuit in `post_postable_object` / `Thread.post`; this also protects third-party code that duck-types PostableObjects. |
665665
| `rehydrate_attachment` URL allowlist (Slack / Teams / Google Chat / Twilio) | Validates the downloaded URL's scheme (https) + host against a per-adapter allowlist inside the fetch closure; raises `ValidationError` on untrusted hosts before forwarding bearer/Basic credentials | No validation — `fetchData` blindly GETs `fetchMetadata.url` (Twilio: `fetchMetadata.twilioMediaUrl`) and forwards the workspace/bot token (Twilio: the account SID + auth token as HTTP Basic) | SSRF + token-exfil risk upstream: after the 4.26 `rehydrateAttachment` hook lands, a crafted `fetchMetadata` in persisted state can redirect auth'd downloads to an arbitrary host. The Twilio adapter (vercel/chat#558) shares the exact pattern — `fetchTwilioMedia` GETs the rehydrated URL with the adapter's Basic auth and no host check. Python port enforces `CLAUDE.md`'s "Validate external URLs before requests (SSRF)" rule. The check runs inside the download closure (not at build time) so an attachment trusted at parse time still fails closed if the allowlist tightens later. Allowlist: Slack = `{files.slack.com, slack.com, *.slack.com, *.slack-edge.com}`; Teams = `{smba.trafficmanager.net, graph.microsoft.com, attachments.office.net, *.botframework.com, *.graph.microsoft.com, *.sharepoint.com, *.officeapps.live.com, *.office.com, *.office365.com, *.onedrive.com, *.microsoft.com}`; Google Chat = `{chat.googleapis.com, googleapis.com, *.googleapis.com, *.googleusercontent.com, *.google.com}`; Twilio = `{twilio.com, api.twilio.com, *.twilio.com, *.twiliocdn.com}`. Regression coverage: `tests/test_twilio_adapter.py::TestRehydrateAttachment::test_media_downloader_refuses_untrusted_hosts`. |
666+
| Teams file attachment URL source (`_create_attachment`) | For a `application/vnd.microsoft.teams.file.download.info` attachment, prefers the nested `content.downloadUrl` (a short-lived pre-signed link) over the top-level `contentUrl`. Every other attachment type keeps the upstream `contentUrl`-first path (with `content.downloadUrl` only as a fallback when `contentUrl` is missing/falsy). | Reads `att.contentUrl` only — `createAttachment(att)`'s param type doesn't even include `content` (`adapter-teams/src/index.ts:833`, `const url = att.contentUrl`) | **Hard-UX-failure divergence.** A SharePoint/OneDrive file shared in a personal or group chat arrives as a `file.download.info` attachment that carries BOTH a top-level `contentUrl` (the SharePoint/OneDrive item, e.g. `https://contoso.sharepoint.com/.../file.txt`, which returns **403** to an anonymous GET because it needs a SharePoint auth context) AND a nested `content.downloadUrl` — a pre-authenticated link the [Bot Framework docs](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4#message-activity-with-file-attachment-example) explicitly say to issue an `HTTP GET` against. Upstream (and our pre-fix adapter) read `contentUrl` only, so the attachment is **undownloadable** (`fetch_data` 403s). We prefer `content.downloadUrl` for this attachment type so the download actually works. The resulting URL still flows through the unchanged `_build_teams_fetch_data` / `rehydrate_attachment` SSRF allowlist (download hosts are SharePoint/OneDrive for Business → already covered by `*.sharepoint.com` / `*.onedrive.com` in the row above — no host added). To be filed as an upstream issue against vercel/chat. Supersedes stale PR #136. Regression coverage: `tests/test_teams_adapter.py::TestFileDownloadInfoAttachment`. |
666667
| `_rehydrate_message` with `Message` input | Falls through to the `rehydrate_attachment` pass even when the dequeued entry is already a `Message` instance | Early-returns on `raw instanceof Message` before rehydration | The Python port's Redis + Postgres `dequeue()` upgrade raw JSON to `Message.from_json(...)` before returning (upstream's dequeue returns the raw JSON.parse'd dict). Upstream's `instanceof Message` shortcut therefore only fires for in-memory state, but ours would fire for persistent backends too, leaving `fetch_data` stripped forever. The rehydrate pass still skips any attachment that already has `fetch_data`, so in-memory callers pay no cost. |
667668
| Slack Socket Mode reconnect loop | Outer reconnect loop on top of `slack_sdk.socket_mode.aiohttp.SocketModeClient` (which itself has `auto_reconnect_enabled=True`). Exponential backoff (1s → 30s) with explicit shutdown signaling and a tracked `asyncio.Task` so `disconnect()` can cancel cleanly | Single `SocketModeClient` instance from `@slack/socket-mode`; relies entirely on the package's internal reconnect | Hazard #5 (async task lifecycle): a long-lived WebSocket needs an explicit shutdown path so `disconnect()` doesn't leak the loop, and a guarded outer reconnect path so the adapter survives `connect()` itself raising (which the inner client doesn't retry). Inner auto-reconnect still runs; the outer loop is belt-and-suspenders, not a divergence in observable behavior. |
668669
| Slack Socket Mode listener serverless variant | Not ported | `startSocketModeListener()` / `runSocketModeListener()` open a transient socket for `durationMs` and forward events via HTTP POST | Vercel-specific pattern (cron-triggered ephemeral listener with `waitUntil`). The forwarded-event receiver (`x-slack-socket-token` handling in `handle_webhook`) is ported so a separate Python process can run the long-lived listener; the deployment glue itself isn't part of the SDK. |

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,22 @@ def _create_attachment(self, att: dict[str, Any]) -> Attachment:
12351235
elif content_type.startswith("audio/"):
12361236
att_type = "audio"
12371237

1238-
url = att.get("contentUrl")
1238+
# Python-first divergence (upstream reads ``contentUrl`` only; see
1239+
# adapter-teams/src/index.ts:833 and docs/UPSTREAM_SYNC.md Known
1240+
# Non-Parity). A SharePoint/OneDrive file shared in a personal or group
1241+
# chat arrives as a ``application/vnd.microsoft.teams.file.download.info``
1242+
# attachment that carries BOTH a top-level ``contentUrl`` (pointing at the
1243+
# SharePoint/OneDrive item, which 403s on an anonymous GET) and a nested
1244+
# ``content.downloadUrl`` — a short-lived pre-signed link that fetches with
1245+
# no auth header. For that attachment type the top-level URL is unusable,
1246+
# so we prefer the pre-signed ``content.downloadUrl``. Every other
1247+
# attachment (inline images, etc.) keeps the upstream ``contentUrl`` path.
1248+
content = att.get("content")
1249+
download_url = content.get("downloadUrl") if isinstance(content, dict) else None
1250+
if content_type == "application/vnd.microsoft.teams.file.download.info" and download_url:
1251+
url = download_url
1252+
else:
1253+
url = att.get("contentUrl") or download_url
12391254
return Attachment(
12401255
type=att_type,
12411256
url=url,

tests/test_teams_adapter.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,166 @@ def test_is_trusted_teams_download_url_allowlist(self):
511511
assert not TeamsAdapter._is_trusted_teams_download_url("https://graph.microsoft.com.attacker.tld/x")
512512

513513

514+
# ---------------------------------------------------------------------------
515+
# file.download.info attachments (content.downloadUrl fallback)
516+
#
517+
# Python-first divergence (supersedes stale PR #136). A SharePoint/OneDrive
518+
# file shared in a personal/group chat arrives as a
519+
# ``application/vnd.microsoft.teams.file.download.info`` attachment whose
520+
# top-level ``contentUrl`` (the SharePoint item) 403s on an anonymous GET,
521+
# while the nested ``content.downloadUrl`` is a pre-signed link that works.
522+
# Upstream reads ``contentUrl`` only (adapter-teams/src/index.ts:833), so the
523+
# attachment is undownloadable. We prefer ``content.downloadUrl`` for this
524+
# attachment type. See docs/UPSTREAM_SYNC.md Known Non-Parity.
525+
# ---------------------------------------------------------------------------
526+
527+
_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info"
528+
529+
530+
def _file_download_activity(attachment: dict) -> dict:
531+
return {
532+
"type": "message",
533+
"id": "msg-file-dl",
534+
"text": "here is a file",
535+
"from": {"id": "user-1", "name": "Alice"},
536+
"conversation": {"id": "19:abc@thread.tacv2"},
537+
"serviceUrl": "https://smba.trafficmanager.net/teams/",
538+
"attachments": [attachment],
539+
}
540+
541+
542+
class TestFileDownloadInfoAttachment:
543+
def test_prefers_download_url_over_403_content_url(self):
544+
"""A file.download.info attachment carries BOTH a (403-ing) contentUrl
545+
and a pre-signed content.downloadUrl; the Attachment must use the
546+
downloadUrl, not the SharePoint contentUrl.
547+
548+
Pins the exact URL (not just "non-None") so a mutation that reverts to
549+
``contentUrl`` is caught: the SharePoint item URL would surface instead.
550+
"""
551+
adapter = _make_adapter(app_id="test-app")
552+
content_url = "https://contoso.sharepoint.com/personal/jadams/Documents/report.pdf"
553+
download_url = "https://contoso.sharepoint.com/_layouts/download.aspx?presigned=abc123"
554+
activity = _file_download_activity(
555+
{
556+
"contentType": _FILE_DOWNLOAD_INFO,
557+
"contentUrl": content_url,
558+
"name": "report.pdf",
559+
"content": {
560+
"downloadUrl": download_url,
561+
"uniqueId": "1150D938-8870-4044-9F2C-5BBDEBA70C9D",
562+
"fileType": "pdf",
563+
},
564+
}
565+
)
566+
msg = adapter.parse_message(activity)
567+
assert len(msg.attachments) == 1
568+
att = msg.attachments[0]
569+
assert att.url == download_url
570+
assert att.url != content_url
571+
# fetch_metadata must carry the SAME (working) URL so rehydrate rebuilds
572+
# the closure around the pre-signed link, not the 403-ing one.
573+
assert att.fetch_metadata == {"url": download_url}
574+
assert att.name == "report.pdf"
575+
assert att.type == "file"
576+
577+
def test_uses_download_url_when_content_url_absent(self):
578+
"""A file.download.info attachment with no top-level contentUrl still
579+
yields a downloadable Attachment via content.downloadUrl."""
580+
adapter = _make_adapter(app_id="test-app")
581+
download_url = "https://contoso.sharepoint.com/_layouts/download.aspx?presigned=xyz"
582+
activity = _file_download_activity(
583+
{
584+
"contentType": _FILE_DOWNLOAD_INFO,
585+
"name": "notes.txt",
586+
"content": {"downloadUrl": download_url, "fileType": "txt"},
587+
}
588+
)
589+
msg = adapter.parse_message(activity)
590+
assert len(msg.attachments) == 1
591+
att = msg.attachments[0]
592+
assert att.url == download_url
593+
assert att.fetch_metadata == {"url": download_url}
594+
assert att.fetch_data is not None
595+
596+
def test_regular_attachment_uses_content_url_unchanged(self):
597+
"""An ordinary (inline image) attachment keeps the upstream contentUrl
598+
path — the downloadUrl fallback must not perturb it.
599+
600+
The attachment deliberately ALSO carries a ``content.downloadUrl`` so an
601+
over-eager mutation that prefers ``content.downloadUrl`` for *every*
602+
attachment type (rather than only ``file.download.info``) is caught: it
603+
would surface the downloadUrl instead of the inline-image contentUrl.
604+
"""
605+
adapter = _make_adapter(app_id="test-app")
606+
content_url = "https://smba.trafficmanager.net/teams/v3/attachments/img/photo.png"
607+
activity = _file_download_activity(
608+
{
609+
"contentType": "image/png",
610+
"contentUrl": content_url,
611+
"name": "photo.png",
612+
# A bogus competing downloadUrl that must be IGNORED for a
613+
# non-file.download.info attachment.
614+
"content": {"downloadUrl": "https://attacker.example.com/wrong.png"},
615+
}
616+
)
617+
msg = adapter.parse_message(activity)
618+
assert len(msg.attachments) == 1
619+
att = msg.attachments[0]
620+
assert att.url == content_url
621+
assert att.type == "image"
622+
assert att.fetch_metadata == {"url": content_url}
623+
624+
@pytest.mark.asyncio
625+
async def test_download_url_flows_through_trusted_fetch(self):
626+
"""The pre-signed downloadUrl becomes the URL the SSRF-gated fetch
627+
closure GETs — proving the fallback URL is what actually gets fetched.
628+
629+
A SharePoint host is in the Teams allowlist, so the GET proceeds and
630+
returns the stubbed bytes.
631+
"""
632+
adapter = _make_adapter(app_id="test-app")
633+
download_url = "https://contoso.sharepoint.com/_layouts/download.aspx?presigned=ok"
634+
session = _MockAiohttpSession(payload=b"file-contents")
635+
adapter._get_http_session = AsyncMock(return_value=session) # type: ignore[method-assign]
636+
637+
activity = _file_download_activity(
638+
{
639+
"contentType": _FILE_DOWNLOAD_INFO,
640+
"contentUrl": "https://contoso.sharepoint.com/personal/jadams/Documents/x.pdf",
641+
"name": "x.pdf",
642+
"content": {"downloadUrl": download_url, "fileType": "pdf"},
643+
}
644+
)
645+
att = adapter.parse_message(activity).attachments[0]
646+
assert att.fetch_data is not None
647+
assert await att.fetch_data() == b"file-contents"
648+
# The pre-signed URL — not the SharePoint item — is what gets fetched.
649+
assert session.get_calls == [download_url]
650+
651+
@pytest.mark.asyncio
652+
async def test_download_url_still_gated_by_ssrf_allowlist(self):
653+
"""Even via the downloadUrl path, an untrusted host fails closed at
654+
fetch time — the fallback does NOT bypass the SSRF allowlist."""
655+
adapter = _make_adapter(app_id="test-app")
656+
# If the gate were bypassed, the session would be awaited — it must not.
657+
adapter._get_http_session = AsyncMock() # type: ignore[method-assign]
658+
659+
activity = _file_download_activity(
660+
{
661+
"contentType": _FILE_DOWNLOAD_INFO,
662+
"name": "evil.bin",
663+
"content": {"downloadUrl": "https://attacker.example.com/exfil.bin"},
664+
}
665+
)
666+
att = adapter.parse_message(activity).attachments[0]
667+
assert att.url == "https://attacker.example.com/exfil.bin"
668+
assert att.fetch_data is not None
669+
with pytest.raises(ValidationError):
670+
await att.fetch_data()
671+
adapter._get_http_session.assert_not_awaited()
672+
673+
514674
# ---------------------------------------------------------------------------
515675
# normalizeMentions (via parseMessage)
516676
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)