Skip to content

Conversation

@jakejarvis
Copy link
Owner

@jakejarvis jakejarvis commented Nov 16, 2025

Summary by CodeRabbit

  • New Features

    • Secure remote asset fetching with retries, per-attempt timeouts, redirect limits, and size caps
    • Improved favicon and social preview image handling with safer proxying and standardized processing
  • Bug Fixes

    • Blocked access to private/unroutable addresses and better handling of oversized or invalid responses
  • Tests

    • Expanded tests and mocks covering remote asset fetching, DNS resolution, redirects, and error cases

@vercel
Copy link

vercel bot commented Nov 16, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
domainstack Ready Ready Preview Comment Nov 16, 2025 7:13pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 16, 2025

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: implementing a new fetchRemoteAsset function with DNS checks and size limits, which is the primary feature introduced across multiple files in this PR.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/safer-fetches

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 68cfd80 and cb031dd.

📒 Files selected for processing (5)
  • lib/fetch-remote-asset.ts (1 hunks)
  • lib/fetch.ts (4 hunks)
  • server/services/favicon.test.ts (4 hunks)
  • server/services/favicon.ts (4 hunks)
  • server/services/seo.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
server/services/favicon.test.ts (2)
lib/fetch-remote-asset.ts (1)
  • RemoteAssetError (25-34)
server/services/favicon.ts (1)
  • getOrCreateFaviconBlobUrl (173-183)
server/services/seo.ts (5)
lib/fetch.ts (1)
  • fetchWithTimeoutAndRetry (5-35)
lib/fetch-remote-asset.ts (1)
  • fetchRemoteAsset (66-149)
lib/constants/app.ts (1)
  • USER_AGENT (5-7)
lib/image.ts (1)
  • optimizeImageCover (70-79)
lib/storage.ts (1)
  • storeImage (160-208)
server/services/favicon.ts (3)
lib/fetch-remote-asset.ts (2)
  • fetchRemoteAsset (66-149)
  • RemoteAssetError (25-34)
lib/constants/app.ts (1)
  • USER_AGENT (5-7)
lib/image.ts (1)
  • convertBufferToImageCover (15-68)
🔇 Additional comments (15)
lib/fetch-remote-asset.ts (7)

5-12: LGTM! Well-defined security defaults.

The constants are sensibly chosen: localhost blocking, reasonable size/timeout/redirect limits. The 5MB default is appropriate for most assets while preventing memory exhaustion.


36-53: Documentation now correctly reflects the security model.

The updated comments at lines 37 and 49 accurately describe the behavior: relative URLs are resolved against currentUrl, and allowedHosts acts as an additional restriction on top of the non-overridable blocklist.


151-158: LGTM! Proper relative URL resolution.

The toUrl helper correctly implements WHATWG URL resolution with optional base support, enabling the relative URL functionality documented in the options type.


164-253: LGTM! Security validation logic is sound.

The validation order correctly enforces:

  1. Non-overridable blocklist (lines 182-191)
  2. Optional allowlist as additional restriction (lines 193-205)
  3. DNS resolution and private IP checks (lines 207-252)

This matches the documented behavior and follows SSRF hardening best practices.


258-274: LGTM! Proper timeout and manual redirect handling.

The wrapper correctly implements per-request timeouts and uses redirect: "manual" to allow the caller to validate each redirect hop, preventing redirect-based SSRF bypasses.


283-323: LGTM! Efficient streaming with memory safety.

The function correctly reads the response incrementally and aborts immediately when the size limit is exceeded (line 307), preventing unbounded memory consumption. The use of Buffer.concat with a known size (line 322) is efficient.


325-333: LGTM! Robust IP blocking logic.

Using ipaddr.js to check range !== "unicast" is a sound approach that blocks private, loopback, link-local, multicast, and other non-public ranges. The catch block correctly defaults to blocking on parse errors.

server/services/favicon.test.ts (2)

39-58: LGTM! Clean mock setup for asset fetching.

The hoisted mock for fetchRemoteAsset provides a flexible stub that can be reconfigured per test, and the module mock properly preserves other exports while replacing the target function.


145-165: LGTM! Negative-cache test properly validates caching behavior.

The test correctly uses mockRejectedValueOnce chained four times (one per source) and verifies the second call hits the negative cache without fetching, as suggested in the past review.

server/services/seo.ts (2)

132-159: LGTM! Appropriate use of trusted fetch for HTML.

Using fetchWithTimeoutAndRetry for the domain's HTML is correct—these are trusted upstream sources being analyzed, not user-provided URLs. The separation from the hardened fetchRemoteAsset (used for images) is appropriate.


193-228: LGTM! Secure image proxying through blob storage.

The implementation correctly:

  • Uses fetchRemoteAsset for untrusted image URLs (line 196)
  • Passes currentUrl to resolve relative URLs (line 198)
  • Enforces size limits and redirects (lines 204-206)
  • Degrades gracefully on failure (lines 225-227)
lib/fetch.ts (2)

1-35: LGTM! Proper retry logic with external signal awareness.

