Skip to content

GHSA-7jrr-xw9c-mj39 CVE-2026-42220 GO-2026-5227

Nginx-UI: Authenticated settings disclosure exposes node.secret and enables trusted-node authentication abuse, backup exfiltration, and restore-based nginx-ui state rollback

Medium Go
Severity
6.5 / 10
Medium · CVSS 3.1
Exploit chance (EPSS)
0.30%
Chance of exploitation in the next 30 days. Higher than 22.63% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-200 Exposure of Sensitive Information to an Unauthorized Actor
CWE-863 Incorrect Authorization
Fix
None yet
No fixed version published

Description

Summary

An authenticated user can call GET /api/settings and retrieve sensitive configuration values, including node.secret. The same node.secret is accepted by AuthRequired() through the X-Node-Secret header (or node_secret query parameter), causing the request to be treated as authenticated via the trusted-node path and associated with the init user. In my local reproduction on v2.3.6, GET /api/settings also returned app.jwt_secret. After extracting node.secret, I was able to access GET /api/backup using only X-Node-Secret, download a full backup archive, and obtain the X-Backup-Security response header containing the backup decryption material (AESKey:AESIv). I also confirmed that the disclosed node.secret is sufficient to reach the restore workflow on an installed instance. Using only X-Node-Secret, a valid backup archive, and its matching X-Backup-Security token, I successfully invoked POST /api/restore. In a follow-up rollback test, I changed node.name to rollback-poc-B, then restored a previously captured backup and observed the value revert to its original state. This extends the issue beyond secret disclosure and backup exfiltration into confirmed integrity impact through restore-based rollback of nginx-ui state/configuration. This breaks the trust boundary between ordinary user-authenticated API access and the internal node-authentication mechanism, and results in sensitive configuration disclosure, alternate-authentication abuse, backup exfiltration with decryption material, and confirmed restore-based rollback of nginx-ui state.

Details

Vulnerable code / related files and functions

1) Route exposure and insufficient protection on the read path

File: api/settings/router.go

Relevant function: InitRouter

The settings router exposes the following endpoints:

GET /api/settings/server/name → GetServerName
GET /api/settings → GetSettings
POST /api/settings → RequireSecureSession(), SaveSettings

The key issue is that the read path (GET /api/settings) is only protected by the generic authentication middleware, while the write path (POST /api/settings) has an additional RequireSecureSession() check. This makes the read path a much easier place to leak sensitive configuration data than the write path.

r.GET("settings/server/name", GetServerName)
r.GET("settings", GetSettings)
r.POST("settings", middleware.RequireSecureSession(), SaveSettings)

2) Sensitive data is disclosed by GetSettings

File: api/settings/settings.go

Relevant functions: GetSettings, SaveSettings

GetSettings returns multiple configuration objects directly in the JSON response, including app, server, database, auth, casdoor, oidc, cert, http, logrotate, nginx, node, openai, terminal, and webauthn. In other words, the handler does not use a redacted DTO for user-facing output; it serializes the live settings objects directly.

c.JSON(http.StatusOK, gin.H{
  "app":       cSettings.AppSettings,
  "server":    cSettings.ServerSettings,
  "database":  settings.DatabaseSettings,
  "auth":      settings.AuthSettings,
  "casdoor":   settings.CasdoorSettings,
  "oidc":      settings.OIDCSettings,
  "cert":      settings.CertSettings,
  "http":      settings.HTTPSettings,
  "logrotate": settings.LogrotateSettings,
  "nginx":     settings.NginxSettings,
  "node":      settings.NodeSettings,
  "openai":    settings.OpenAISettings,
  "terminal":  settings.TerminalSettings,
  "webauthn":  settings.WebAuthnSettings,
})

In my local reproduction on v2.3.6, this response exposed both:

node.secret
app.jwt_secret

This makes GetSettings the direct disclosure source for the vulnerability.

3) The disclosed value is explicitly defined as protected/sensitive

File: settings/node.go

Relevant object: type Node

The Node settings object defines the following field:

type Node struct {
    Name   string `json:"name" binding:"omitempty,safety_text"`
    Secret string `json:"secret" protected:"true"`
    ...
}

The protected:"true" tag shows that the codebase itself treats node.secret as a protected/sensitive value. Despite that, the field is still returned unredacted by GetSettings. This strongly indicates a real secret disclosure issue rather than a harmless configuration read.

4) The disclosed secret is reused as an authentication credential

