Skip to content

Extra ssrf guards#3953

Open
pepeladeira wants to merge 9 commits into
mainfrom
security-ssrf-safe-fetch
Open

Extra ssrf guards#3953
pepeladeira wants to merge 9 commits into
mainfrom
security-ssrf-safe-fetch

Conversation

@pepeladeira
Copy link
Copy Markdown
Collaborator

@pepeladeira pepeladeira commented May 27, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Safer outbound requests: comprehensive SSRF protections, private-IP blocking, stricter redirect and timeout handling, and explicit failures for invalid/non-OK targets.
  • Refactor

    • Iframe eligibility now evaluates response headers with request origin for more reliable embedding decisions.
    • Multiple API flows and sitemap fetching updated to use the safer outbound fetch behavior.
  • Tests

    • Added private-IP detection tests and adjusted sitemap/content fetch mocks; extended test logging mocks.

Review Change Stack

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 27, 2026

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jun 2, 2026 1:03pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99133a9f-b0eb-4b50-9198-fc2a3bb09138

📥 Commits

Reviewing files that changed from the base of the PR and between b3f07f5 and 25a63ca.

📒 Files selected for processing (2)
  • apps/web/lib/sitemaps/import-tracked-sitemaps.ts
  • apps/web/tests/misc/import-tracked-sitemaps.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/tests/misc/import-tracked-sitemaps.test.ts

📝 Walkthrough

Walkthrough

Adds a new SSRF-safe fetch utility with private-IP and DNS validation, refactors isIframeable to use caller-provided headers, updates routes and sitemap importer to use safeFetch, and adds/adjusts tests and test wiring for the new behavior.

Changes

SSRF-safe fetching implementation and API route adoption

Layer / File(s) Summary
Private IP classification foundation
apps/web/lib/api/safe-fetch.ts
IPv4/IPv6 parsing, CIDR deny-lists, IPv4‑mapped IPv6 handling, and fail-closed isPrivateIp.
DNS resolution and safe URL assertion
apps/web/lib/api/safe-fetch.ts
Google DoH A/AAAA resolution and assertUrlIsSafe that enforces http/https and rejects hosts resolving to private IPs or with no results.
safeFetch with timeout and redirect handling
apps/web/lib/api/safe-fetch.ts
safeFetch enforces total timeout, manual redirect handling, per-hop URL safety checks, and redirect limits/errors.
isIframeable synchronous refactor
packages/utils/src/functions/is-iframeable.ts
Converted to synchronous function accepting Headers and requestDomain; normalizes X-Frame-Options parsing.
API routes and sitemap importer adoption
apps/web/app/api/links/iframeable/route.ts, apps/web/app/api/links/metatags/utils.ts, apps/web/app/api/providers/route.ts, apps/web/app/api/domains/[domain]/validate/route.ts, apps/web/lib/sitemaps/import-tracked-sitemaps.ts
Routes and importer now call safeFetch (iframeable passes response headers to isIframeable; providers & sitemap importer disable redirects; domain probe uses HEAD with timeout).
Private IP detection test coverage
apps/web/tests/misc/safe-fetch.test.ts
Vitest tests for isPrivateIp covering private ranges, IPv4-mapped IPv6, public cases, and invalid inputs (fail-closed).
Sitemap importer test rewiring
apps/web/tests/misc/import-tracked-sitemaps.test.ts
Splits mocked fetches into content vs DoH responses, updates dohResponse shape, adjusts many tests to use mockContentFetch, and adds a non-2xx sitemap test.
Test setup: logging mock extension
apps/web/tests/setupTests.ts
Adds a ConsoleTransport class to the @axiomhq/logging test mock.

Sequence Diagram

