VYPR
Critical severity9.6NVD Advisory· Published Apr 3, 2026· Updated Apr 8, 2026

CVE-2026-31818

CVE-2026-31818

Description

Budibase is an open-source low-code platform. Prior to version 3.33.4, a 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 issue has been patched in version 3.33.4.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@budibase/backend-corenpm
< 3.33.43.33.4

Affected products

1

Patches

1
5b0fe83d4ece

Merge pull request #18236 from Budibase/fix/ssrf-rest

https://github.com/Budibase/budibasemelohaganMar 13, 2026via ghsa
4 files changed · +298 69
  • packages/backend-core/src/blacklist/blacklist.ts+114 26 modified
    @@ -3,52 +3,140 @@ import net from "net"
     import env from "../environment"
     import { promisify } from "util"
     
    -let blackListArray: string[] | undefined
    +const DEFAULT_BLACKLIST = [
    +  "127.0.0.0/8",
    +  "10.0.0.0/8",
    +  "172.16.0.0/12",
    +  "192.168.0.0/16",
    +  "169.254.0.0/16",
    +  "0.0.0.0/8",
    +  "::1/128",
    +  "fc00::/7",
    +  "fe80::/10",
    +] as const
    +
    +let blackList: net.BlockList | undefined
     const performLookup = promisify(dns.lookup)
     
    -async function lookup(address: string): Promise<string[]> {
    -  if (!net.isIP(address)) {
    -    // need this for URL parsing simply
    -    if (!address.startsWith("http")) {
    -      address = `https://${address}`
    -    }
    -    address = new URL(address).hostname
    +function getIpVersion(address: string): "ipv4" | "ipv6" {
    +  return net.isIP(address) === 6 ? "ipv6" : "ipv4"
    +}
    +
    +function getMaxPrefixLength(address: string): number | null {
    +  const ipVersion = net.isIP(address)
    +  if (ipVersion === 4) {
    +    return 32
       }
    +  if (ipVersion === 6) {
    +    return 128
    +  }
    +  return null
    +}
    +
    +function getValidSubnetPrefix(address: string, prefix: string): number | null {
    +  if (!/^\d+$/.test(prefix)) {
    +    return null
    +  }
    +
    +  const parsedPrefix = Number.parseInt(prefix, 10)
    +  const maxPrefixLength = getMaxPrefixLength(address)
    +  if (maxPrefixLength == null || parsedPrefix > maxPrefixLength) {
    +    return null
    +  }
    +
    +  return parsedPrefix
    +}
    +
    +function parseAddress(address: string) {
    +  if (net.isIP(address)) {
    +    return address
    +  }
    +  if (!address.startsWith("http")) {
    +    address = `https://${address}`
    +  }
    +  return new URL(address).hostname.replace(/^\[|\]$/g, "")
    +}
    +
    +async function lookup(address: string): Promise<string[]> {
    +  address = parseAddress(address)
       const addresses = await performLookup(address, {
         all: true,
       })
       return addresses.map(addr => addr.address)
     }
     
    +function addEntryToBlacklist(blockList: net.BlockList, entry: string) {
    +  const trimmed = entry.trim()
    +  if (!trimmed) {
    +    return
    +  }
    +
    +  const segments = trimmed.split("/")
    +  if (segments.length > 2) {
    +    console.log(`Ignoring invalid blacklist entry: ${trimmed}`)
    +    return
    +  }
    +
    +  const [ip, prefix] = segments
    +  const parsedIp = net.isIP(ip)
    +  if (segments.length === 2) {
    +    const parsedPrefix = getValidSubnetPrefix(ip, prefix)
    +    if (parsedIp && parsedPrefix !== null) {
    +      blockList.addSubnet(ip, parsedPrefix, getIpVersion(ip))
    +      return
    +    }
    +
    +    console.log(`Ignoring invalid blacklist entry: ${trimmed}`)
    +    return
    +  }
    +
    +  if (parsedIp) {
    +    blockList.addAddress(ip, getIpVersion(ip))
    +  }
    +}
    +
     export async function refreshBlacklist() {
    -  const blacklist = env.BLACKLIST_IPS
    -  const list = blacklist?.split(",") || []
    -  let final: string[] = []
    -  for (let addr of list) {
    -    const trimmed = addr.trim()
    -    if (!net.isIP(trimmed)) {
    -      const addresses = await lookup(trimmed)
    -      final = final.concat(addresses)
    -    } else {
    -      final.push(trimmed)
    +  const next = new net.BlockList()
    +  for (const entry of DEFAULT_BLACKLIST) {
    +    addEntryToBlacklist(next, entry)
    +  }
    +
    +  const configuredBlacklist = env.BLACKLIST_IPS?.split(",") || []
    +  for (const entry of configuredBlacklist) {
    +    const trimmed = entry.trim()
    +    if (!trimmed) {
    +      continue
    +    }
    +
    +    const [ip] = trimmed.split("/")
    +    if (net.isIP(ip)) {
    +      addEntryToBlacklist(next, trimmed)
    +      continue
    +    }
    +
    +    const addresses = await lookup(trimmed)
    +    for (const address of addresses) {
    +      addEntryToBlacklist(next, address)
         }
       }
    -  blackListArray = final
    +
    +  blackList = next
     }
     
     export async function isBlacklisted(address: string): Promise<boolean> {
    -  if (!blackListArray) {
    +  if (!blackList) {
         await refreshBlacklist()
       }
    -  if (blackListArray?.length === 0) {
    -    return false
    -  }
    -  // no need for DNS
    +
       let ips: string[]
       if (!net.isIP(address)) {
    -    ips = await lookup(address)
    +    try {
    +      ips = await lookup(address)
    +    } catch {
    +      return true
    +    }
       } else {
         ips = [address]
       }
    -  return !!blackListArray?.find(addr => ips.includes(addr))
    +  return ips.some(ip => blackList!.check(ip, getIpVersion(ip)))
     }
    
  • packages/backend-core/src/blacklist/tests/blacklist.spec.ts+139 30 modified
    @@ -1,46 +1,155 @@
     import { refreshBlacklist, isBlacklisted } from ".."
    -import env from "../../environment"
    +import { setEnv } from "../../environment"
     
     describe("blacklist", () => {
    -  beforeAll(async () => {
    -    env._set(
    -      "BLACKLIST_IPS",
    -      "www.google.com,192.168.1.1, 1.1.1.1,2.2.2.2/something"
    -    )
    -    await refreshBlacklist()
    -  })
    +  describe("default ranges", () => {
    +    let restoreEnv: (() => void) | undefined
     
    -  it("should blacklist 192.168.1.1", async () => {
    -    expect(await isBlacklisted("192.168.1.1")).toBe(true)
    -  })
    +    beforeAll(async () => {
    +      restoreEnv = setEnv({ BLACKLIST_IPS: undefined })
    +      await refreshBlacklist()
    +    })
     
    -  it("should allow 192.168.1.2", async () => {
    -    expect(await isBlacklisted("192.168.1.2")).toBe(false)
    -  })
    +    afterAll(async () => {
    +      restoreEnv?.()
    +      await refreshBlacklist()
    +    })
     
    -  it("should blacklist www.google.com", async () => {
    -    expect(await isBlacklisted("www.google.com")).toBe(true)
    -  })
    +    it("should blacklist localhost", async () => {
    +      expect(await isBlacklisted("127.0.0.1")).toBe(true)
    +    })
     
    -  it("should handle a complex domain", async () => {
    -    expect(
    -      await isBlacklisted("https://www.google.com/derp/?something=1")
    -    ).toBe(true)
    -  })
    +    it("should blacklist RFC1918 addresses", async () => {
    +      expect(await isBlacklisted("192.168.1.1")).toBe(true)
    +      expect(await isBlacklisted("10.0.0.1")).toBe(true)
    +      expect(await isBlacklisted("172.16.0.1")).toBe(true)
    +    })
     
    -  it("should allow www.microsoft.com", async () => {
    -    expect(await isBlacklisted("www.microsoft.com")).toBe(false)
    +    it("should blacklist link-local addresses", async () => {
    +      expect(await isBlacklisted("169.254.169.254")).toBe(true)
    +    })
    +
    +    it("should allow public IPs by default", async () => {
    +      expect(await isBlacklisted("8.8.8.8")).toBe(false)
    +    })
    +
    +    it("should block addresses that fail lookup or parsing", async () => {
    +      expect(await isBlacklisted("http://[")).toBe(true)
    +    })
    +
    +    it("should block addresses when DNS lookup fails", async () => {
    +      expect(await isBlacklisted("https://budibase-ssrf.invalid")).toBe(true)
    +    })
       })
     
    -  it("should blacklist an IP that needed trimming", async () => {
    -    expect(await isBlacklisted("1.1.1.1")).toBe(true)
    +  describe("configured entries", () => {
    +    let restoreEnv: (() => void) | undefined
    +
    +    beforeAll(async () => {
    +      restoreEnv = setEnv({
    +        BLACKLIST_IPS: "www.google.com,192.168.1.1,1.1.1.1",
    +      })
    +      await refreshBlacklist()
    +    })
    +
    +    afterAll(async () => {
    +      restoreEnv?.()
    +      await refreshBlacklist()
    +    })
    +
    +    it("should blacklist 192.168.1.1", async () => {
    +      expect(await isBlacklisted("192.168.1.1")).toBe(true)
    +    })
    +
    +    it("should allow public IPs that are not configured", async () => {
    +      expect(await isBlacklisted("8.8.8.8")).toBe(false)
    +    })
    +
    +    it("should blacklist www.google.com", async () => {
    +      expect(await isBlacklisted("www.google.com")).toBe(true)
    +    })
    +
    +    it("should handle a complex domain", async () => {
    +      expect(
    +        await isBlacklisted("https://www.google.com/derp/?something=1")
    +      ).toBe(true)
    +    })
    +
    +    it("should allow www.microsoft.com", async () => {
    +      expect(await isBlacklisted("www.microsoft.com")).toBe(false)
    +    })
    +
    +    it("should blacklist an IP that needed trimming", async () => {
    +      expect(await isBlacklisted("1.1.1.1")).toBe(true)
    +    })
    +
    +    it("should blacklist 1.1.1.1/something", async () => {
    +      expect(await isBlacklisted("1.1.1.1/something")).toBe(true)
    +    })
       })
     
    -  it("should blacklist 1.1.1.1/something", async () => {
    -    expect(await isBlacklisted("1.1.1.1/something")).toBe(true)
    +  describe("malformed CIDR entries", () => {
    +    let restoreEnv: (() => void) | undefined
    +
    +    afterEach(async () => {
    +      restoreEnv?.()
    +      restoreEnv = undefined
    +      await refreshBlacklist()
    +    })
    +
    +    it("should ignore out of range ipv4 prefixes", async () => {
    +      restoreEnv = setEnv({ BLACKLIST_IPS: "1.1.1.1/33" })
    +
    +      await refreshBlacklist()
    +
    +      expect(await isBlacklisted("1.1.1.1")).toBe(false)
    +      expect(await isBlacklisted("1.1.1.2")).toBe(false)
    +    })
    +
    +    it("should ignore partially numeric prefixes", async () => {
    +      restoreEnv = setEnv({ BLACKLIST_IPS: "2.2.2.2/1foo" })
    +
    +      await refreshBlacklist()
    +
    +      expect(await isBlacklisted("2.2.2.2")).toBe(false)
    +      expect(await isBlacklisted("64.0.0.1")).toBe(false)
    +    })
    +
    +    it("should ignore empty prefixes", async () => {
    +      restoreEnv = setEnv({ BLACKLIST_IPS: "3.3.3.3/" })
    +
    +      await refreshBlacklist()
    +
    +      expect(await isBlacklisted("3.3.3.3")).toBe(false)
    +    })
    +
    +    it("should ignore entries with multiple slashes", async () => {
    +      restoreEnv = setEnv({ BLACKLIST_IPS: "4.4.4.4/24/extra" })
    +
    +      await refreshBlacklist()
    +
    +      expect(await isBlacklisted("4.4.4.4")).toBe(false)
    +      expect(await isBlacklisted("4.4.4.5")).toBe(false)
    +    })
       })
     
    -  it("should blacklist 2.2.2.2", async () => {
    -    expect(await isBlacklisted("2.2.2.2")).toBe(true)
    +  describe("valid CIDR entries", () => {
    +    let restoreEnv: (() => void) | undefined
    +
    +    afterEach(async () => {
    +      restoreEnv?.()
    +      restoreEnv = undefined
    +      await refreshBlacklist()
    +    })
    +
    +    it("should blacklist the full configured ipv4 subnet", async () => {
    +      restoreEnv = setEnv({ BLACKLIST_IPS: "5.5.5.0/24" })
    +
    +      await refreshBlacklist()
    +
    +      expect(await isBlacklisted("5.5.5.1")).toBe(true)
    +      expect(await isBlacklisted("5.5.5.200")).toBe(true)
    +      expect(await isBlacklisted("5.5.6.1")).toBe(false)
    +    })
       })
     })
    
  • packages/server/src/api/routes/tests/queries/rest.spec.ts+38 6 modified
    @@ -2,9 +2,10 @@ import * as setup from "../utilities"
     import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
     import { BodyType, Datasource, SourceName } from "@budibase/types"
     import { getCachedVariable } from "../../../../threads/utils"
    +import { blacklist, setEnv as setCoreEnv } from "@budibase/backend-core"
     import { generator } from "@budibase/backend-core/tests"
     import type { MockAgent } from "undici"
    -import { setEnv } from "../../../../environment"
    +import { setEnv as setServerEnv } from "../../../../environment"
     import { installHttpMocking, resetHttpMocking } from "../../../../tests/jestEnv"
     
     describe("rest", () => {
    @@ -120,7 +121,7 @@ describe("rest", () => {
       }
     
       beforeAll(async () => {
    -    restoreEnv = setEnv({ REST_REJECT_UNAUTHORIZED: false })
    +    restoreEnv = setServerEnv({ REST_REJECT_UNAUTHORIZED: false })
         config = setup.getConfig()
         await config.init()
         datasource = await config.api.datasource.create({
    @@ -150,7 +151,7 @@ describe("rest", () => {
     
       it("should automatically retry on fail with cached dynamics", async () => {
         const basedOnQuery = await createQuery({
    -      path: "one.example.com",
    +      path: "example.com",
         })
     
         let cached = await getCachedVariable(basedOnQuery._id!, "foo")
    @@ -176,10 +177,10 @@ describe("rest", () => {
         const body1 = [{ name: "one" }]
         const body2 = [{ name: "two" }]
         mockAgent!
    -      .get("http://one.example.com")
    +      .get("http://example.com")
           .intercept({ path: "/", method: "GET" })
           .reply(200, body1, { headers: jsonHeaders })
    -    const twoExample = mockAgent!.get("http://two.example.com")
    +    const twoExample = mockAgent!.get("http://example.org")
         twoExample
           .intercept({ path: "/", method: "GET", query: { test: "one" } })
           .reply(500, { message: "fail" }, { headers: jsonHeaders })
    @@ -196,7 +197,7 @@ describe("rest", () => {
           schema: {},
           readable: true,
           fields: {
    -        path: "two.example.com",
    +        path: "example.org",
             queryString: "test={{ foo }}",
           },
         })
    @@ -209,6 +210,37 @@ describe("rest", () => {
         expect(cached.rows[0].name).toEqual("one")
       })
     
    +  it("should block localhost requests when BLACKLIST_IPS is unset", async () => {
    +    const resetBlacklistEnv = setCoreEnv({ BLACKLIST_IPS: undefined })
    +    await blacklist.refreshBlacklist()
    +
    +    try {
    +      await config.api.query.preview(
    +        {
    +          datasourceId: datasource._id!,
    +          name: "test query",
    +          parameters: [],
    +          queryVerb: "read",
    +          transformer: "",
    +          schema: {},
    +          readable: true,
    +          fields: {
    +            path: "http://127.0.0.1:5984",
    +          },
    +        },
    +        {
    +          status: 400,
    +          body: {
    +            message: "Cannot connect to URL.",
    +          },
    +        }
    +      )
    +    } finally {
    +      resetBlacklistEnv()
    +      await blacklist.refreshBlacklist()
    +    }
    +  })
    +
       it("should update schema when structure changes from JSON to array", async () => {
         const datasource = await config.api.datasource.create({
           name: generator.guid(),
    
  • packages/server/src/automations/tests/oauth2.spec.ts+7 7 modified
    @@ -94,7 +94,7 @@ describe("OAuth2 Automation Binding", () => {
             config: {
               clientID: "test_client_id",
               clientSecret: "test_client_secret",
    -          callbackURL: "http://callback.example.com",
    +          callbackURL: "http://example.com/callback",
               activated: true,
             },
           }
    @@ -122,7 +122,7 @@ describe("OAuth2 Automation Binding", () => {
     
       it("should make OAuth2 token available in executeQuery automation step", async () => {
         await config.withUser(ssoUser, async () => {
    -      const pool = getPool("https://api.example.com")
    +      const pool = getPool("https://example.com")
           let requestCount = 0
     
           pool
    @@ -144,7 +144,7 @@ describe("OAuth2 Automation Binding", () => {
           })
     
           const queryFields: RestQueryFields = {
    -        path: "https://api.example.com/test",
    +        path: "https://example.com/test",
             queryString: "",
             headers: {
               Authorization: "Bearer {{ user.oauth2.accessToken }}",
    @@ -174,7 +174,7 @@ describe("OAuth2 Automation Binding", () => {
     
       it("should make OAuth2 token available in apiRequest automation step", async () => {
         await config.withUser(ssoUser, async () => {
    -      const pool = getPool("https://api.example.com")
    +      const pool = getPool("https://example.com")
           let requestCount = 0
     
           pool
    @@ -203,7 +203,7 @@ describe("OAuth2 Automation Binding", () => {
           })
     
           const queryFields: RestQueryFields = {
    -        path: "https://api.example.com/api-request",
    +        path: "https://example.com/api-request",
             queryString: "",
             headers: {
               Authorization: "Bearer {{ user.oauth2.accessToken }}",
    @@ -258,7 +258,7 @@ describe("OAuth2 Automation Binding", () => {
     
       it("should handle 401 Unauthorized and retry mechanism in apiRequest step", async () => {
         await config.withUser(ssoUser, async () => {
    -      const apiPool = getPool("https://api.example.com")
    +      const apiPool = getPool("https://example.com")
           let firstCall = true
     
           apiPool
    @@ -293,7 +293,7 @@ describe("OAuth2 Automation Binding", () => {
           })
     
           const queryFields: RestQueryFields = {
    -        path: "https://api.example.com/protected-endpoint",
    +        path: "https://example.com/protected-endpoint",
             queryString: "",
             headers: {
               Authorization: "Bearer {{ user.oauth2.accessToken }}",
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.