File: internal/middleware/middleware.go

Relevant functions: getNodeSecret, AuthRequired, AuthRequiredWS

The authentication middleware contains a separate node-secret authentication path:

  • getNodeSecret(c) reads the value from the X-Node-Secret header or the node_secret query parameter.
  • AuthRequired() checks whether the supplied value equals settings.NodeSettings.Secret.
  • If it matches, the middleware: loads initUser := user.GetInitUser(c) stores Secret in the context stores user in the context
  • allows the request to proceed without relying on the ordinary JWT path for that identity flow

This is the sink of the vulnerability: the same secret disclosed by GET /api/settings is accepted as a valid authentication credential by the middleware.

if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret {
    initUser := user.GetInitUser(c)
    c.Set("Secret", nodeSecret)
    c.Set("user", initUser)
    c.Next()
    return
}

AuthRequiredWS() contains similar logic for the WebSocket path, meaning the same secret is also trusted by the WebSocket authentication flow.

5) The write path already treats these fields as protected, but the read path does not

File: api/settings/settings.go

Relevant function: SaveSettings

SaveSettings() already uses ProtectedFill(...) for several settings objects, including:

AppSettings
NodeSettings
OpenAISettings
NginxSettings
OIDCSettings

This shows the project already recognizes that these objects contain protected fields on the write path. However, GetSettings() still returns the raw objects on the read path, creating a clear “write-protected but read-exposed” inconsistency. That inconsistency is the core authorization/secret-handling flaw here.

cSettings.ProtectedFill(cSettings.AppSettings, &json.App)
cSettings.ProtectedFill(settings.NodeSettings, &json.Node)
cSettings.ProtectedFill(settings.OpenAISettings, &json.Openai)
cSettings.ProtectedFill(settings.NginxSettings, &json.Nginx)
cSettings.ProtectedFill(settings.OIDCSettings, &json.Oidc)

6) Backup endpoint reachable after alternate authentication

File: api/backup/router.go, api/backup/backup.go

Relevant functions: InitRouter, CreateBackup

The backup route is exposed as:

r.GET("backup", CreateBackup)

This route is protected by the same AuthRequired() middleware chain as other authenticated API routes.

In CreateBackup(), the server returns the backup archive to the caller and also sets the X-Backup-Security response header containing the decryption material:

c.Header("X-Backup-Security", fmt.Sprintf("%s:%s", backup.Security.AESKey, backup.Security.AESIv))
c.File(backupFilePath)

As a result, once node.secret is disclosed from /api/settings and reused through X-Node-Secret, the attacker can access /api/backup and obtain both the encrypted backup and the decryption token in the same response.

This means the disclosed secret is not only usable for low-risk authenticated reads, but also for high-impact data exfiltration through the backup subsystem.

7) Restore endpoint is reachable and usable after alternate authentication

File: api/backup/router.go, api/backup/restore.go, internal/backup/restore.go

Relevant functions: authIfInstalled, RestoreBackup, internal restore helpers

The restore route is exposed as:

r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)

On installed instances, authIfInstalled calls AuthRequired(). Because AuthRequired() accepts X-Node-Secret and associates the request with the init user, the same disclosed node.secret can be used to reach the restore workflow, not just read-only or backup routes.

RestoreBackup() accepts:

  • backup_file
  • security_token
  • restore_nginx
  • restore_nginx_ui
  • verify_hash

It parses the security_token as AESKey:AESIv, decodes both values from base64, saves the uploaded backup archive to a temporary location, and then calls the internal restore logic.

In my local reproduction on v2.3.6, a request to POST /api/restore using only:

  • X-Node-Secret
  • a valid backup archive
  • the matching X-Backup-Security token

returned:

{"nginx_ui_restored":false,"nginx_restored":false,"hash_match":true}

for a no-op restore test, confirming that the restore path was reachable and processed successfully via the trusted-node authentication path.

I then performed an observable rollback test. After changing node.name to rollback-poc-B, I restored a previously captured backup using only X-Node-Secret plus the matching backup/security token pair. The server returned:

{"nginx_ui_restored":true,"nginx_restored":false,"hash_match":true}

and GET /api/settings/server/name changed from:

rollback-poc-B

back to its original empty value after the restore completed.

This confirms that the disclosed node.secret is sufficient not only for backup exfiltration, but also for successful restore invocation and rollback of nginx-ui state/configuration.

Why these files together form the vulnerability