sequenceDiagram
  participant APIRoute as API Route Handler
  participant SafeFetch as safeFetch
  participant URLValidator as assertUrlIsSafe
  participant DNSResolver as Google DoH
  participant Fetch as fetch()
  APIRoute->>SafeFetch: safeFetch(url, init, opts)
  SafeFetch->>URLValidator: Validate initial URL
  URLValidator->>DNSResolver: Resolve A/AAAA
  DNSResolver-->>URLValidator: IP addresses
  URLValidator->>URLValidator: Check isPrivateIp
  URLValidator-->>SafeFetch: Safe or throw DubApiError
  SafeFetch->>Fetch: fetch with redirect:manual
  Fetch-->>SafeFetch: Response (may redirect)
  SafeFetch->>URLValidator: Validate redirect URL
  URLValidator-->>SafeFetch: Safe or throw
  SafeFetch-->>APIRoute: Final Response or Error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • steven-tey

Poem

🐰 Beneath the stack a rabbit peeks,
I guard the hops and check the leaks,
DNS, headers, redirects all,
I nibble bugs before they fall,
Safe fetch hops on steady feet.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Extra ssrf guards' directly relates to the main purpose of the PR: adding SSRF-safe fetch utilities and replacing unsafe fetch calls with safeFetch across multiple routes and handlers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security-ssrf-safe-fetch

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.

@pepeladeira pepeladeira marked this pull request as draft May 27, 2026 16:33
Copy link
Copy Markdown
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: 2

🧹 Nitpick comments (1)
apps/web/lib/api/safe-fetch.ts (1)

137-151: 💤 Low value

IPv4-compatible addresses (::/96) not handled.

The IPv4-mapped check correctly catches ::ffff:x.x.x.x, but IPv4-compatible addresses like ::127.0.0.1 (deprecated format where upper 96 bits are zero) would slip through—they're not matched by the IPv4-mapped check and don't fall into any IPv6 CIDR.

While IPv4-compatible addresses are deprecated (RFC 4291, 2006) and rarely routed by modern stacks, for a security feature, fail-closed coverage is preferred.

🛡️ Optional: Add IPv4-compatible handling
   // IPv4-mapped IPv6 (::ffff:0:0/96): unwrap the last 32 bits and re-check.
   if (ipBig >> BIG_32 === BIG_FFFF) {
     const v4Int = Number(ipBig & BIG_FFFFFFFF);
     const v4Str = `${(v4Int >>> 24) & 0xff}.${(v4Int >>> 16) & 0xff}.${
       (v4Int >>> 8) & 0xff
     }.${v4Int & 0xff}`;
     return isPrivateIpv4(v4Str);
   }
 
+  // IPv4-compatible (deprecated ::/96): upper 96 bits are zero, last 32 are IPv4.
+  // Exclude :: itself (already caught by ::/128) to avoid false positives.
+  if (ipBig >> BIG_32 === BIG_ZERO && ipBig !== BIG_ZERO) {
+    const v4Int = Number(ipBig & BIG_FFFFFFFF);
+    const v4Str = `${(v4Int >>> 24) & 0xff}.${(v4Int >>> 16) & 0xff}.${
+      (v4Int >>> 8) & 0xff
+    }.${v4Int & 0xff}`;
+    return isPrivateIpv4(v4Str);
+  }
+
   return PRIVATE_IPV6_CIDRS.some((c) => ipv6InCidr(ipBig, c));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/safe-fetch.ts` around lines 137 - 151, The function
isPrivateIpv6 misses IPv4-compatible addresses (::/96) like ::127.0.0.1; update
isPrivateIpv6 (after computing ipBig and the existing mapped-address block that
checks ipBig >> BIG_32 === BIG_FFFF) to also detect the IPv4-compatible case
where the upper 96 bits are zero (ipBig >> BIG_32 === 0n) and then unwrap the
low 32 bits (ipBig & BIG_FFFFFFFF) into an IPv4 dotted string and call
isPrivateIpv4 on it; keep the existing mapped-address logic intact and only add
this additional branch before falling back to PRIVATE_IPV6_CIDRS/ipv6InCidr.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/app/api/providers/route.ts`:
- Around line 45-53: The request incorrectly places method: "HEAD" inside the
headers object when calling safeFetch (so fetch will ignore it); modify the
safeFetch invocation that builds `headers` from `urlObject.toString()` so that
the RequestInit passed to safeFetch includes method: "HEAD" at the top level
(e.g., alongside headers) rather than inside the headers map; update the call
site where `safeFetch(...)` is invoked and the RequestInit argument so `method`
is a top-level property and only actual header fields remain in the headers
object.

