You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Second review pass on PR #67 (rehydrate_attachment). The previous fixup
addressed pyrefly only — this commit resolves the remaining review
feedback.
SSRF guards (3 adapters)
- Slack, Teams, Google Chat all rebuild fetch_data closures from
serialized fetch_metadata["url"] in rehydrate_attachment. A tampered
URL in persisted queue state could exfiltrate the workspace
bot/OAuth token to an attacker-controlled host. Each adapter now
validates the URL's scheme (https only) and host against a
platform-specific allowlist before forwarding the auth header.
Upstream TS does not validate; this is a Python-first divergence
documented in docs/UPSTREAM_SYNC.md.
- Slack: files.slack.com, slack.com, *.slack.com, *.slack-edge.com
- Teams: Microsoft-owned hosts (graph.microsoft.com,
smba.trafficmanager.net, *.sharepoint.com, *.botframework.com,
*.office.com, attachments.office.net, …)
- Google Chat: chat.googleapis.com, *.googleapis.com,
*.googleusercontent.com, *.google.com
Message-instance rehydration (P1)
- Chat._rehydrate_message used to early-return on Message inputs,
matching upstream TS's `raw instanceof Message` shortcut. That
shortcut is safe in upstream because its state adapters return raw
JSON dicts from dequeue. Our RedisStateAdapter / PostgresStateAdapter
both upgrade the dequeued dict to `Message.from_json(...)` before
returning, so the early return would skip rehydrate_attachment for
every persistent-backend dequeue and leave fetch_data stripped. We
now fall through and apply the rehydrate pass on Message inputs too
(already-hydrated attachments with fetch_data are filtered out).
Truthiness fallbacks (Port Rule #1)
- telegram, whatsapp rehydrate_attachment and types.py dual-key
fetch_metadata lookup now use explicit `is not None` instead of
`or`, so an empty-dict fetch_metadata is preserved.
Teams connection pooling
- _build_teams_fetch_data used httpx.AsyncClient as a throwaway
context manager per download. Refactored to use the shared
aiohttp session (_get_http_session) that the rest of the adapter
already goes through.
Test hardening
- test_slack_webhook.py and test_teams_adapter.py now stub the fetch
path with AsyncMock, await rehydrated.fetch_data(), and assert the
URL + token that were forwarded. Previously the tests only checked
that `fetch_data is not None` — they would have passed even if
rehydration returned a dummy closure.
- New tests per adapter verify the SSRF guard rejects untrusted hosts
and the allowlist accepts the intended Slack/Teams/GCP hosts.
- New regression test in test_chat_faithful.py drives a Message-
instance dequeue through the chat queue and asserts
rehydrate_attachment still fires.
Slack adapter connection pooling (deferred)
- _fetch_slack_file still uses httpx.AsyncClient per call. The Slack
adapter has no pooled aiohttp helper (only slack_sdk.AsyncWebClient
for Slack API calls), so adding one is a larger refactor left for
a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/UPSTREAM_SYNC.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -458,6 +458,8 @@ stay explicit instead of being rediscovered in code review.
458
458
| Teams divider rendering |`card_to_adaptive_card` hoists `separator: True` onto the next sibling (or emits a non-empty Container for a trailing divider) |`convertDividerToElement` emits an empty `Container` with `separator: True`| Upstream shares the same bug: Microsoft Teams renders an empty Container at zero height, so the separator line is effectively invisible. Python port fixes locally (issue #45) rather than blocking on upstream. |
459
459
|`SlackAdapter.current_token` / `current_client`| Public `@property` accessors that return the request-context-bound token and a preconfigured `AsyncWebClient`| Not exposed (`getToken()` is private on the TS `SlackAdapter`) | Python-only addition (issue #47). Downstream code that calls Slack Web APIs from inside a handler — email resolution, user profile fetches, reaction bookkeeping — otherwise depends on underscore-prefixed helpers. |
460
460
|`ConcurrencyConfig.max_concurrent`| Enforced via `asyncio.Semaphore` in the `"concurrent"` strategy path; rejects non-integer or `<= 0` values, and rejects any non-`None``max_concurrent` paired with a non-`"concurrent"` strategy | Accepted into the config type with docstring "Default: Infinity" but never read (3 writes, 0 reads) | Silent correctness bug upstream — consumers setting `max_concurrent=N` with `strategy="concurrent"` reasonably expect an N-way bound on in-flight handlers. We honor the documented contract via a semaphore and fail-fast on misconfiguration so it's never silent. `max_concurrent=None` stays compatible with every strategy (unbounded default). |
461
+
| `rehydrate_attachment` URL allowlist (Slack / Teams / Google Chat) | Validates the downloaded URL's scheme + host against a per-adapter allowlist inside the fetch closure; raises `ValidationError` on untrusted hosts before forwarding bearer tokens | No validation — `fetchData` blindly GETs `fetchMetadata.url` and forwards the workspace/bot token | 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. Python port enforces `CLAUDE.md`'s "Validate external URLs before requests (SSRF)" rule. 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}`. |
462
+
|`_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. |
0 commit comments