These files combine into a single exploitable chain:

  • api/settings/router.go exposes the settings read endpoint to authenticated callers.
  • api/settings/settings.go:GetSettings returns raw settings objects, disclosing node.secret and other sensitive values.
  • settings/node.go confirms that node.secret is explicitly treated as a protected field.
  • internal/middleware/middleware.go:AuthRequired accepts that same secret as a valid alternate authentication factor and associates the request with the init user.

For that reason, this is not just a “settings disclosure” issue. It is more accurately described as:

secret disclosure in a user-facing API combined with reuse of the disclosed secret as an authentication factor in middleware.

Vulnerable source-to-sink path

The vulnerable chain spans the settings API, node authentication middleware, backup subsystem, and restore subsystem.

Source

An authenticated caller can reach:

  • GET /api/settings

The handler returns raw settings objects directly in the JSON response, including:

  • settings.NodeSettings
  • cSettings.AppSettings
  • settings.OpenAISettings
  • other configuration objects

In my local reproduction on v2.3.6, the response exposed:

  • node.secret
  • app.jwt_secret

Propagation

The attacker extracts node.secret from the /api/settings response and reuses it as:

  • X-Node-Secret header`, or
  • node_secret query parameter`

Authentication sink

AuthRequired() in internal/middleware/middleware.go checks whether the supplied node secret matches settings.NodeSettings.Secret. If it matches, the middleware loads initUser := user.GetInitUser(c), stores the user in the request context, and allows the request to proceed without using the ordinary JWT path for that identity flow.

Post-authentication sinks

After satisfying AuthRequired() through X-Node-Secret, the attacker can reach additional protected routes, including:

  • GET /api/settings/server/name
  • GET /api/settings
  • GET /api/backup
  • POST /api/restore (on installed instances via authIfInstalled → AuthRequired())`

In particular:

  • GET /api/backup returns the backup archive and sets the X-Backup-Security response header containing the decryption material (AESKey:AESIv)`
  • POST /api/restore accepts a backup archive plus the matching security_token and executes the restore workflow

This creates the following end-to-end source-to-sink chain:

  1. Authenticated caller reaches GET /api/settings
  2. Response discloses node.secret (and in my lab also app.jwt_secret)
  3. Attacker reuses node.secret as X-Node-Secret
  4. AuthRequired() accepts the request on the trusted-node path and associates it with the init user
  5. Attacker accesses GET /api/backup
  6. Server returns the encrypted backup archive and X-Backup-Security decryption material in the same response
  7. Attacker submits the captured backup and matching token to POST /api/restore using only X-Node-Secret
  8. Server processes the restore request successfully
  9. nginx-ui state/configuration can be rolled back to the contents of the captured backup

This is not just a read-only disclosure chain. It is a disclosure-to-authentication-to-backup-to-restore chain with confirmed integrity impact.

Why this is a vulnerability, not intended behavior

This is not expected behavior for three reasons:

  1. Node.Secret is explicitly marked protected:"true", indicating it is sensitive.
  2. SaveSettings() uses ProtectedFill(...) on NodeSettings, OpenAISettings, and other settings objects, showing the write path already treats these fields as protected/special.
  3. Despite that, GetSettings() still returns the raw secret-bearing objects to the caller, and the disclosed node.secret is immediately reusable as an authentication credential in middleware. That breaks the intended separation between user-facing configuration APIs and internal trusted-node authentication.

Trust boundary that is broken

The broken boundary is:

ordinary authenticated user/API session → trusted node / init-user authentication path

A caller who is only supposed to use the normal JWT/cookie-based user path can retrieve a secret that belongs to the trusted-node path, then cross that boundary by presenting X-Node-Secret to AuthRequired().

Attacker model / required privileges

The confirmed attacker requirement is:

  • ability to authenticate to the web UI and call GET /api/settings

In my local reproduction on v2.3.6, I reproduced this with a normal browser-authenticated session after resetting the initial account password in a fresh Docker deployment. The issue does not require shell access or direct database access. The route itself is protected, but the read-path has no additional redaction for secret-bearing settings, and the disclosed node secret can then be reused as alternate authentication.

Additional confirmed impact: backup exfiltration through the trusted-node authentication path

The impact is not limited to reading settings or downloading backups.

In api/backup/router.go, the restore endpoint is exposed as:

r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)

On installed instances, authIfInstalled calls AuthRequired(). Because AuthRequired() accepts X-Node-Secret and maps the request to the init user when the supplied secret matches settings.NodeSettings.Secret, the disclosed node.secret can also be reused to reach the restore workflow.

