Skip to content

fix(security): validate browser navigation targets to block SSRF - #18

Merged
eldadfux merged 1 commit into
mainfrom
fix-ssrf-navigation
Sep 29, 2026
Merged

eldadfux merged 1 commit into
mainfrom
fix-ssrf-navigation

Conversation

@eldadfux

Copy link
Copy Markdown
Member

What does this PR do?

Closes an SSRF gap in the screenshot and report routes. Both took the caller's URL and handed it to Chromium, which resolves and navigates on its own. Because the calling API (appwrite/appwrite) can only check the hostname's DNS answer and cannot pin the browser's connection, a hostname that resolved to a public address at check time could resolve to an internal or cloud-metadata address (e.g. 169.254.169.254) by the time the browser connected — and headerless endpoints such as AWS IMDSv1 were reachable and rendered into the returned screenshot.

Changes:

  • src/utils/ssrf.ts — a guard built on Node's net.BlockList. isPublicIp, isPublicHost (resolves and checks every address), and assertPublicUrl (scheme + host). Blocks private, loopback, link-local (incl. the metadata range), CGNAT, ULA, and IPv4-embedding ranges, and handles IPv4-mapped IPv6.
  • screenshots.ts / reports.ts — validate the target URL before page.goto, and in page.route("**/*") re-check every request the page makes (redirects, iframes, subresources), aborting any that resolve to a non-public host. Header injection still applies only to the target origin.
  • tests/unit/ssrf.test.ts — unit coverage for the guard.

Test Plan

  • bun test tests/unit/ssrf.test.ts — 8 pass.
  • bun run type-check, bun run lint (Biome) and bun run check (ESLint) all clean.

Notes

  • A narrow TOCTOU remains between the Node-side resolution and Chromium's own connection, since Playwright can't pin the socket to a verified IP without breaking TLS. It is much smaller than before (literal internal IPs, redirects, and names resolving to internal addresses are all blocked) and is defense-in-depth alongside the network isolation and public-DNS changes on the Appwrite side.
  • Consumes the API-side fix in fix(avatars): validate remote URLs with a public URL param validator appwrite#13995; once released, bump the appwrite/browser image pin in docker-compose.yml.

The screenshot and report routes handed the caller's URL to Chromium,
which resolved and navigated on its own. A hostname that resolved to a
public address at the API's check could point at an internal or
cloud-metadata address (e.g. 169.254.169.254) by the time the browser
connected, and headerless endpoints like AWS IMDSv1 were reachable.

Validate the target URL before navigating and re-check every request the
page makes (redirects, iframes, subresources), aborting any that resolve
to a private or reserved address.
@hansi-codes

hansi-codes Bot commented Sep 29, 2026

Copy link
Copy Markdown

🟢 Tier S · Ready to merge

Adds an SSRF guard (src/utils/ssrf.ts) built on net.BlockList. The screenshot and report routes now validate the target URL before navigating. They also intercept every page request and abort any that resolves to a non-public host. Unit tests cover the guard itself.

Verdict New comments Fixed Still open
✅ Approved 0 0 0
📂 Walkthrough · 4
File Change
src/utils/ssrf.ts New guard with IP blocklists, isPublicIp, isPublicHost (DNS resolve and check all addresses), and assertPublicUrl.
src/routes/screenshots.ts Validates the target URL up front and checks every intercepted request's host via a per-page cache. Headers are injected only for the target origin.
src/routes/reports.ts Same validation as screenshots. Request interception is now always installed, where before it ran only when custom headers were given.
tests/unit/ssrf.test.ts Unit tests for IP classification, host resolution and URL validation.
🔇 Filtered out · 2

Findings Hansi considered but did not post.

Finding Why
Route-level blocking is not tested Not on a changed line
Interception is now always on for Lighthouse runs Verifier: The always-on interception is the point of the SSRF fix. The finding says to ignore it if acceptable, and it gives no concrete bug. The DNS lookups are cached per hostname, so the cost is small. I dropped it as a speculative nit.

Reviewed 39b879a · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@github-actions

Copy link
Copy Markdown

Docker Image Stats

Metric Value
Image Size 494MB
Memory Usage 157.7MiB
Cold Start Time 1.02s
Screenshot Time 1.82s

Screenshot benchmark: Average of 3 runs on https://appwrite.io

@hansi-codes hansi-codes 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.

🟢 Tier S · Looks good to merge. Summary

@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5

[Critical risk] Adds SSRF protection to browser navigation endpoints.

This PR is not safe to merge until popup requests are guarded and later requests cannot rely on stale hostname approvals.

Fix All in Claude CodeFindings

  1. P1 Security Popup navigation escapes the guard ▶
  2. P1 Security Cached approval survives DNS changes ▶
  3. P2 Endpoint blocking remains untested ▶
Fix with agent prompt
### Issue 1
src/routes/screenshots.ts:54
A public page can open a popup to an internal URL. This route is attached only to the original page, so it does not check the popup’s initial request. Chromium can therefore request an internal address during a screenshot or report. **How this was verified:** The route is attached only to the page created for the validated URL, while that page can open another page in the same context.