In `@packages/utils/src/functions/is-iframeable.ts`:
- Around line 27-29: The check for X-Frame-Options in is-iframeable currently
does a strict equality against "DENY" and "SAMEORIGIN", which misses lowercase
or whitespace-padded values; update the logic around the xFrameOptions value
(from headers.get("X-Frame-Options")) to normalize it (e.g., trim() and
toUpperCase() or toLowerCase()) before comparing, and then return false when the
normalized value equals DENY or SAMEORIGIN so the iframeability check handles
case and padding variations.

---

Nitpick comments:
In `@apps/web/lib/api/safe-fetch.ts`:
- Around line 137-151: The function isPrivateIpv6 misses IPv4-compatible
addresses (::/96) like ::127.0.0.1; update isPrivateIpv6 (after computing ipBig
and the existing mapped-address block that checks ipBig >> BIG_32 === BIG_FFFF)
to also detect the IPv4-compatible case where the upper 96 bits are zero (ipBig
>> BIG_32 === 0n) and then unwrap the low 32 bits (ipBig & BIG_FFFFFFFF) into an
IPv4 dotted string and call isPrivateIpv4 on it; keep the existing
mapped-address logic intact and only add this additional branch before falling
back to PRIVATE_IPV6_CIDRS/ipv6InCidr.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d3b583fd-5d7a-4d49-b1e2-a6143edb9e0a

📥 Commits

Reviewing files that changed from the base of the PR and between 9fee29d and f6e9c69.

📒 Files selected for processing (6)
  • apps/web/app/api/links/iframeable/route.ts
  • apps/web/app/api/links/metatags/utils.ts
  • apps/web/app/api/providers/route.ts
  • apps/web/lib/api/safe-fetch.ts
  • apps/web/tests/misc/safe-fetch.test.ts
  • packages/utils/src/functions/is-iframeable.ts

Comment thread apps/web/app/api/providers/route.ts
Comment thread packages/utils/src/functions/is-iframeable.ts Outdated
@pepeladeira pepeladeira marked this pull request as ready for review May 27, 2026 18:52
@pepeladeira
Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review. any breaking changes? any security issues?

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== safe-fetch.ts ==="
cat apps/web/lib/api/safe-fetch.ts

echo ""
echo "=== iframeable route ==="
cat apps/web/app/api/links/iframeable/route.ts

echo ""
echo "=== metatags utils ==="
cat apps/web/app/api/links/metatags/utils.ts

echo ""
echo "=== providers route ==="
cat apps/web/app/api/providers/route.ts

echo ""
echo "=== is-iframeable.ts ==="
cat packages/utils/src/functions/is-iframeable.ts

echo ""
echo "=== safe-fetch tests ==="
cat apps/web/tests/misc/safe-fetch.test.ts

echo ""
echo "=== Any other consumers of isIframeable ==="
rg -n "isIframeable" --type ts -C 2

echo ""
echo "=== Any other consumers of fetchWithTimeout in the changed paths ==="
rg -n "fetchWithTimeout" --type ts -C 2

echo ""
echo "=== Checking for other callers of the old isIframeable signature (url:) ==="
rg -n "isIframeable.*url" --type ts -C 3

Length of output: 26632



@pepeladeira Here is a full review covering breaking changes and security issues:


Breaking Changes

1. isIframeable public API in @dub/utils

The function signature changed fundamentally:

Before After
async ({ url, requestDomain }: { url: string; requestDomain: string }) ({ headers, requestDomain }: { headers: Headers; requestDomain: string })