In api/backup/restore.go, RestoreBackup() accepts:

  • backup_file
  • security_token
  • restore_nginx
  • restore_nginx_ui
  • verify_hash

It parses security_token as AESKey:AESIv, decodes both values from base64, saves the uploaded backup archive, and invokes the internal restore logic.

In my local reproduction on v2.3.6, I first confirmed route reachability by submitting a valid backup archive and matching security_token using only X-Node-Secret, which returned:

{"nginx_ui_restored":false,"nginx_restored":false,"hash_match":true}

I then performed an observable rollback test:

  1. Captured a valid backup in state A
  2. Changed node.name to rollback-poc-B
  3. Verified GET /api/settings/server/name returned rollback-poc-B
  4. Submitted the previously captured backup to POST /api/restore using only X-Node-Secret and the matching security_token Received:
{"nginx_ui_restored":true,"nginx_restored":false,"hash_match":true}

Verified GET /api/settings/server/name returned the original empty value after restore

This confirms that the disclosed node.secret is sufficient not only for backup exfiltration, but also for successful restore invocation and rollback of nginx-ui state/configuration through the trusted-node authentication path.

PoC

Reproduction environment

  • Product: 0xJacky/nginx-ui
  • Confirmed version: v2.3.6
  • Deployment method: local Docker lab on http://127.0.0.1:8080 using uozi/nginx-ui:latest at the time of testing.

Exact reproduction steps

1.Start a fresh local Docker deployment of uozi/nginx-ui:latest.

Optional convenience settings I used in the lab:

NGINX_UI_NODE_SKIP_INSTALLATION=true
NGINX_UI_NODE_SECRET=<known test value>
NGINX_UI_APP_JWT_SECRET=<known test value>
NGINX_UI_IGNORE_DOCKER_SOCKET=true

These are documented environment settings supported by Nginx UI.

2.Reset the initial account password using the official command:

docker exec nginx-ui-lab nginx-ui reset-password --config=/etc/nginx-ui/app.ini

The application prints the username/password for the initial account. [Screenshot 1: password reset output showing the initial username/password] <img width="1919" height="274" alt="image" src="https://github.com/user-attachments/assets/ec37a0f1-8de5-42dd-beee-c6ddac458ab8" />

3.Log in through the browser and capture the JWT token from the login response or the token cookie. [Screenshot 2: browser/devtools showing authenticated session and token] <img width="1535" height="746" alt="image" src="https://github.com/user-attachments/assets/012b65a4-fa51-44a2-a8d0-bcb6a733cffa" />

4.Send:

GET /api/settings
Header: Authorization: <raw JWT>

In my reproduction, the response contained:

  • node.secret
  • app.jwt_secret
  • other settings objects such as openai, oidc, casdoor, nginx, etc.

Example PowerShell:

$Base = "http://127.0.0.1:8080"
$Jwt  = "<captured token>"
$authHeaders = @{ Authorization = $Jwt }
$settings = Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $authHeaders
$nodeSecret = $settings.node.secret
$settings | ConvertTo-Json -Depth 20

[Screenshot 3: /api/settings response showing node.secret and app.jwt_secret] <img width="1706" height="978" alt="image" src="https://github.com/user-attachments/assets/25fc3c94-e5b3-4309-8b49-09633fbe3b89" />

<img width="948" height="104" alt="image" src="https://github.com/user-attachments/assets/eca687a5-1e02-42a1-b196-155184db4226" />

5.Verify that the protected route fails without authentication:

Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name"

Expected result: 403 Forbidden.

[Screenshot 4: unauthenticated 403] <img width="1261" height="236" alt="image" src="https://github.com/user-attachments/assets/ba302b53-e4f7-414a-9a95-ea2b64a5e05a" />

6.Re-send the same request with only X-Node-Secret:

$nodeHeaders = @{ "X-Node-Secret" = $nodeSecret }
Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $nodeHeaders

Expected result: 200 OK with a JSON body such as:

{ "name": "" }

[Screenshot 5: successful response using only X-Node-Secret] <img width="1833" height="96" alt="image" src="https://github.com/user-attachments/assets/eef06152-2450-4701-9b06-6997d7ce24f5" />

7.Re-send GET /api/settings using only X-Node-Secret:

$settingsViaSecret = Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $nodeHeaders
$settingsViaSecret | ConvertTo-Json -Depth 20