The renamed fetchWithTimeoutAndRetry correctly implements:

  • Per-attempt timeout via createAbortSignal (line 17)
  • External signal propagation (line 25-27)
  • Linear backoff between retries (line 30)
  • Cleanup after each attempt (lines 20, 24)

The documentation accurately warns against using this for user-controlled URLs (lines 1-3).


139-214: LGTM! Robust signal composition with runtime compatibility.

The helper correctly handles three cases:

  1. Simple timeout-only (lines 153-158)
  2. Native AbortSignal.any() when available (lines 166-170)
  3. Manual composition fallback (lines 173-208)

The manual fallback properly handles already-aborted signals (lines 190-207) and ensures cleanup via event listener removal.

server/services/favicon.ts (2)

60-80: LGTM! Proper migration to secure asset fetching.

The fetchRemoteAsset call correctly:

  • Enforces 1MB size limit (line 66)
  • Allows HTTP for http:// sources (line 69)
  • Limits redirects to 2 (line 68)
  • Passes appropriate headers (lines 62-65)
  • Uses asset.buffer and asset.contentType for downstream processing (lines 72, 78)

122-134: LGTM! Proper distinction between 404s and transient errors.

The error handling correctly differentiates:

  • True 404s (lines 123-128): keep allNotFound true for negative caching
  • Other errors like timeouts/network failures (line 130): set allNotFound false to avoid incorrectly caching transient failures

This ensures negative caching only happens when all sources genuinely don't have the resource.


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.

@codecov
Copy link

codecov bot commented Nov 16, 2025

Codecov Report

❌ Patch coverage is 50.30303% with 82 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.25%. Comparing base (b30320d) to head (cb031dd).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
lib/fetch-remote-asset.ts 56.88% 33 Missing and 14 partials ⚠️
lib/fetch.ts 30.76% 25 Missing and 2 partials ⚠️
server/services/seo.ts 37.50% 5 Missing ⚠️
server/services/favicon.ts 62.50% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #183      +/-   ##
==========================================
- Coverage   69.09%   68.25%   -0.85%     
==========================================
  Files         107      108       +1     
  Lines        3042     3122      +80     
  Branches      922      941      +19     
==========================================
+ Hits         2102     2131      +29     
- Misses        580      626      +46     
- Partials      360      365       +5     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
lib/fetch.ts (2)

1-33: Consider combining external and internal AbortSignals for caller cancellation

The function currently creates its own AbortController and ignores any init.signal, preventing callers from cancelling in-flight retries. The modern WHATWG pattern is to combine an external signal with an internal timeout signal using AbortSignal.any([externalSignal, AbortSignal.timeout(timeoutMs)]). This allows both the internal timeout and external cancellation to work correctly while preserving distinct abort reasons. Available in Node 20+ and modern browsers; for older runtimes, a small polyfill combining signals via listeners is widely used. This is an optional improvement—the current behavior is acceptable if external cancellation isn't needed.


35-68: Update comment to clarify strict hostname-equality behavior, or implement true registrable-domain logic

The mismatch is confirmed. Tests verify the function blocks different domains but do not cover subdomain scenarios (e.g., example.comblog.example.com), which your code would reject despite the comment's "registrable domain" wording.

The code enforces strict hostname equality post-www-normalization (line 58: if (fromHost !== toHost) return false;), not the registrable-domain (eTLD+1) semantics referenced in the comment. Web security standards treat the registrable domain (eTLD+1) as the relevant unit for same-site decisions—meaning blog.example.com and example.com are the same site, but your implementation would block such redirects.

Either:

  1. Change line 57 comment from "registrable domain" to "hostname" to reflect the stricter actual behavior, or
  2. Compute true registrable domains (e.g., using a Public Suffix List library) to match the comment's promise.
server/services/favicon.ts (1)

60-133: Prevent 200 responses from setting notFound permanently

As soon as fetchRemoteAsset succeeds we should consider this source “found”, even if conversion/storage fails later. Today allNotFound only flips to false on caught errors; when conversion returns null we continue with allNotFound still true, and after exhausting sources we persist notFound: true. That permanently short-circuits future favicon fetches for domains that served an image but we couldn’t process (e.g., unsupported format). Please set allNotFound = false immediately after the successful fetch so we only mark notFound when every source really returned a 404.

       const asset = await fetchRemoteAsset({
         url: src,
         headers: {
           Accept: "image/avif,image/webp,image/png,image/*;q=0.9,*/*;q=0.8",
           "User-Agent": USER_AGENT,
         },
         maxBytes: MAX_FAVICON_BYTES,
         timeoutMs: REQUEST_TIMEOUT_MS,
         maxRedirects: 2,
         allowHttp: src.startsWith("http://"),
       });
+      allNotFound = false;
       const buf = asset.buffer;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b30320d and 68cfd80.

📒 Files selected for processing (9)
  • lib/fetch-remote-asset.test.ts (1 hunks)
  • lib/fetch-remote-asset.ts (1 hunks)
  • lib/fetch.test.ts (4 hunks)
  • lib/fetch.ts (3 hunks)
  • server/services/dns.ts (2 hunks)
  • server/services/favicon.test.ts (4 hunks)
  • server/services/favicon.ts (4 hunks)
  • server/services/seo.test.ts (6 hunks)
  • server/services/seo.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
lib/fetch-remote-asset.test.ts (1)
lib/fetch-remote-asset.ts (2)
  • fetchRemoteAsset (64-147)
  • RemoteAssetError (25-34)
lib/fetch.test.ts (1)
lib/fetch.ts (1)
  • fetchWithTimeoutAndRetry (5-33)
server/services/dns.ts (1)
lib/fetch.ts (1)
  • fetchWithTimeoutAndRetry (5-33)
server/services/seo.test.ts (1)
server/services/seo.ts (1)
  • getSeo (31-288)
server/services/seo.ts (5)
lib/fetch.ts (1)
  • fetchWithTimeoutAndRetry (5-33)
lib/fetch-remote-asset.ts (1)
  • fetchRemoteAsset (64-147)
lib/constants/app.ts (1)
  • USER_AGENT (5-7)
lib/image.ts (1)
  • optimizeImageCover (70-79)
lib/storage.ts (1)
  • storeImage (160-208)
server/services/favicon.test.ts (2)
lib/fetch-remote-asset.ts (1)
  • RemoteAssetError (25-34)
server/services/favicon.ts (1)
  • getOrCreateFaviconBlobUrl (172-182)
server/services/favicon.ts (2)
lib/fetch-remote-asset.ts (2)
  • fetchRemoteAsset (64-147)
  • RemoteAssetError (25-34)
lib/image.ts (1)
  • convertBufferToImageCover (15-68)
🔇 Additional comments (4)
lib/fetch-remote-asset.test.ts (1)

8-24: Deterministic test harness for fetch + DNS looks solid

Use of vi.hoisted plus beforeEach to reset fetchMock and dnsLookupMock makes these tests deterministic and isolates DNS behavior from the network. The private IP test correctly asserts that no fetch occurs when DNS resolves to a blocked address.

Also applies to: 64-72

lib/fetch.test.ts (1)

44-57: Tests accurately exercise timeout and retry behavior

The updated tests for fetchWithTimeoutAndRetry cover success-before-timeout, abort-on-timeout, and a retry-with-backoff success path, matching the helper’s semantics and guarding against future regressions in the retry loop.

Also applies to: 65-82, 85-107

server/services/favicon.test.ts (1)

11-12: fetchRemoteAsset mocking pattern is clean and preserves real exports

Using a hoisted fetchRemoteAssetMock and a partial module mock that spreads importActual keeps types like RemoteAssetError real while swapping out just the network call, which is ideal for these tests. This isolates favicon behavior from network variability without sacrificing type correctness.

Also applies to: 39-58

lib/fetch-remote-asset.ts (1)

194-251: SSRF/IP range checks confirmed effective

Verification confirms ipaddr.js range() correctly classifies only globally-routable public addresses as "unicast" while reserving all non-public ranges (loopback, private, link-local, ULA, etc.) as non-"unicast". The isBlockedIp check effectively blocks all SSRF vectors for both IPv4 and IPv6.

Comment on lines +26 to +43
it("returns buffer and content type for valid asset", async () => {
const body = new Uint8Array([1, 2, 3]);
fetchMock.mockResolvedValueOnce(
new Response(body, {
status: 200,
headers: { "content-type": "image/png" },
}),
);

const result = await fetchRemoteAsset({
url: "https://example.com/image.png",
maxBytes: 1024,
});

expect(Buffer.isBuffer(result.buffer)).toBe(true);
expect(result.contentType).toBe("image/png");
expect(result.finalUrl).toBe("https://example.com/image.png");
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding tests for remaining RemoteAssetError cases

Happy-path, protocol gating, redirect, private IP, and size_exceeded behaviors are well covered. To lock in the error surface, consider adding tests for invalid_url, host_not_allowed, host_blocked, dns_error, and redirect_limit so regressions on those branches are caught early.

Also applies to: 45-52, 74-97, 99-116

🤖 Prompt for AI Agents
In lib/fetch-remote-asset.test.ts around lines 26 to 43 (and similarly for
ranges 45-52, 74-97, 99-116), the test suite covers successful and several error
paths but omits unit tests asserting the RemoteAssetError variants; add focused
tests that exercise and assert each error type: invalid_url (pass malformed URL
and expect RemoteAssetError with code "invalid_url"),
host_not_allowed/host_blocked (set allowed/blocked host config and expect
respective error codes), dns_error (mock fetch or DNS resolution to throw a
network/DNS error and assert "dns_error"), and redirect_limit (mock fetch to
return a chain of redirects exceeding the redirect limit and assert
"redirect_limit"). For each test, use fetchMock to simulate responses/errors,
set maxBytes/allowed hosts/redirect settings as needed, and assert the thrown
error.code and any relevant properties (e.g., finalUrl) to lock the error
surface.

@jakejarvis jakejarvis merged commit 2be7449 into main Nov 17, 2025
6 checks passed
@jakejarvis jakejarvis deleted the feat/safer-fetches branch November 17, 2025 00:24
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.

2 participants