Skip to content

fix(sidecar): block private targets in global fetch#3608

Open
lspassos1 wants to merge 1 commit into
koala73:mainfrom
lspassos1:fix/sidecar-fetch-ssrf
Open

fix(sidecar): block private targets in global fetch#3608
lspassos1 wants to merge 1 commit into
koala73:mainfrom
lspassos1:fix/sidecar-fetch-ssrf

Conversation

@lspassos1
Copy link
Copy Markdown
Collaborator

@lspassos1 lspassos1 commented May 5, 2026

Summary

Closes #3549. The Tauri sidecar global fetch override now treats SSRF protection as the default floor for handler fetches: http(s) targets are validated before connection, private/reserved addresses are blocked, and validated IPv4 DNS results are pinned into the outbound request.

Root cause

The sidecar had an SSRF guard for /api/rss-proxy, but the process-wide globalThis.fetch replacement did not use it. Any sidecar handler that later fetched a user-influenced URL through global fetch could reach loopback, link-local metadata, or private RFC1918 addresses unless that handler remembered to add its own guard.

Changes

  • Run isSafeUrl() from the global sidecar fetch override before outbound http(s) requests.
  • Reject private/reserved targets with an ERR_SSRF_BLOCKED error and a redacted URL in the message.
  • Strip query strings and fragments from blocked-URL error messages.
  • Pin outbound requests to the validated IPv4 DNS result, with the pinned lookup callback invoked asynchronously.
  • Keep explicit private-network allowances narrow: the sidecar self-origin for local API calls, programmatic dev/test remoteBase, and Ollama/LLM health probes.
  • Keep privileged local fetches inside the sidecar upstream concurrency limiter.
  • Add regression tests for loopback blocking, error redaction, and async pinned lookup behavior.

Validation

  • node --test src-tauri/sidecar/local-api-server.test.mjs - 39/39 pass
  • npm run test:sidecar - 96/96 pass
  • npm run typecheck
  • npm run typecheck:api
  • npx biome lint src-tauri/sidecar/local-api-server.mjs src-tauri/sidecar/local-api-server.test.mjs - exits 0; existing complexity/HTML-string warnings remain in local-api-server.mjs
  • git diff --check -- src-tauri/sidecar/local-api-server.mjs src-tauri/sidecar/local-api-server.test.mjs

Risk

Medium-low. The intended behavior change is fail-closed for sidecar handler fetch() calls to private networks. The main compatibility risk is code that intentionally used global fetch for arbitrary localhost services; known local LLM/Ollama probes are explicitly preserved, and the sidecar's own local origin remains allowed.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

@lspassos1 is attempting to deploy a commit to the World Monitor Team on Vercel.

A member of the Team first needs to authorize it.

@lspassos1
Copy link
Copy Markdown
Collaborator Author

This PR keeps the fix intentionally narrow for #3549: the sidecar global fetch wrapper now fails closed on private/reserved http(s) targets before any outbound connection is made, and validated IPv4 DNS results are pinned into the request path.

The compatibility exceptions are explicit and limited to known local sidecar flows: the sidecar's own bound origin, programmatic dev/test remoteBase, and Ollama/LLM probes. The regression test covers the risky case directly by proving a handler-level fetch() to an arbitrary loopback port is blocked and the target server receives zero requests.

