Unauthorized access to screen In github.com/0xjacky/nginx-ui

Description

Nginx-UI: Authenticated settings disclosure exposes node.secret and enables trusted-node authentication abuse, backup exfiltration, and restore-based nginx-ui state rollback ## 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: http 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. go 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. go 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: go 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. 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 } 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. go 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: go 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: go 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: go 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

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Package
Affected version