Expected result: 200 OK, and the response again includes node.secret.

[Screenshot 6: /api/settings succeeding with only X-Node-Secret] <img width="1708" height="835" alt="image" src="https://github.com/user-attachments/assets/7401ba0e-fb7e-4de8-970a-39f8077c0748" />

8.Use the disclosed node.secret to access the backup endpoint:

$Base = "http://127.0.0.1:8080"
$nodeHeaders =…

Affected versions

Go

PackageVulnerableFixed in
github.com/0xJacky/Nginx-UI
<= 1.9.9
No fix yet
github.com/0xJacky/Nginx-UI
all versions
No fix yet

How to fix

No fixed version has been published yet. Until one is, remove or replace the package, or turn off or isolate the affected feature. This page updates when a fix ships.

Severity breakdown

CVSS 3.1 6.5

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

Weakness

CWE-200: Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information.

How to prevent it
  • Architecture and Design: Compartmentalize the system to have "safe" areas where trust boundaries can be unambiguously drawn. Do not allow sensitive data to go outside of the trust boundary and always be careful when interfacing with a compartment outside of the safe area. Ensure that appropriate compartmentalization is built into the system design, and the compartmentalization allows for and reinforces privilege separation functionality. Architects and designers should rely on the principle of least privilege to decide the appropriate time to use privileges and the time to drop privileges.

CWE-863: Incorrect Authorization

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check.

How to prevent it
  • Architecture and Design: Divide the product into anonymous, normal, privileged, and administrative areas. Reduce the attack surface by carefully mapping roles with data and functionality. Use role-based access control (RBAC) [REF-229] to enforce the roles at the appropriate boundaries. Note that this approach may not protect against horizontal authorization, i.e., it will not protect a user from attacking others with the same role.
  • Architecture and Design: Ensure that access control checks are performed related to the business logic. These checks may be different than the access control checks that are applied to more generic resources such as files, connections, processes, memory, and database records. For example, a database may restrict access for medical records to a specific database user, but each record might only be intended to be accessible to the patient and the patient's doctor [REF-7].
  • Architecture and Design: Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid. For example, consider using authorization frameworks such as the JAAS Authorization Framework [REF-233] and the OWASP ESAPI Access Control feature [REF-45].
  • Architecture and Design: For web applications, make sure that the access control mechanism is enforced correctly at the server side on every page. Users should not be able to access any unauthorized functionality or information by simply requesting direct access to that page. One way to do this is to ensure that all pages containing sensitive information are not cached, and that all such pages restrict access to requests that are accompanied by an active and authenticated session token associated with a user who has the required permissions to access that page.
  • System Configuration, Installation: Use the access control capabilities of your operating system and server environment and define your access control lists accordingly. Use a "default deny" policy when defining these ACLs.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GO-2026-6773
Nginx UI: Casdoor SSO callback uses a constant `state` parameter (login CSRF)
High Also affects github.com/0xJacky/Nginx-UI
GHSA-vc32-4gwf-77xp
Nginx UI: OTP secure-session step-up can be bypassed via MCP configuration tools
High Also affects github.com/0xJacky/Nginx-UI
GHSA-wq4w-vwvq-q3x9
Nginx UI: Standalone short tokens survive administrative revocation in cache and authenticate again after account re-enable
Medium Also affects github.com/0xJacky/Nginx-UI
GHSA-jrq7-xrjx-pcfw
Nginx UI: Auto backup name allows path traversal outside validated storage path
Medium Also affects github.com/0xJacky/Nginx-UI
GHSA-5r62-gj48-897x
Nginx UI: MCP configuration tools bypass restricted directive validation and allow secondary authenticated users to execute NJS in Nginx worker context
High Also affects github.com/0xJacky/Nginx-UI
GHSA-9jww-cxg5-rj9m
Nginx UI: MCP `nginx_config_add` Bypasses Directive Validation — Authenticated RCE
High Also affects github.com/0xJacky/Nginx-UI
GHSA-76pm-mq2q-9gcr
Nginx UI: MCP nginx_config_add skips config directive validation → authenticated nginx directive injection (RCE on the official image)
High Also affects github.com/0xJacky/Nginx-UI
GHSA-cf23-7qxj-xmhr
Nginx UI: nginx config directive denylist (ValidateConfigFile) is bypassable via same-line ";" separation → authenticated directive injection (RCE on the official image)
High Also affects github.com/0xJacky/Nginx-UI
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor