Skip to content

fix: infra-http#106

Merged
ro80t merged 4 commits into
mainfrom
fix-http
Jun 30, 2026
Merged

fix: infra-http#106
ro80t merged 4 commits into
mainfrom
fix-http

Conversation

@ro80t

@ro80t ro80t commented Jun 30, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added safer URL handling for network requests, including validation before fetching and protection against private/reserved IPs.
    • Improved DNS handling by pinning hostnames to resolved safe IPs while preserving the original address shown to users.
  • Bug Fixes

    • Better detects Cloudflare challenge responses using response headers.
    • Expanded local IP detection to include CGNAT ranges, reducing risky connections.
    • Improved Discord invite URL parsing and invite link checks for safer handling.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ro80t, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: af6828c1-08a8-4d7b-ba78-5dcad3519eab

📥 Commits

Reviewing files that changed from the base of the PR and between 786618c and fc81a68.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • lib/distopia/jsr.json
  • lib/distopia/package.json
  • src/infrastructure/http/package.json
  • src/infrastructure/http/src/safefetch.test.ts
  • src/infrastructure/http/src/safefetch.ts
  • src/infrastructure/http/src/safeurl.ts
  • src/infrastructure/http/src/url.test.ts
📝 Walkthrough

Walkthrough

Removes jsdom and replaces HTML-parsing Cloudflare detection with a cf-mitigated header check. Introduces DNS-rebinding protection via resolveHostnameToSafeIp and a resolveToPinnedUrl helper that rewrites fetch URLs to pinned IPs while preserving Host/SNI. Adds validateSafeUrl for input validation, extends isLocalIPv4 to cover CGNAT (100.64.0.0/10), and proxies response.url to expose the original hostname.

Changes

DNS Pinning, SSRF Hardening, and jsdom Removal

Layer / File(s) Summary
CGNAT range and URL protocol fix
src/infrastructure/http/src/url.ts, src/infrastructure/http/src/url.test.ts
isLocalIPv4 adds 100.64.0.0/10 CGNAT range; isHttpProtocol fixes protocol extraction via url.toString(); tests cover CGNAT boundaries.
resolveHostnameToSafeIp DNS helper
src/infrastructure/http/src/dns.ts
New exported function resolves A/AAAA records concurrently, filters private/local IPs, and returns the first safe IP or null.
validateSafeUrl input validator
src/infrastructure/http/src/safeurl.ts
New validateSafeUrl parses raw URL strings, enforces http/https protocol, and brands the result as SafeUrl or returns null.
DNS-pinned safeFetch and safeFetchForDiscord
src/infrastructure/http/src/safefetch.ts
Adds PinnedRequest and resolveToPinnedUrl; rewrites request URLs to pinned IPs, preserves Host/SNI. Both safeFetch and safeFetchForDiscord pin URLs before fetching. Terminal responses are wrapped in a Proxy so response.url reflects the original hostname.
isUsedCf header detection and isInviteLink hardening
src/infrastructure/http/src/invite.ts, src/infrastructure/http/src/invite.test.ts
isUsedCf rewritten as synchronous cf-mitigated header check; isInviteLink validates URL via validateSafeUrl before fetching; isDiscordInviteLink fixes URL.parse call; tests updated accordingly.
safeFetch tests updated for DNS pinning
src/infrastructure/http/src/safefetch.test.ts
Mocks switch from isLocalUrl to resolveHostnameToSafeIp; SSRF tests use IP literals and DNS-resolution control; new DNS pinning assertion verifies response.url preserves hostname.
jsdom removal and changeset
package.json, lib/distopia/jsr.json, lib/distopia/package.json, src/infrastructure/http/package.json, .changeset/light-lies-shave.md
Removes @types/jsdom and jsdom from all dependency manifests and import maps; adds minor version changeset entry.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • ThunLights/distopia#64: Introduced the original safeFetch/safeUrl helpers in src/infrastructure/http that this PR extends with DNS pinning and redirect proxying.
  • ThunLights/distopia#75: Introduced invite-link blocking that depends on isInviteLink/isUsedCf, both of which are refactored in this PR.
  • ThunLights/distopia#94: Modified Discord invite detection in invite.ts and its interaction with safeFetch, the same code paths changed here.

Poem

🐇 Hop hop, no more jsdom to parse!
The cf-mitigated header speaks clear —
DNS pinned tight, no rebinding to fear,
CGNAT blocked, private IPs kept sparse.
This bunny fetches safe URLs with cheer! 🌐

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is related to the changeset, but it is too generic to convey the actual infra-http fixes. Use a more specific title that names the main change, such as DNS-safe fetch or Cloudflare invite handling updates.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-http

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.

@github-actions

Copy link
Copy Markdown
Contributor

github-actions Bot pushed a commit that referenced this pull request Jun 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (2)
.changeset/light-lies-shave.md (1)

1-6: 📐 Maintainability & Code Quality | 🔵 Trivial

The changeset description under-represents the PR scope.

The current description—"remove unused dependencies and improve URL parsing in invite handling"—omits the substantial security hardening in this PR: DNS pinning to prevent DNS rebinding/SSRF, the new resolveHostnameToSafeIp/validateSafeUrl/resolveToPinnedUrl APIs, header-based Cloudflare detection replacing jsdom HTML parsing, and CGNAT range coverage in isLocalIPv4. Consumers reading this changeset will not understand the security implications or new capabilities.

Expand the description to accurately reflect the DNS pinning, SSRF hardening, and jsdom removal. For example:

-fix: remove unused dependencies and improve URL parsing in invite handling
+fix: remove jsdom, add DNS pinning and SSRF hardening for HTTP fetches
+
+- Replaces jsdom-based Cloudflare detection with `cf-mitigated` header check
+- Adds `resolveHostnameToSafeIp`, `validateSafeUrl`, and `resolveToPinnedUrl` for DNS-rebinding protection
+- Extends `isLocalIPv4` to cover CGNAT (`100.64.0.0/10`)
+- Pins fetch URLs to resolved IPs while preserving `Host` header and TLS SNI

The minor bump level is appropriate given the new exports and behavioral changes.

🤖 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 @.changeset/light-lies-shave.md around lines 1 - 6, The changeset summary
understates the scope of the PR and should be expanded to mention the
security-hardening work in addition to the dependency cleanup and invite URL
parsing tweaks. Update the description in the changeset to reflect the new
exports and behavioral changes around DNS pinning/SSRF protection, including
resolveHostnameToSafeIp, validateSafeUrl, resolveToPinnedUrl, Cloudflare
header-based detection, and the broader CGNAT coverage in isLocalIPv4, so
consumers can understand the security impact.
src/infrastructure/http/src/safefetch.test.ts (1)

301-305: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the actual pinned request, too.

This test verifies the proxied response.url, but it would still pass if fetch were called with the original hostname instead of the pinned IP.

Suggested test assertions
   it("exposes the original hostname URL via response.url (not the pinned IP)", async () => {
     fetchMock.mockResolvedValueOnce(ok());
     const result = await safeFetch(url("https://example.com/path"));
     expect((result as Response).url).toBe("https://example.com/path");
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://1.2.3.4/path",
+      expect.objectContaining({ redirect: "manual" }),
+    );
+
+    const init = fetchMock.mock.calls[0]?.[1] as RequestInit & {
+      tls?: { serverName?: string };
+    };
+    expect(new Headers(init.headers).get("Host")).toBe("example.com");
+    expect(init.tls?.serverName).toBe("example.com");
   });
