GHSA-45GV-9WJV-XH7P
Vulnerability from github – Published: 2026-10-09 17:08 – Updated: 2026-10-09 17:08Summary
nginx-ui supports two second-factor methods — TOTP (OTP) and WebAuthn passkeys —
and reports an account as 2FA-enabled when either is configured. However, the
password login endpoint (POST /api/login) only enforces a second factor when a
TOTP secret is present. An account that has registered a passkey but no
TOTP is logged in after password verification alone — the passkey is never
requested. This silently downgrades a passkey-protected account to single-factor
(password-only) authentication.
Details
The account's 2FA policy treats passkeys as a valid factor (model/user.go):
func (u *User) EnabledOTP() bool { return len(u.OTPSecret) != 0 }
func (u *User) EnabledPasskey() bool { /* true if a passkey row exists */ }
func (u *User) Enabled2FA() bool { return u.EnabledOTP() || u.EnabledPasskey() }
func (u *User) AfterFind(_ *gorm.DB) error {
u.EnabledTwoFA = u.Enabled2FA() // exposed to the UI as `enabled_2fa`
return nil
}
But the login handler only checks EnabledOTP() (api/user/auth.go, Login):
u, err := user.Login(json.Name, json.Password)
...
if u.EnabledOTP() { // <-- only TOTP is enforced
if json.OTP == "" && json.RecoveryCode == "" {
c.JSON(http.StatusOK, LoginResponse{Message: "The user has enabled 2FA", Code: Enabled2FA}) // 199
user.BanIP(clientIP)
return
}
if _, err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { /* ... */ }
secureSessionID = user.SetSecureSessionID(u.ID)
}
// Passkey-only accounts fall through to here and receive a full session token:
accessToken, err := user.GenerateJWT(u)
No branch requires a WebAuthn assertion during password login when
EnabledPasskey() is true. The root cause is the mismatch between the
policy definition (Enabled2FA() = OTP or passkey) and the
enforcement check (EnabledOTP() only).
The same EnabledOTP()-only gating in RequireSecureSession()
(internal/middleware/secure_session.go) means passkey-only users are also
exempted from step-up on sensitive actions.
Proof of Concept
Tested against nginx-ui built from source (go build -tags unembed) on
127.0.0.1:9000, with WebAuthn configured and a victim account that has a
registered passkey and no TOTP. The vulnerable code is identical on the
production main branch (api/user/auth.go Login gates on u.EnabledOTP()
only; verified at commit 6c86e5a, 2026-05-17).
Account state advertised by GET /api/2fa_status:
{"enabled":true,"otp_status":false,"passkey_status":true, ...}
Attacker logs in with password only (POST /api/login, encrypted params as
the client normally sends):
HTTP 200
{"message":"ok","code":200,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."}
code: 200 + a valid JWT = an authenticated session, with the passkey never
used. For comparison, the identical account with a TOTP secret instead correctly
returns the second-factor challenge:
HTTP 200
{"message":"The user has enabled 2FA","code":199}
| Account state | POST /api/login (password only) |
|---|---|
| Passkey registered, no TOTP | code 200 + JWT — 2FA NOT enforced |
| TOTP registered | code 199 — 2FA challenge enforced |
This isolates the defect: the login path enforces OTP but ignores passkeys.
Impact
- Any account protected only by a passkey is reduced to password-only authentication. An attacker who obtains the password (phishing, reuse, leak) gains full access despite the registered security key.
- In nginx-ui all authenticated users are effectively administrators and the terminal feature grants a host shell, so account takeover leads to full control of the managed nginx instance / host.
- Users are given a false sense of security: the UI shows the account as 2FA-enabled while the second factor is not enforced at login.
Remediation
- Gate the second-factor decision on
u.Enabled2FA()(notu.EnabledOTP()) inLogin, in both SSO callbacks, and inRequireSecureSession(). - For a passkey-only user logging in with a password, return a
"passkey assertion required" challenge and complete login only after a
successful WebAuthn assertion (the
begin_passkey_login/finish_passkey_loginflow already exists and should be required as the second step). - Centralize session-token issuance so no login entry point can skip the 2FA decision.
Affected components
api/user/auth.go—Loginmodel/user.go—EnabledOTP,EnabledPasskey,Enabled2FA,AfterFindapi/user/2fa.go—get2FAStatusinternal/middleware/secure_session.go—RequireSecureSession
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/0xJacky/Nginx-UI"
},
"ranges": [
{
"events": [
{
"introduced": "1.9.10-0.20250517140552-daee3ac7ade1"
},
{
"fixed": "1.9.10-0.20260728074558-95cd21b70814"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-107808"
],
"database_specific": {
"cwe_ids": [
"CWE-287",
"CWE-305",
"CWE-308"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-09T17:08:15Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nnginx-ui supports two second-factor methods \u2014 TOTP (OTP) and WebAuthn passkeys \u2014\nand reports an account as 2FA-enabled when **either** is configured. However, the\npassword login endpoint (`POST /api/login`) only enforces a second factor when a\n**TOTP secret** is present. An account that has registered a **passkey but no\nTOTP** is logged in after password verification alone \u2014 the passkey is never\nrequested. This silently downgrades a passkey-protected account to single-factor\n(password-only) authentication.\n\n## Details\n\nThe account\u0027s 2FA policy treats passkeys as a valid factor (`model/user.go`):\n\n```go\nfunc (u *User) EnabledOTP() bool { return len(u.OTPSecret) != 0 }\nfunc (u *User) EnabledPasskey() bool { /* true if a passkey row exists */ }\nfunc (u *User) Enabled2FA() bool { return u.EnabledOTP() || u.EnabledPasskey() }\n\nfunc (u *User) AfterFind(_ *gorm.DB) error {\n u.EnabledTwoFA = u.Enabled2FA() // exposed to the UI as `enabled_2fa`\n return nil\n}\n```\n\nBut the login handler only checks `EnabledOTP()` (`api/user/auth.go`, `Login`):\n\n```go\nu, err := user.Login(json.Name, json.Password)\n...\nif u.EnabledOTP() { // \u003c-- only TOTP is enforced\n if json.OTP == \"\" \u0026\u0026 json.RecoveryCode == \"\" {\n c.JSON(http.StatusOK, LoginResponse{Message: \"The user has enabled 2FA\", Code: Enabled2FA}) // 199\n user.BanIP(clientIP)\n return\n }\n if _, err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { /* ... */ }\n secureSessionID = user.SetSecureSessionID(u.ID)\n}\n\n// Passkey-only accounts fall through to here and receive a full session token:\naccessToken, err := user.GenerateJWT(u)\n```\n\nNo branch requires a WebAuthn assertion during password login when\n`EnabledPasskey()` is true. The root cause is the mismatch between the\n**policy definition** (`Enabled2FA()` = OTP **or** passkey) and the\n**enforcement check** (`EnabledOTP()` only).\n\nThe same `EnabledOTP()`-only gating in `RequireSecureSession()`\n(`internal/middleware/secure_session.go`) means passkey-only users are also\nexempted from step-up on sensitive actions.\n\n## Proof of Concept\n\nTested against nginx-ui built from source (`go build -tags unembed`) on\n`127.0.0.1:9000`, with WebAuthn configured and a victim account that has a\nregistered passkey and **no** TOTP. The vulnerable code is identical on the\nproduction `main` branch (`api/user/auth.go` `Login` gates on `u.EnabledOTP()`\nonly; verified at commit `6c86e5a`, 2026-05-17).\n\nAccount state advertised by `GET /api/2fa_status`:\n\n```json\n{\"enabled\":true,\"otp_status\":false,\"passkey_status\":true, ...}\n```\n\nAttacker logs in with **password only** (`POST /api/login`, encrypted params as\nthe client normally sends):\n\n```\nHTTP 200\n{\"message\":\"ok\",\"code\":200,\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....\"}\n```\n\n`code: 200` + a valid JWT = an authenticated session, with the passkey never\nused. For comparison, the identical account with a TOTP secret instead correctly\nreturns the second-factor challenge:\n\n```\nHTTP 200\n{\"message\":\"The user has enabled 2FA\",\"code\":199}\n```\n\n| Account state | `POST /api/login` (password only) |\n|---|---|\n| Passkey registered, no TOTP | `code 200` + JWT \u2014 **2FA NOT enforced** |\n| TOTP registered | `code 199` \u2014 2FA challenge enforced |\n\nThis isolates the defect: the login path enforces OTP but ignores passkeys.\n\n## Impact\n\n- Any account protected **only** by a passkey is reduced to password-only\n authentication. An attacker who obtains the password (phishing, reuse, leak)\n gains full access despite the registered security key.\n- In nginx-ui all authenticated users are effectively administrators and the\n terminal feature grants a host shell, so account takeover leads to full\n control of the managed nginx instance / host.\n- Users are given a false sense of security: the UI shows the account as\n 2FA-enabled while the second factor is not enforced at login.\n\n## Remediation\n\n- Gate the second-factor decision on `u.Enabled2FA()` (not `u.EnabledOTP()`) in\n `Login`, in both SSO callbacks, and in `RequireSecureSession()`.\n- For a passkey-only user logging in with a password, return a\n \"passkey assertion required\" challenge and complete login only after a\n successful WebAuthn assertion (the `begin_passkey_login` / `finish_passkey_login`\n flow already exists and should be required as the second step).\n- Centralize session-token issuance so no login entry point can skip the 2FA\n decision.\n\n## Affected components\n\n- `api/user/auth.go` \u2014 `Login`\n- `model/user.go` \u2014 `EnabledOTP`, `EnabledPasskey`, `Enabled2FA`, `AfterFind`\n- `api/user/2fa.go` \u2014 `get2FAStatus`\n- `internal/middleware/secure_session.go` \u2014 `RequireSecureSession`",
"id": "GHSA-45gv-9wjv-xh7p",
"modified": "2026-10-09T17:08:15Z",
"published": "2026-10-09T17:08:15Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-45gv-9wjv-xh7p"
},
{
"type": "WEB",
"url": "https://github.com/0xJacky/nginx-ui/commit/95cd21b70814e5d9a48a359aa238aeea1ac97429"
},
{
"type": "PACKAGE",
"url": "https://github.com/0xJacky/nginx-ui"
},
{
"type": "WEB",
"url": "https://github.com/0xJacky/nginx-ui/releases/tag/v2.5.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Nginx UI: Authentication bypass: password login does not enforce a passkey-only second factor (2FA bypass)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.