### Issue 2
src/routes/screenshots.ts:58-62
If an attacker-controlled hostname changes from a public address to an internal one during a capture or audit, later requests to that hostname reuse its first approval. The handler does not check the new address before allowing Chromium’s independent connection, so an internal request can proceed. The report route uses the same cache. **How this was verified:** Both routes cache the first hostname approval, while Chromium is not pinned to the address checked by Node.

### Issue 3
tests/unit/ssrf.test.ts:48-72
These tests check the helper’s results and error text, but not whether either endpoint rejects an internal target or blocks a redirect or page resource aimed at one. Browser-backed endpoint tests would catch a broken route guard that this suite currently misses.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds public-address validation before screenshot and report navigation and attempts to block internal redirects and page resources. The page-scoped interception leaves popup navigation unguarded, and cached hostname approvals weaken later-request checks. The new tests do not exercise either endpoint’s interception behavior.

Reviews (1) · Last reviewed commit: "fix(security): block navigation to non-p..."

Comment thread src/routes/screenshots.ts
// Re-check every request the page makes — redirects, iframes and
// subresources each resolve independently and could target an internal
// address that the initial check never saw.
await page.route("**/*", async (route, request) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Popup navigation escapes the guard A public page can open a popup to an internal URL. This route is attached only to the original page, so it does not check the popup’s initial request. Chromium can therefore request an internal address during a screenshot or report. How this was verified: The route is attached only to the page created for the validated URL, while that page can open another page in the same context.

Knowledge Base Used: Browser execution lifecycle

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/screenshots.ts
Line: 54

Comment:
**Popup navigation escapes the guard** A public page can open a popup to an internal URL. This route is attached only to the original page, so it does not check the popup’s initial request. Chromium can therefore request an internal address during a screenshot or report. **How this was verified:** The route is attached only to the page created for the validated URL, while that page can open another page in the same context.

**Knowledge Base Used:** [Browser execution lifecycle](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/docker-browser/-/docs/browser-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment thread src/routes/screenshots.ts
Comment on lines +58 to +62
let allowed = hostAllowed.get(requestUrl.hostname);
if (allowed === undefined) {
allowed = isPublicHost(requestUrl.hostname);
hostAllowed.set(requestUrl.hostname, allowed);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Cached approval survives DNS changes If an attacker-controlled hostname changes from a public address to an internal one during a capture or audit, later requests to that hostname reuse its first approval. The handler does not check the new address before allowing Chromium’s independent connection, so an internal request can proceed. The report route uses the same cache. How this was verified: Both routes cache the first hostname approval, while Chromium is not pinned to the address checked by Node.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/screenshots.ts
Line: 58-62

Comment:
**Cached approval survives DNS changes** If an attacker-controlled hostname changes from a public address to an internal one during a capture or audit, later requests to that hostname reuse its first approval. The handler does not check the new address before allowing Chromium’s independent connection, so an internal request can proceed. The report route uses the same cache. **How this was verified:** Both routes cache the first hostname approval, while Chromium is not pinned to the address checked by Node.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment thread tests/unit/ssrf.test.ts
Comment on lines +48 to +72
describe("assertPublicUrl", () => {
test("rejects non-http schemes", async () => {
await expect(assertPublicUrl("file:///etc/passwd")).rejects.toThrow(
"is not allowed",
);
await expect(assertPublicUrl("gopher://1.1.1.1/")).rejects.toThrow(
"is not allowed",
);
});

test("rejects internal and metadata targets", async () => {
await expect(
assertPublicUrl("http://169.254.169.254/latest/meta-data/"),
).rejects.toThrow("not publicly routable");
await expect(assertPublicUrl("http://127.0.0.1/")).rejects.toThrow(
"not publicly routable",
);
await expect(assertPublicUrl("http://[::1]/")).rejects.toThrow(
"not publicly routable",
);
});

test("rejects malformed URLs", async () => {
await expect(assertPublicUrl("not a url")).rejects.toThrow("Invalid URL");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Endpoint blocking remains untested These tests check the helper’s results and error text, but not whether either endpoint rejects an internal target or blocks a redirect or page resource aimed at one. Browser-backed endpoint tests would catch a broken route guard that this suite currently misses.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/ssrf.test.ts
Line: 48-72

Comment:
**Endpoint blocking remains untested** These tests check the helper’s results and error text, but not whether either endpoint rejects an internal target or blocks a redirect or page resource aimed at one. Browser-backed endpoint tests would catch a broken route guard that this suite currently misses.

**Knowledge Base Used:**
- [Screenshot endpoint](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/docker-browser/-/docs/screenshot-api.md)
- [Lighthouse report endpoint](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/docker-browser/-/docs/lighthouse-report-api.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

@eldadfux
eldadfux merged commit 60a09d8 into main Sep 29, 2026
5 checks passed
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