🤖 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 `@src/infrastructure/http/src/safefetch.test.ts` around lines 301 - 305, The
safefetch test only checks Response.url and could still pass if safeFetch calls
fetch with the original hostname, so update the test to also assert the actual
request passed to fetchMock. In the safeFetch test case around response.url,
verify the mocked fetch was invoked with the pinned IP (while preserving the
original Host header or equivalent hostname mapping expected by safeFetch) using
the existing safeFetch, fetchMock, and ok helpers so the test proves both the
outbound request target and the proxied response URL behavior.
🤖 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 `@src/infrastructure/http/src/invite.test.ts`:
- Around line 49-69: The invite tests are still using the local helper instead
of the production implementation, so they won’t catch regressions in invite.ts.
Update the test block in invite.test.ts to import and call the exported isUsedCf
from the invite module, and remove or rename the test-local helper so these
assertions exercise the real implementation rather than duplicated logic.

In `@src/infrastructure/http/src/safefetch.ts`:
- Line 86: Disable automatic redirects in safeFetchForDiscord by updating the
fetch call in safeFetchForDiscord to use redirect handling consistent with
safeFetch. The current return await fetch(pinned.url, ...) lets native fetch
follow redirects automatically, which bypasses the DNS pinning and header
stripping protections. Set fetch to not follow redirects and rely on the
existing safeFetch-style manual redirect handling so cross-origin redirects
remove authorization/cookie headers before the next request.
- Around line 58-60: The URL pinning logic in safefetch is not handling IPv6
literals correctly, so the hostname assignment can fail to replace the original
host. Update the safe fetch flow around resolveHostnameToSafeIp() and the
pinnedUrlObj.hostname assignment to detect raw IPv6 results and wrap them in
bracket notation before setting hostname. Keep the change localized to the URL
construction in safefetch so IPv4 behavior remains unchanged and IPv6-only hosts
are properly pinned.

In `@src/infrastructure/http/src/safeurl.ts`:
- Around line 21-25: The validateSafeUrl function is still doing manual parsing
and branding inline, so move the URL validation logic into a Zod 4 schema and
have validateSafeUrl use that schema for parsing. Keep the protocol check inside
the schema definition, brand the validated result there as SafeUrl, and make
validateSafeUrl return the schema’s parsed output or null so it follows the
existing TypeScript validation pattern.

In `@src/infrastructure/http/src/url.test.ts`:
- Line 15: The url.test.ts CGNAT lower-bound case is using the wrong address for
the labeled boundary. Update the test case in the URL IP boundary list so the
entry for the CGNAT lower bound uses the exact lower-edge address of the
100.64.0.0/10 range, keeping the existing test structure and label aligned with
that boundary value.

---

Nitpick comments:
In @.changeset/light-lies-shave.md:
- Around line 1-6: The changeset summary understates the scope of the PR and
should be expanded to mention the security-hardening work in addition to the
dependency cleanup and invite URL parsing tweaks. Update the description in the
changeset to reflect the new exports and behavioral changes around DNS
pinning/SSRF protection, including resolveHostnameToSafeIp, validateSafeUrl,
resolveToPinnedUrl, Cloudflare header-based detection, and the broader CGNAT
coverage in isLocalIPv4, so consumers can understand the security impact.

In `@src/infrastructure/http/src/safefetch.test.ts`:
- Around line 301-305: The safefetch test only checks Response.url and could
still pass if safeFetch calls fetch with the original hostname, so update the
test to also assert the actual request passed to fetchMock. In the safeFetch
test case around response.url, verify the mocked fetch was invoked with the
pinned IP (while preserving the original Host header or equivalent hostname
mapping expected by safeFetch) using the existing safeFetch, fetchMock, and ok
helpers so the test proves both the outbound request target and the proxied
response URL behavior.
🪄 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 Plus

Run ID: b7638219-18bb-4b70-8d06-ab95d6feee55

📥 Commits

Reviewing files that changed from the base of the PR and between a171f3e and 786618c.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • .changeset/light-lies-shave.md
  • lib/distopia/jsr.json
  • lib/distopia/package.json
  • package.json
  • src/infrastructure/http/package.json
  • src/infrastructure/http/src/dns.ts
  • src/infrastructure/http/src/invite.test.ts
  • src/infrastructure/http/src/invite.ts
  • src/infrastructure/http/src/safefetch.test.ts
  • src/infrastructure/http/src/safefetch.ts
  • src/infrastructure/http/src/safeurl.ts
  • src/infrastructure/http/src/url.test.ts
  • src/infrastructure/http/src/url.ts