This is a breaking change for any external consumer of @dub/utils that calls isIframeable. Internally, only apps/web/app/api/links/iframeable/route.ts uses it, and it has been correctly updated. However, if this package is published to npm, downstream users will break on upgrade.

The function is also no longer async — awaiting it still works (awaiting a non-Promise returns the value), so that part is backward-compatible at the call-site level.


Security Issues

1. ⚠️ DNS Rebinding / TOCTOU Race (Medium–High)

In safe-fetch.ts, assertUrlIsSafe resolves the hostname via Google DoH and checks the returned IPs. Then, separately, the actual TCP connection is made using the OS/runtime resolver. There is a time gap between these two operations:

sequenceDiagram
    participant Attacker as Attacker DNS
    participant safeFetch
    participant Target as Internal Service
    safeFetch->>Attacker: DoH resolve → returns 1.2.3.4 (public) ✅
    Note over Attacker: Attacker flips DNS TTL to 0 and points to 169.254.169.254
    safeFetch->>Target: TCP connect → OS resolves → 169.254.169.254 ❌
Loading

An attacker with control over DNS can pass the DoH check (by returning a public IP) and then serve the private IP to the runtime's actual connection. This is a known limitation of DoH-based SSRF guards. The only robust mitigation is binding to a known-good IP after DNS resolution (i.e., connecting directly to the resolved IP, bypassing the OS resolver), which is not straightforward in fetch-based environments.

This is a hard problem in edge runtimes where you can't control the resolver. Worth documenting as a known limitation.

2. providers/route.ts — Leftover fetchWithTimeout for DNS Lookup

// apps/web/app/api/providers/route.ts, line 5 & 21
import { fetchWithTimeout } from "`@dub/utils`";
...
const dns = await fetchWithTimeout(
  `https://dns.google/resolve?name=${domain}`,
)

fetchWithTimeout (non-SSRF-safe) is still being used here. The target URL is hardcoded to dns.google, and domain is only appended as a query parameter (not the host), so there is no SSRF risk in this specific call. However, it's an inconsistency — the DNS lookup for the provider check continues to use the old unsafe fetcher. Consider using safeFetch here as well for consistency, or at minimum add a comment explaining why fetchWithTimeout is safe in this context.


Other Observations

3. No Response Body Size Limit in getHtml (Pre-existing)

metatags/utils.ts reads the full response body with response.text() — no size cap. A server could return a gigantic response and exhaust memory. This predates this PR, but now that safeFetch is in place, adding a size limit (e.g., abort if Content-Length exceeds a threshold) would be a good follow-up.

4. Test Coverage Gap

safe-fetch.test.ts only tests isPrivateIp. The higher-level safeFetch function (redirect following, DoH resolution, timeout, error paths) has no test coverage. Some important cases:

  • Redirect to a private IP should be blocked
  • More than maxRedirects hops should throw
  • Protocol downgrade redirect (https → http) is currently allowed — intentional?

5. IPv4-mapped IPv6 Detection — Correct ✅

The check ipBig >> BIG_32 === BIG_FFFF correctly identifies ::ffff:x.x.x.x addresses and unwraps them for IPv4 private-range checking. The math holds.

6. isIframeable CSP Parsing (Pre-existing Limitation)

The frame-ancestors check compares bare domains (allowedOrigins.includes(requestDomain)) but some servers emit full origin values like https://dub.co. This is pre-existing and not introduced by this PR.


Summary

Category Item Severity
Breaking isIframeable signature + async removal Medium (affects external consumers of @dub/utils)
Security DNS rebinding / TOCTOU in assertUrlIsSafe Medium (hard to fully mitigate in edge runtimes)
Code quality fetchWithTimeout still used in providers/route.ts DNS call Low
Pre-existing No body size limit in getHtml Low
Testing safeFetch redirect/DNS paths untested Low

Overall, the SSRF protections are a meaningful improvement. The main architectural concern is the TOCTOU race inherent to the DoH-check-then-connect pattern — worth documenting as a known limitation if it can't be fully mitigated in the edge runtime.