Validated with npm run test:sidecar (95/95), typecheck, typecheck:api, targeted Biome lint, and diff whitespace check.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7f5fbb08d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/sidecar/local-api-server.mjs Outdated
};
const pinnedV4 = isAllowedPrivateSidecarFetch(url) ? null : firstIPv4Address(safety.resolvedAddresses);
if (pinnedV4) {
requestOptions.lookup = (_hostname, _opts, cb) => cb(null, pinnedV4, 4);
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 Badge Make pinned DNS lookup callback asynchronous

The new pinned-IPv4 path in the global fetch override sets requestOptions.lookup to call cb(null, pinnedV4, 4) synchronously, which can race with req.on('error', reject) registration. If the connection fails immediately (for example when the machine is offline or the route is unreachable), the socket emits an unhandled 'error' and the sidecar process exits instead of returning a handler error. This regression is in the new non-private fetch path, so ordinary handler fetch() calls to public URLs can now crash the sidecar under network-failure conditions.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR closes a gap where the process-wide globalThis.fetch override in the Tauri sidecar did not apply the existing isSafeUrl() SSRF guard, allowing any handler module that fetched a user-influenced URL via global fetch to reach loopback, link-local, or RFC1918 addresses.

  • SSRF guard added to ipv4Fetch: assertSafeSidecarFetchUrl is called before every outbound http(s) request; the resolved IPv4 is then pinned via a lookup callback to reduce DNS rebinding TOCTOU risk. The sidecar's own port, and optionally a dev-only remoteBase, are registered in an allowlist so self-calls continue to work.
  • Private-network escape hatches narrowed: Ollama/LLM health probes use a new allowPrivateNetwork option in fetchWithTimeout which routes through _privilegedFetch (bypassing the SSRF check), keeping the sensitive bypass visible and explicit rather than scattered across callers.
  • IPv6 multicast block and isNaNNumber.isNaN correction: Two pre-existing gaps in isPrivateIP are fixed alongside the main change.

Confidence Score: 4/5

The SSRF fix is fail-closed and correctly guarded; the main compatibility risk is handled through an explicit opt-in flag that cannot be set via env or CLI.

The core guard logic is sound — DNS resolution checks all returned addresses, IP-literal fast-paths are handled, and the DNS-pinning pattern is correctly applied after the safety check. The two minor issues found do not affect the correctness of the SSRF protection itself.

The SSRF logic lives entirely in local-api-server.mjs; pay particular attention to the assertSafeSidecarFetchUrl / isSafeUrl path and the _privilegedFetch usages in fetchWithTimeout.

Important Files Changed

Filename Overview
src-tauri/sidecar/local-api-server.mjs Adds SSRF guard to globalThis.fetch override: validates http(s) URLs via isSafeUrl(), pins the outbound request to the resolved IPv4 to reduce DNS rebinding risk, and exposes allowPrivateNetwork/allowPrivateRemoteBase escapes for Ollama probes and test fixtures. Minor issues: redactUrlForLog preserves query params in blocked-URL errors, and _privilegedFetch bypasses the upstream concurrency slot.
src-tauri/sidecar/local-api-server.test.mjs Adds a regression test for the SSRF fix (#3549), refactors two fetch-body tests to route through the sidecar's own echo handler instead of a separate upstream server, and adds allowPrivateRemoteBase: true to cloud-fallback tests that use local mock servers. Test name 'strips browser origin headers' is now misleading since the assertion changed to the replacement origin.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Handler calls fetch] --> B{http or https?}
    B -- No --> C[_originalFetch passthrough]
    B -- Yes --> D{isAllowedPrivateSidecarFetch?}
    D -- Yes --> E[Skip SSRF check, no IP pinning]
    D -- No --> F[isSafeUrl: DNS resolve and check all IPs are public]
    F -- unsafe --> G[Throw ERR_SSRF_BLOCKED]
    F -- safe --> H[Pin first IPv4 via lookup callback]
    E --> I[acquireUpstreamSlot]
    H --> I
    I --> J[http or https.request with family 4]
    J --> K[releaseUpstreamSlot]
Loading

Comments Outside Diff (1)

  1. src-tauri/sidecar/local-api-server.test.mjs, line 540-562 (link)

    P2 Test name no longer matches the asserted behavior

    The test is titled 'strips browser origin headers when proxying to cloud fallback' but now asserts body.origin === 'https://worldmonitor.app' — meaning the browser Origin is replaced with the canonical app origin, not stripped. While the actual behavior in proxyToCloud (setting Origin: 'https://worldmonitor.app' after stripping the browser origin) is correct and pre-existing, the test name will mislead anyone reading it later.

Reviews (1): Last reviewed commit: "fix(sidecar): block private targets in g..." | Re-trigger Greptile

@@ -17,6 +18,8 @@ const brotliCompressAsync = promisify(brotliCompress);
// IPv6 endpoints time out, causing ETIMEDOUT. This override ensures ALL
// fetch() calls in dynamically-loaded handler modules (api/*.js) use IPv4.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 _privilegedFetch bypasses upstream concurrency slot

_privilegedFetch calls _originalFetch directly, skipping both the SSRF guard and the acquireUpstreamSlot / releaseUpstreamSlot rate-limiter that ipv4Fetch enforces. Because fetchWithTimeout uses _privilegedFetch for HTTP calls whenever allowPrivateNetwork: true, concurrent start-up warm-up probes to Ollama and LLM_API_URL can fire simultaneously and unconstrained. In the current code the affected callers are limited and low-volume, but any future caller that sets allowPrivateNetwork: true on an HTTP URL will silently escape the concurrency budget.

Comment on lines 93 to +101
return _yahooQueue;
}

function redactUrlForLog(rawUrl) {
try {
const redacted = new URL(String(rawUrl));
redacted.username = '';
redacted.password = '';
return redacted.toString();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 redactUrlForLog preserves query parameters

redactUrlForLog strips username and password but leaves the full path and query string intact in the ERR_SSRF_BLOCKED error message. Credentials or tokens passed as query parameters (common in OAuth callback URLs and third-party integrations) would be surfaced verbatim in the blocked-URL error, which may end up in logs that capture the thrown Error's message.

@lspassos1 lspassos1 force-pushed the fix/sidecar-fetch-ssrf branch from 7f5fbb0 to a8e9f90 Compare May 5, 2026 19:13
@lspassos1
Copy link
Copy Markdown
Collaborator Author

Reviewed all bot feedback on #3608 and addressed the actionable items in a8e9f90:

  • Codex P1: pinned DNS lookup callbacks now run asynchronously via queueMicrotask, with a regression test covering the global fetch path.
  • Greptile P2: blocked-URL errors now strip query strings/fragments so tokens are not echoed in handler error messages.
  • Greptile P2: privileged private-network fetches now still run inside the sidecar upstream concurrency limiter.
  • Greptile P2: renamed the cloud fallback Origin test to match the actual canonical-origin replacement behavior.

Re-ran node --test src-tauri/sidecar/local-api-server.test.mjs (39/39), npm run test:sidecar (96/96), typecheck, typecheck:api, targeted Biome lint, and diff whitespace check.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8e9f90ebc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +775 to +776
const fetchImpl = allowPrivateNetwork ? _privilegedFetch : fetch;
return await fetchImpl(fetchUrl, { ...fetchOptions, headers: fetchHeaders, signal: controller.signal });
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 Badge Preserve IPv4-only routing for private network probe requests

fetchWithTimeout() now routes allowPrivateNetwork calls through _originalFetch (const fetchImpl = allowPrivateNetwork ? _privilegedFetch : fetch), which bypasses the sidecar's globalThis.fetch IPv4 override. That regression affects newly-updated call sites like validateSecretAgainstProvider() and /api/llm-health probes that pass allowPrivateNetwork: true: these requests can again prefer/bind to IPv6 and fail or time out on environments where IPv6 is misconfigured but IPv4 is healthy, causing false negative health/validation results.

Useful? React with 👍 / 👎.

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.

[SECURITY] Tauri sidecar globalThis.fetch replacement has no SSRF guard — RSS proxy guard is not the global path

1 participant