💤 Files with no reviewable changes (4)
  • lib/distopia/jsr.json
  • lib/distopia/package.json
  • package.json
  • src/infrastructure/http/package.json

Comment on lines +49 to +69
it("returns true when cf-mitigated: challenge header is present", () => {
const res = fakeResponse("https://example.com", 403, "", { "cf-mitigated": "challenge" });
expect(isUsedCf(res)).toBe(true);
});

it("returns false for a 200 with a Cloudflare-like title", async () => {
const res = fakeResponse(
"https://example.com",
200,
"<html><title>Just a moment...</title></html>",
);
expect(await isUsedCf(res)).toBe(false);
});

it("returns true for a 403 with the exact Cloudflare title", async () => {
const html =
"<html><head><title>Just a moment...</title></head><body>CF challenge</body></html>";
const res = fakeResponse("https://example.com", 403, html);
expect(await isUsedCf(res)).toBe(true);
});

it("returns false for a 403 with a different title", async () => {
const html = "<html><head><title>Access Denied</title></head><body>Forbidden</body></html>";
const res = fakeResponse("https://example.com", 403, html);
expect(await isUsedCf(res)).toBe(false);
});

it("returns false for a 403 with no <title> element", async () => {
const html = "<html><body>Forbidden</body></html>";
const res = fakeResponse("https://example.com", 403, html);
expect(await isUsedCf(res)).toBe(false);
it("returns true for any status code when cf-mitigated: challenge is set", () => {
// CF challenges can be served with 200, 403, 429, or 503
for (const status of [200, 403, 429, 503]) {
const res = fakeResponse("https://example.com", status, "", { "cf-mitigated": "challenge" });
expect(isUsedCf(res)).toBe(true);
}
});

it("returns false for a 403 with a title that partially matches", async () => {
const html = "<html><head><title>Just a moment</title></head></html>";
const res = fakeResponse("https://example.com", 403, html);
expect(await isUsedCf(res)).toBe(false);
it("returns false when cf-mitigated header is absent", () => {
const res = fakeResponse("https://example.com", 403, "");
expect(isUsedCf(res)).toBe(false);
});

it("returns false for a 403 with an empty title", async () => {
const html = "<html><head><title></title></head></html>";
const res = fakeResponse("https://example.com", 403, html);
expect(await isUsedCf(res)).toBe(false);
it("returns false when cf-mitigated has a value other than 'challenge'", () => {
const res = fakeResponse("https://example.com", 403, "", { "cf-mitigated": "other" });
expect(isUsedCf(res)).toBe(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Import the production isUsedCf into this test block.

These assertions are currently calling the test-local helper defined at Lines 32-34, so they still pass if src/infrastructure/http/src/invite.ts regresses. Please exercise the exported implementation here instead of mirroring its logic.

🤖 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 `@src/infrastructure/http/src/invite.test.ts` around lines 49 - 69, The invite
tests are still using the local helper instead of the production implementation,
so they won’t catch regressions in invite.ts. Update the test block in
invite.test.ts to import and call the exported isUsedCf from the invite module,
and remove or rename the test-local helper so these assertions exercise the real
implementation rather than duplicated logic.

Comment thread src/infrastructure/http/src/safefetch.ts
Comment thread src/infrastructure/http/src/safefetch.ts Outdated
Comment thread src/infrastructure/http/src/safeurl.ts Outdated
Comment thread src/infrastructure/http/src/url.test.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

github-actions Bot pushed a commit that referenced this pull request Jun 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

github-actions Bot pushed a commit that referenced this pull request Jun 30, 2026
@ro80t ro80t merged commit 7ce1a1a into main Jun 30, 2026
26 checks passed
@ro80t ro80t deleted the fix-http branch June 30, 2026 05:16
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