@pepeladeira pepeladeira marked this pull request as ready for review May 28, 2026 18:44
Copy link
Copy Markdown
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: 0

Caution

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

⚠️ Outside diff range comments (1)
apps/web/lib/sitemaps/import-tracked-sitemaps.ts (1)

75-87: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat non-2xx sitemap responses as errors before parsing.

At Line 75, the code parses the body without checking response.ok. A 404/500 can be treated as a successful crawl path, which may incorrectly update crawl metadata.

Suggested patch
 async function fetchAndParseSitemap(
   sitemapUrl: string,
 ): Promise<SitemapXmlResult> {
   const response = await safeFetch(sitemapUrl, undefined, { maxRedirects: 0 });
+  if (!response.ok) {
+    throw new Error(
+      `Failed to fetch sitemap: ${response.status} ${response.statusText}`,
+    );
+  }
   const MAX_SITEMAP_BYTES = 10 * 1024 * 1024; // 10 MB
   const contentLength = response.headers.get("content-length");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/sitemaps/import-tracked-sitemaps.ts` around lines 75 - 87, The
code reads and parses the sitemap body regardless of HTTP status; update the
handler in import-tracked-sitemaps (the block using safeFetch and response) to
check response.ok (or response.status) immediately after the fetch and throw a
descriptive error for non-2xx responses (include status and statusText) before
inspecting headers or calling response.arrayBuffer(), then proceed with the
existing content-length/byteLength checks, decompressIfGzip, and parser.parse as
before.
🧹 Nitpick comments (1)
apps/web/app/api/domains/[domain]/validate/route.ts (1)

61-65: ⚡ Quick win

Disable redirects for the domain HEAD probe.

At Line 61, this check only needs to confirm origin responsiveness. Allowing default redirect hops adds avoidable outbound requests and latency.

Suggested patch
         const response = await safeFetch(
           url,
           { method: "HEAD" },
-          { timeoutMs: 3000 },
+          { timeoutMs: 3000, maxRedirects: 0 },
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/domains/`[domain]/validate/route.ts around lines 61 - 65,
The HEAD probe currently follows redirects via safeFetch(url, { method: "HEAD"
}, { timeoutMs: 3000 }), causing extra hops and latency; update the RequestInit
passed to safeFetch so redirects are disabled (e.g., set redirect:"manual" on
the second argument) so the probe only checks the origin responsiveness for the
url variable and use the same timeout parameter.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/web/lib/sitemaps/import-tracked-sitemaps.ts`:
- Around line 75-87: The code reads and parses the sitemap body regardless of
HTTP status; update the handler in import-tracked-sitemaps (the block using
safeFetch and response) to check response.ok (or response.status) immediately
after the fetch and throw a descriptive error for non-2xx responses (include
status and statusText) before inspecting headers or calling
response.arrayBuffer(), then proceed with the existing content-length/byteLength
checks, decompressIfGzip, and parser.parse as before.

---

Nitpick comments:
In `@apps/web/app/api/domains/`[domain]/validate/route.ts:
- Around line 61-65: The HEAD probe currently follows redirects via
safeFetch(url, { method: "HEAD" }, { timeoutMs: 3000 }), causing extra hops and
latency; update the RequestInit passed to safeFetch so redirects are disabled
(e.g., set redirect:"manual" on the second argument) so the probe only checks
the origin responsiveness for the url variable and use the same timeout
parameter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d3c5017d-0ccb-46d3-8d3b-af49b019dd5a

📥 Commits

Reviewing files that changed from the base of the PR and between b3e8b9f and b3f07f5.

📒 Files selected for processing (4)
  • apps/web/app/api/domains/[domain]/validate/route.ts
  • apps/web/lib/sitemaps/import-tracked-sitemaps.ts
  • apps/web/tests/misc/import-tracked-sitemaps.test.ts
  • apps/web/tests/setupTests.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/web/tests/setupTests.ts

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

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