Harden collectible metadata fetch with bounded reads#2719
Conversation
Introduces a dedicated helper that wraps fetch() with URL scheme validation (https-only), an AbortController-backed 5-second timeout, a Content-Length pre-check, and a streaming byte counter so response bodies are read within a 1 MB cap. When the current runtime does not expose response.body.getReader, the helper falls back to response.text() followed by a byte-length check. Errors include the offending protocol when the scheme check rejects, for easier diagnosis. Cancellation-side rejections from reader.cancel() are swallowed so the original "too large" message always wins. 23 Jest tests cover URL validation, response handling, size limits via both Content-Length and streaming, the getReader-missing fallback, and timeout behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Json - Validate the final response.url after fetch resolves, not just the input URL. If a 3xx redirect lands on a non-https scheme (e.g. https→http) the helper now rejects instead of silently following through. Covered by two new tests (redirect-bypass rejected, https→https redirect accepted). - On the early-exit failure paths (non-OK status, oversized Content-Length, redirect-to-non-https) call response.body?.cancel?.() before throwing so streaming implementations don't keep downloading into a discarded response. Covered by new tests asserting the cancel call. - JSDoc updated to describe the redirect check and the body-cancel behavior on early exits. Mirrors stellar/freighter-mobile#835 (e3dd3cf8). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ataJson Replaces the inline fetch()/response.json() pair inside fetchCollectibleMetadata with a single call through the new fetchMetadataJson<CollectibleMetadataResponse>(tokenUri) helper. The outer try/catch remains so any helper rejection is still surfaced as a null return — UI callers fall back to the existing default-name / broken-image placeholder behaviour unchanged. The accompanying Jest suite switches from mocking global.fetch for the metadata leg to mocking the helper via jest.mock. global.fetch remains spied only for the /collectibles indexer endpoint; any unexpected URL now explicitly rejects, which surfaces drift instead of masking it. Preserves the intentional decision not to report metadata failures to Sentry — third-party hosts fail often and flooding the tracker is counter-productive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens third-party NFT metadata fetching by introducing a dedicated helper that enforces HTTPS-only requests and bounds reads by time and response size, then routing collectible metadata retrieval through that helper to preserve the existing UI contract (return null on failure).
Changes:
- Add
fetchMetadataJsonhelper with HTTPS validation (including post-redirect), 5s timeout, and 1MB response cap with streaming reads + cancellation. - Update
fetchCollectibleMetadatato use the new helper while keeping the existingtry/catch → nullbehavior. - Add comprehensive Jest coverage for the helper and update
fetchCollectiblestests to mock at the helper seam.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| @shared/api/helpers/fetchMetadataJson.ts | New bounded/validated JSON fetch helper for NFT metadata. |
| @shared/api/helpers/fetchCollectibles.ts | Route collectible metadata fetch through fetchMetadataJson. |
| @shared/api/helpers/tests/fetchMetadataJson.test.ts | New test suite covering URL validation, redirects, timeout, and size limits. |
| @shared/api/helpers/tests/fetchCollectibles.test.ts | Update tests to mock fetchMetadataJson instead of global.fetch for metadata. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Addresses PR review feedback. If response.url is present but cannot be parsed we can't confirm the scheme, so we can't confirm we weren't redirected away from https — treat that as a rejection rather than silently proceeding. One new test covers the unparseable case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
piyalbasu
left a comment
There was a problem hiding this comment.
Looks good to me. Confirmed on Slack that a user sees sane defaults and is not blocked when metadata cannot be parsed
https://stellarfoundation.slack.com/archives/C03347FNAHK/p1776976823841349
Summary
Adds a dedicated
fetchMetadataJsonhelper and routesfetchCollectibleMetadatathrough it so NFT metadata reads are HTTPS-only, bounded in time and size, and robust against redirect-driven scheme downgrades.The helper enforces, in order:
https:; the rejection message includes the offending protocol for easier diagnosis.fetchresolves,response.urlis re-validated so a 3xx redirect that lands on a non-https scheme (e.g.https:→http:) is rejected instead of silently followed.AbortControlleraborts the in-flight request so slow or unreachable hosts don't leave work hanging.Content-Lengthis pre-checked; the body is then streamed viaresponse.body.getReader()with a running byte counter, and the reader is cancelled if the accumulated bytes exceed the cap. Falls back toresponse.text()+ byte-length check whengetReaderis unavailable (defense-in-depth; modern runtimes always expose it).Content-Length, and redirect-to-non-https all callresponse.body.cancel()before throwing so streaming runtimes don't continue downloading into a discarded response.fetchCollectibleMetadatakeeps its existingtry/catch → return nullwrapper, so the UI-facing contract is unchanged (null metadata still falls back to the default name / broken-image placeholder). The existing decision to skipcaptureExceptionfor metadata failures is preserved — third-party metadata hosts fail often enough that flooding Sentry is counter-productive.Only the NFT collectible metadata path is touched. The sibling
fetchCollectiblesfunction (which talks to the internal Freighter BE indexer) and every otherfetchcall site in the extension are untouched; the helper is also not routed into them.27 Jest cases cover the helper (URL validation, response handling, size limits via both Content-Length and streamed paths, fallback path, timeout, redirect re-check, body cancellation on all early exits). The sibling
fetchCollectiblestest is updated to mock the helper at its seam instead of atglobal.fetch, and an unexpected-URLfetchmock now rejects explicitly to surface drift.Screen.Recording.2026-04-23.at.13.33.16.mov
When metadata fetching fails
Screen.Recording.2026-04-23.at.14.07.12.mov
🤖 Generated with Claude Code