GHSA-5V7C-XPFP-P65M
Vulnerability from github – Published: 2026-10-15 20:12 – Updated: 2026-10-15 20:12Reporter: Jagadesh Achanta
Target: 0xJacky/nginx-ui
Affected version / commit tested:
- App version: 2.3.10
- Commit: 4948fba8744e82c3d7003f68ee164e9f49421066
- Disclosure channel: GitHub Private Vulnerability Reporting
- Credential reference: cred:github-jagstack-gh-cli
AI assistance disclosure
This report was prepared by Jagadesh Achanta with assistance from OpenAI/Codex for public source review, local evidence collection, and report drafting.
Summary
Nginx UI has a short-lived OTP/passkey "secure session" step-up boundary for users with 2FA enabled. RequireSecureSession() rejects an OTP-enabled user's request unless the request includes a valid X-Secure-Session-ID issued after a fresh OTP/passkey verification.
The project already applies this step-up middleware to sensitive operations such as:
- Nginx config create/edit/rename/delete;
- site save/delete/enable/disable/rename/duplicate;
- stream save/delete/enable/disable/rename/duplicate;
- settings save and protected secret reads;
- backup restore.
However, several similarly sensitive authenticated mutation routes remain outside RequireSecureSession(). An attacker who obtains an authenticated JWT/session for a 2FA-enabled user, but does not have the victim's fresh OTP/passkey secure-session token, can still perform those operations.
The affected route groups include:
- automatic backup create/modify/delete/restore/test-S3 operations;
- DNS credential create/edit/delete operations;
- certificate and ACME user create/modify/delete/register operations;
- DNS domain, record, and DDNS create/update/delete operations;
- Nginx reload/restart, stub-status toggle, performance-setting update, and module-cache refresh operations;
- site/stream advanced-mode toggles and batch updates;
- auto-certificate binding/unbinding for sites;
- site navigation order and health-check updates.
This report is distinct from my earlier Nginx UI report about auto-backup path traversal. This report is about missing 2FA step-up authorization on sensitive authenticated mutation routes.
Why this is a security boundary
internal/middleware/secure_session.go only requires a secure-session token for users with OTP enabled. For OTP users, it reads X-Secure-Session-ID from the header or query string and aborts with 401 Unauthorized if the secure session is missing or invalid:
if !cUser.EnabledOTP() {
c.Next()
return
}
ssid := c.GetHeader("X-Secure-Session-ID")
if ssid == "" {
ssid = c.Query("X-Secure-Session-ID")
}
if ssid == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"message": "Secure Session ID is empty",
})
return
}
if user.VerifySecureSessionID(ssid, cUser.ID) {
c.Next()
return
}
internal/user/otp.go issues the secure-session ID for only 10 minutes:
func SetSecureSessionID(userId uint64) (sessionId string) {
sessionId = uuid.NewString()
cache.Set(secureSessionIDCacheKey(sessionId), userId, 10*time.Minute)
return
}
The UI/server already rely on this boundary for sensitive actions. For example, api/config/router.go wraps config mutations with RequireSecureSession():
o := r.Group("", middleware.RequireSecureSession())
{
o.POST("configs", AddConfig)
o.POST("config", EditConfig)
o.POST("config_mkdir", Mkdir)
o.POST("config_rename", Rename)
o.POST("config_delete", DeleteConfig)
}
api/sites/router.go protects normal site mutations:
o := r.Group("", middleware.RequireSecureSession())
{
o.POST("sites/:name/rename", RenameSite)
o.POST("sites/:name/enable", EnableSite)
o.POST("sites/:name/disable", DisableSite)
o.POST("sites/:name", SaveSite)
o.DELETE("sites/:name", DeleteSite)
o.POST("sites/:name/duplicate", DuplicateSite)
o.POST("sites/:name/maintenance", EnableMaintenanceSite)
}
api/streams/router.go protects normal stream mutations, and api/settings/router.go protects secret reads/settings saves:
o := r.Group("", middleware.RequireSecureSession())
{
o.POST("streams/:name", SaveStream)
o.POST("streams/:name/rename", RenameStream)
o.POST("streams/:name/enable", EnableStream)
o.POST("streams/:name/disable", DisableStream)
o.DELETE("streams/:name", DeleteStream)
o.POST("streams/:name/duplicate", Duplicate)
}
r.GET("settings/protected", middleware.RequireSecureSession(), GetProtectedSetting)
r.POST("settings", middleware.RequireSecureSession(), SaveSettings)
The repository's own tests also verify this expected behavior. api/config/security_test.go, api/sites/security_test.go, and api/streams/security_test.go each assert that OTP-enabled users receive 401 Unauthorized when protected mutations are attempted without a secure session.
Affected routes outside the step-up boundary
The affected routes are under the authenticated private router:
g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
but they do not add middleware.RequireSecureSession().
Automatic backup management
api/backup/router.go protects manual restore:
r.POST("/restore", middleware.AuthRequired(), middleware.RequireSecureSession(), middleware.EncryptedForm(), RestoreBackup)
but the automatic backup management router has no secure-session middleware:
r.POST("/auto_backup", CreateAutoBackup)
r.POST("/auto_backup/:id", ModifyAutoBackup)
r.DELETE("/auto_backup/:id", DestroyAutoBackup)
r.PATCH("/auto_backup/:id", RestoreAutoBackup)
r.POST("/auto_backup/test_s3", TestS3Connection)
Those handlers create or modify backup jobs, register cron jobs, test attacker-supplied S3 settings, and store S3 credentials:
"s3_access_key_id": "omitempty",
"s3_secret_access_key": "omitempty",
...
if ctx.Model.Enabled {
if err := cron.AddAutoBackupJob(ctx.Model.ID, ctx.Model.CronExpression); err != nil {
The model stores S3 secret material:
S3AccessKeyID string `json:"s3_access_key_id" gorm:"comment:S3 access key ID;serializer:json[aes]"`
S3SecretAccessKey string `json:"s3_secret_access_key" gorm:"comment:S3 secret access key;serializer:json[aes]"`
Certificate, ACME, and DNS credential management
api/certificate/router.go registers credential and certificate mutations without RequireSecureSession():
r.POST("dns_credentials", AddDnsCredential)
r.POST("dns_credentials/:id", EditDnsCredential)
r.DELETE("dns_credentials/:id", DeleteDnsCredential)
r.POST("certs", AddCert)
r.POST("certs/:id", ModifyCert)
r.DELETE("certs/:id", RemoveCert)
r.PUT("cert_sync", SyncCertificate)
r.POST("acme_users", CreateAcmeUser)
r.POST("acme_users/:id", ModifyAcmeUser)
r.POST("acme_users/:id/register", RegisterAcmeUser)
r.DELETE("acme_users/:id", DestroyAcmeUser)
The DNS credential handlers persist provider configuration:
dnsCredential := model.DnsCredential{
Name: json.Name,
Config: &json.Config,
Provider: json.Provider,
ProviderCode: providerCode,
}
...
err := d.Create(&dnsCredential)
The model serializes that configuration with AES:
Config *dns.Config `json:"config,omitempty" gorm:"serializer:json[aes]"`
DNS domains, records, and DDNS
api/dns/router.go registers DNS domain, record, and DDNS mutations without secure-session middleware:
group.POST("/domains", CreateDomain)
group.POST("/domains/:id", UpdateDomain)
group.DELETE("/domains/:id", DeleteDomain)
group.POST("/domains/:id/records", CreateRecord)
group.PUT("/domains/:id/records/:record_id", UpdateRecord)
group.DELETE("/domains/:id/records/:record_id", DeleteRecord)
group.PUT("/domains/:id/ddns", UpdateDDNSConfig)
group.DELETE("/domains/:id/ddns", DeleteDDNSConfig)
Those handlers call the DNS provider service and modify DDNS jobs:
record, err := svc.CreateRecord(c.Request.Context(), domainID, toRecordInput(payload))
...
record, err := svc.UpdateRecord(c.Request.Context(), domainID, recordID, toRecordInput(payload))
...
if err := cron.AddOrUpdateDDNSJob(domainID, cfg.IntervalSeconds); err != nil {
Nginx control and configuration-side effects
api/nginx/router.go registers several server/Nginx mutation endpoints without RequireSecureSession():
r.POST("nginx/reload", Reload)
r.POST("nginx/restart", Restart)
r.POST("nginx/stub_status", ToggleStubStatus)
r.POST("nginx/performance", UpdatePerformanceSettings)
r.POST("nginx/modules/refresh", RefreshModulesCache)
The handlers perform control actions or persist Nginx-related changes:
func Reload(c *gin.Context) {
nginx.Control(nginx.Reload).Resp(c)
}
func Restart(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "ok",
})
go nginx.Restart()
}
if json.Enable {
err = performance.EnableStubStatus()
} else {
err = performance.DisableStubStatus()
}
...
reloadOutput, err := nginx.Reload()
err := performance.UpdatePerfOpt(&perfOpt)
Site and stream advanced/batch mutations
api/sites/router.go leaves these site mutations before the secure-session group:
r.PUT("sites", BatchUpdateSites)
r.POST("sites/:name/advance", DomainEditByAdvancedMode)
r.POST("auto_cert/:name", AddDomainToAutoCert)
r.DELETE("auto_cert/:name", RemoveDomainFromAutoCert)
r.POST("site_navigation/order", UpdateSiteOrder)
r.POST("site_navigation/health_check/:id", UpdateHealthCheck)
DomainEditByAdvancedMode() persists the site's Advanced mode:
_, err = s.Where(s.Path.Eq(path)).Update(s.Advanced, json.Advanced)
AddDomainToAutoCert() writes certificate automation settings including DNS credential ID, domains, challenge method, and ACME user:
err = certModel.Updates(&model.Cert{
Name: name,
Domains: json.Domains,
AutoCert: model.AutoCertEnabled,
DnsCredentialID: json.DnsCredentialID,
ChallengeMethod: json.ChallengeMethod,
KeyType: helper.GetKeyType(json.KeyType),
ACMEUserID: json.ACMEUserID,
})
api/streams/router.go protects normal stream save/delete operations, but leaves batch update and advanced-mode outside:
r.PUT("streams", BatchUpdateStreams)
...
r.POST("streams/:name/advance", AdvancedEdit)
AdvancedEdit() persists the stream's Advanced mode:
_, err = s.Where(s.Path.Eq(path)).Update(s.Advanced, json.Advanced)
Safe local proof
I did not access any external Nginx UI deployment. The following proof only inspects the checked-out source tree and confirms that:
RequireSecureSession()is a real OTP-gated 10-minute step-up mechanism;- high-risk control routes are intentionally protected elsewhere;
- the affected route groups are authenticated but outside
RequireSecureSession(); - the affected handlers perform persistent or server-control side effects.
Run:
python3 reports/nginx-ui-missing-secure-session-step-up-poc.py
Observed output:
{
"all_affected_groups_present": true,
"all_controls_present": true,
"all_side_effects_present": true,
"commit": "4948fba8744e82c3d7003f68ee164e9f49421066",
"result": "VULNERABLE_SOURCE_CONFIRMED",
"target": "0xJacky/nginx-ui"
}
The full local proof output is available in my workspace as tmp/nginx-ui-missing-secure-session-step-up-poc-output.txt.
HTTP reproduction outline
This is a local-only reproduction outline for an isolated Nginx UI test instance, consistent with the project's security policy:
- Create a test user with OTP enabled.
- Log in as that user and keep only the normal authenticated session/JWT.
- Do not obtain or send a fresh
X-Secure-Session-ID. - Verify the expected protected control behavior:
POST /api/config
Authorization: <otp-user-session>
Content-Type: application/json
{ "name": "example.conf", "content": "server { listen 80; }" }
Expected result: 401 Unauthorized because config edits require RequireSecureSession().
- Attempt an affected operation with the same OTP-enabled session and no
X-Secure-Session-ID, for example:
POST /api/dns_credentials
Authorization: <otp-user-session>
Content-Type: application/json
{
"name": "attacker-controlled-test",
"provider": "cloudflare",
"provider_code": "cloudflare",
"token": "test-token"
}
Expected secure behavior: 401 Unauthorized, matching the secure-session requirement for other sensitive configuration changes.
Actual behavior from source review: the request reaches AddDnsCredential() because the DNS credential router is under AuthRequired() but does not apply RequireSecureSession().
The same missing step-up applies to the other route groups listed above.
Impact
If an attacker obtains the authenticated web session/JWT for a 2FA-enabled Nginx UI user, the secure-session boundary should limit what they can do until they also pass a fresh OTP/passkey challenge. The current route map leaves major administrative actions outside that boundary.
Depending on the victim user's privileges and deployment configuration, this can allow the attacker to:
- add, edit, or remove DNS provider credentials;
- alter certificate and ACME automation;
- create, modify, delete, or restore automatic backup jobs;
- configure S3 backup destinations and trigger S3 connection tests;
- alter DNS domains, records, or DDNS schedules through configured providers;
- restart or reload Nginx;
- enable/disable stub-status and change performance settings;
- switch sites/streams into advanced mode or change site automation bindings.
This undermines the value of the secure-session step-up for OTP-enabled accounts. I am not claiming pre-authentication access or remote code execution from this issue. The issue is missing step-up authorization for sensitive authenticated mutations.
Suggested fix
Recommended mitigation:
- Apply
middleware.RequireSecureSession()to all high-impact authenticated mutation routes that alter server, Nginx, certificate, DNS, backup, credential, or automation state. - At minimum, protect:
- auto-backup create/modify/delete/restore/test-S3;
- DNS credential create/edit/delete;
- certificate and ACME user mutations;
- DNS domain, record, and DDNS mutations;
- Nginx reload/restart/stub-status/performance/module-cache mutation routes;
- site/stream advanced-mode, batch, auto-cert, navigation order, and health-check mutations.
- Add regression tests mirroring the existing config/site/stream security tests:
- OTP-enabled user without
X-Secure-Session-IDreceives401; - OTP-enabled user with a valid secure-session ID succeeds;
- non-OTP users retain existing behavior if that is the intended product policy.
- Consider centralizing "sensitive mutation" routing to make future omissions harder.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/0xJacky/Nginx-UI"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.9.10-0.20260703131214-23d1934099b0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-84315"
],
"database_specific": {
"cwe_ids": [
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-15T20:12:42Z",
"nvd_published_at": "2026-10-12T21:17:21Z",
"severity": "HIGH"
},
"details": "Reporter: Jagadesh Achanta\n\nTarget: `0xJacky/nginx-ui`\n\nAffected version / commit tested:\n- App version: `2.3.10`\n- Commit: `4948fba8744e82c3d7003f68ee164e9f49421066`\n- Disclosure channel: GitHub Private Vulnerability Reporting\n- Credential reference: `cred:github-jagstack-gh-cli`\n\n## AI assistance disclosure\n\nThis report was prepared by Jagadesh Achanta with assistance from OpenAI/Codex for public source review, local evidence collection, and report drafting.\n\n## Summary\n\nNginx UI has a short-lived OTP/passkey \"secure session\" step-up boundary for users with 2FA enabled. `RequireSecureSession()` rejects an OTP-enabled user\u0027s request unless the request includes a valid `X-Secure-Session-ID` issued after a fresh OTP/passkey verification.\n\nThe project already applies this step-up middleware to sensitive operations such as:\n\n- Nginx config create/edit/rename/delete;\n- site save/delete/enable/disable/rename/duplicate;\n- stream save/delete/enable/disable/rename/duplicate;\n- settings save and protected secret reads;\n- backup restore.\n\nHowever, several similarly sensitive authenticated mutation routes remain outside `RequireSecureSession()`. An attacker who obtains an authenticated JWT/session for a 2FA-enabled user, but does not have the victim\u0027s fresh OTP/passkey secure-session token, can still perform those operations.\n\nThe affected route groups include:\n\n- automatic backup create/modify/delete/restore/test-S3 operations;\n- DNS credential create/edit/delete operations;\n- certificate and ACME user create/modify/delete/register operations;\n- DNS domain, record, and DDNS create/update/delete operations;\n- Nginx reload/restart, stub-status toggle, performance-setting update, and module-cache refresh operations;\n- site/stream advanced-mode toggles and batch updates;\n- auto-certificate binding/unbinding for sites;\n- site navigation order and health-check updates.\n\nThis report is distinct from my earlier Nginx UI report about auto-backup path traversal. This report is about missing 2FA step-up authorization on sensitive authenticated mutation routes.\n\n## Why this is a security boundary\n\n`internal/middleware/secure_session.go` only requires a secure-session token for users with OTP enabled. For OTP users, it reads `X-Secure-Session-ID` from the header or query string and aborts with `401 Unauthorized` if the secure session is missing or invalid:\n\n```go\nif !cUser.EnabledOTP() {\n c.Next()\n return\n}\nssid := c.GetHeader(\"X-Secure-Session-ID\")\nif ssid == \"\" {\n ssid = c.Query(\"X-Secure-Session-ID\")\n}\nif ssid == \"\" {\n c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\n \"message\": \"Secure Session ID is empty\",\n })\n return\n}\n\nif user.VerifySecureSessionID(ssid, cUser.ID) {\n c.Next()\n return\n}\n```\n\n`internal/user/otp.go` issues the secure-session ID for only 10 minutes:\n\n```go\nfunc SetSecureSessionID(userId uint64) (sessionId string) {\n sessionId = uuid.NewString()\n cache.Set(secureSessionIDCacheKey(sessionId), userId, 10*time.Minute)\n return\n}\n```\n\nThe UI/server already rely on this boundary for sensitive actions. For example, `api/config/router.go` wraps config mutations with `RequireSecureSession()`:\n\n```go\no := r.Group(\"\", middleware.RequireSecureSession())\n{\n o.POST(\"configs\", AddConfig)\n o.POST(\"config\", EditConfig)\n o.POST(\"config_mkdir\", Mkdir)\n o.POST(\"config_rename\", Rename)\n o.POST(\"config_delete\", DeleteConfig)\n}\n```\n\n`api/sites/router.go` protects normal site mutations:\n\n```go\no := r.Group(\"\", middleware.RequireSecureSession())\n{\n o.POST(\"sites/:name/rename\", RenameSite)\n o.POST(\"sites/:name/enable\", EnableSite)\n o.POST(\"sites/:name/disable\", DisableSite)\n o.POST(\"sites/:name\", SaveSite)\n o.DELETE(\"sites/:name\", DeleteSite)\n o.POST(\"sites/:name/duplicate\", DuplicateSite)\n o.POST(\"sites/:name/maintenance\", EnableMaintenanceSite)\n}\n```\n\n`api/streams/router.go` protects normal stream mutations, and `api/settings/router.go` protects secret reads/settings saves:\n\n```go\no := r.Group(\"\", middleware.RequireSecureSession())\n{\n o.POST(\"streams/:name\", SaveStream)\n o.POST(\"streams/:name/rename\", RenameStream)\n o.POST(\"streams/:name/enable\", EnableStream)\n o.POST(\"streams/:name/disable\", DisableStream)\n o.DELETE(\"streams/:name\", DeleteStream)\n o.POST(\"streams/:name/duplicate\", Duplicate)\n}\n```\n\n```go\nr.GET(\"settings/protected\", middleware.RequireSecureSession(), GetProtectedSetting)\nr.POST(\"settings\", middleware.RequireSecureSession(), SaveSettings)\n```\n\nThe repository\u0027s own tests also verify this expected behavior. `api/config/security_test.go`, `api/sites/security_test.go`, and `api/streams/security_test.go` each assert that OTP-enabled users receive `401 Unauthorized` when protected mutations are attempted without a secure session.\n\n## Affected routes outside the step-up boundary\n\nThe affected routes are under the authenticated private router:\n\n```go\ng := root.Group(\"/\", middleware.AuthRequired(), middleware.Proxy())\n```\n\nbut they do not add `middleware.RequireSecureSession()`.\n\n### Automatic backup management\n\n`api/backup/router.go` protects manual restore:\n\n```go\nr.POST(\"/restore\", middleware.AuthRequired(), middleware.RequireSecureSession(), middleware.EncryptedForm(), RestoreBackup)\n```\n\nbut the automatic backup management router has no secure-session middleware:\n\n```go\nr.POST(\"/auto_backup\", CreateAutoBackup)\nr.POST(\"/auto_backup/:id\", ModifyAutoBackup)\nr.DELETE(\"/auto_backup/:id\", DestroyAutoBackup)\nr.PATCH(\"/auto_backup/:id\", RestoreAutoBackup)\nr.POST(\"/auto_backup/test_s3\", TestS3Connection)\n```\n\nThose handlers create or modify backup jobs, register cron jobs, test attacker-supplied S3 settings, and store S3 credentials:\n\n```go\n\"s3_access_key_id\": \"omitempty\",\n\"s3_secret_access_key\": \"omitempty\",\n...\nif ctx.Model.Enabled {\n if err := cron.AddAutoBackupJob(ctx.Model.ID, ctx.Model.CronExpression); err != nil {\n```\n\nThe model stores S3 secret material:\n\n```go\nS3AccessKeyID string `json:\"s3_access_key_id\" gorm:\"comment:S3 access key ID;serializer:json[aes]\"`\nS3SecretAccessKey string `json:\"s3_secret_access_key\" gorm:\"comment:S3 secret access key;serializer:json[aes]\"`\n```\n\n### Certificate, ACME, and DNS credential management\n\n`api/certificate/router.go` registers credential and certificate mutations without `RequireSecureSession()`:\n\n```go\nr.POST(\"dns_credentials\", AddDnsCredential)\nr.POST(\"dns_credentials/:id\", EditDnsCredential)\nr.DELETE(\"dns_credentials/:id\", DeleteDnsCredential)\n\nr.POST(\"certs\", AddCert)\nr.POST(\"certs/:id\", ModifyCert)\nr.DELETE(\"certs/:id\", RemoveCert)\nr.PUT(\"cert_sync\", SyncCertificate)\n\nr.POST(\"acme_users\", CreateAcmeUser)\nr.POST(\"acme_users/:id\", ModifyAcmeUser)\nr.POST(\"acme_users/:id/register\", RegisterAcmeUser)\nr.DELETE(\"acme_users/:id\", DestroyAcmeUser)\n```\n\nThe DNS credential handlers persist provider configuration:\n\n```go\ndnsCredential := model.DnsCredential{\n Name: json.Name,\n Config: \u0026json.Config,\n Provider: json.Provider,\n ProviderCode: providerCode,\n}\n...\nerr := d.Create(\u0026dnsCredential)\n```\n\nThe model serializes that configuration with AES:\n\n```go\nConfig *dns.Config `json:\"config,omitempty\" gorm:\"serializer:json[aes]\"`\n```\n\n### DNS domains, records, and DDNS\n\n`api/dns/router.go` registers DNS domain, record, and DDNS mutations without secure-session middleware:\n\n```go\ngroup.POST(\"/domains\", CreateDomain)\ngroup.POST(\"/domains/:id\", UpdateDomain)\ngroup.DELETE(\"/domains/:id\", DeleteDomain)\ngroup.POST(\"/domains/:id/records\", CreateRecord)\ngroup.PUT(\"/domains/:id/records/:record_id\", UpdateRecord)\ngroup.DELETE(\"/domains/:id/records/:record_id\", DeleteRecord)\ngroup.PUT(\"/domains/:id/ddns\", UpdateDDNSConfig)\ngroup.DELETE(\"/domains/:id/ddns\", DeleteDDNSConfig)\n```\n\nThose handlers call the DNS provider service and modify DDNS jobs:\n\n```go\nrecord, err := svc.CreateRecord(c.Request.Context(), domainID, toRecordInput(payload))\n...\nrecord, err := svc.UpdateRecord(c.Request.Context(), domainID, recordID, toRecordInput(payload))\n...\nif err := cron.AddOrUpdateDDNSJob(domainID, cfg.IntervalSeconds); err != nil {\n```\n\n### Nginx control and configuration-side effects\n\n`api/nginx/router.go` registers several server/Nginx mutation endpoints without `RequireSecureSession()`:\n\n```go\nr.POST(\"nginx/reload\", Reload)\nr.POST(\"nginx/restart\", Restart)\nr.POST(\"nginx/stub_status\", ToggleStubStatus)\nr.POST(\"nginx/performance\", UpdatePerformanceSettings)\nr.POST(\"nginx/modules/refresh\", RefreshModulesCache)\n```\n\nThe handlers perform control actions or persist Nginx-related changes:\n\n```go\nfunc Reload(c *gin.Context) {\n nginx.Control(nginx.Reload).Resp(c)\n}\n\nfunc Restart(c *gin.Context) {\n c.JSON(http.StatusOK, gin.H{\n \"message\": \"ok\",\n })\n go nginx.Restart()\n}\n```\n\n```go\nif json.Enable {\n err = performance.EnableStubStatus()\n} else {\n err = performance.DisableStubStatus()\n}\n...\nreloadOutput, err := nginx.Reload()\n```\n\n```go\nerr := performance.UpdatePerfOpt(\u0026perfOpt)\n```\n\n### Site and stream advanced/batch mutations\n\n`api/sites/router.go` leaves these site mutations before the secure-session group:\n\n```go\nr.PUT(\"sites\", BatchUpdateSites)\nr.POST(\"sites/:name/advance\", DomainEditByAdvancedMode)\nr.POST(\"auto_cert/:name\", AddDomainToAutoCert)\nr.DELETE(\"auto_cert/:name\", RemoveDomainFromAutoCert)\nr.POST(\"site_navigation/order\", UpdateSiteOrder)\nr.POST(\"site_navigation/health_check/:id\", UpdateHealthCheck)\n```\n\n`DomainEditByAdvancedMode()` persists the site\u0027s `Advanced` mode:\n\n```go\n_, err = s.Where(s.Path.Eq(path)).Update(s.Advanced, json.Advanced)\n```\n\n`AddDomainToAutoCert()` writes certificate automation settings including DNS credential ID, domains, challenge method, and ACME user:\n\n```go\nerr = certModel.Updates(\u0026model.Cert{\n Name: name,\n Domains: json.Domains,\n AutoCert: model.AutoCertEnabled,\n DnsCredentialID: json.DnsCredentialID,\n ChallengeMethod: json.ChallengeMethod,\n KeyType: helper.GetKeyType(json.KeyType),\n ACMEUserID: json.ACMEUserID,\n})\n```\n\n`api/streams/router.go` protects normal stream save/delete operations, but leaves batch update and advanced-mode outside:\n\n```go\nr.PUT(\"streams\", BatchUpdateStreams)\n...\nr.POST(\"streams/:name/advance\", AdvancedEdit)\n```\n\n`AdvancedEdit()` persists the stream\u0027s `Advanced` mode:\n\n```go\n_, err = s.Where(s.Path.Eq(path)).Update(s.Advanced, json.Advanced)\n```\n\n## Safe local proof\n\nI did not access any external Nginx UI deployment. The following proof only inspects the checked-out source tree and confirms that:\n\n- `RequireSecureSession()` is a real OTP-gated 10-minute step-up mechanism;\n- high-risk control routes are intentionally protected elsewhere;\n- the affected route groups are authenticated but outside `RequireSecureSession()`;\n- the affected handlers perform persistent or server-control side effects.\n\nRun:\n\n```bash\npython3 reports/nginx-ui-missing-secure-session-step-up-poc.py\n```\n\nObserved output:\n\n```json\n{\n \"all_affected_groups_present\": true,\n \"all_controls_present\": true,\n \"all_side_effects_present\": true,\n \"commit\": \"4948fba8744e82c3d7003f68ee164e9f49421066\",\n \"result\": \"VULNERABLE_SOURCE_CONFIRMED\",\n \"target\": \"0xJacky/nginx-ui\"\n}\n```\n\nThe full local proof output is available in my workspace as `tmp/nginx-ui-missing-secure-session-step-up-poc-output.txt`.\n\n## HTTP reproduction outline\n\nThis is a local-only reproduction outline for an isolated Nginx UI test instance, consistent with the project\u0027s security policy:\n\n1. Create a test user with OTP enabled.\n2. Log in as that user and keep only the normal authenticated session/JWT.\n3. Do not obtain or send a fresh `X-Secure-Session-ID`.\n4. Verify the expected protected control behavior:\n\n```http\nPOST /api/config\nAuthorization: \u003cotp-user-session\u003e\nContent-Type: application/json\n\n{ \"name\": \"example.conf\", \"content\": \"server { listen 80; }\" }\n```\n\nExpected result: `401 Unauthorized` because config edits require `RequireSecureSession()`.\n\n5. Attempt an affected operation with the same OTP-enabled session and no `X-Secure-Session-ID`, for example:\n\n```http\nPOST /api/dns_credentials\nAuthorization: \u003cotp-user-session\u003e\nContent-Type: application/json\n\n{\n \"name\": \"attacker-controlled-test\",\n \"provider\": \"cloudflare\",\n \"provider_code\": \"cloudflare\",\n \"token\": \"test-token\"\n}\n```\n\nExpected secure behavior: `401 Unauthorized`, matching the secure-session requirement for other sensitive configuration changes.\n\nActual behavior from source review: the request reaches `AddDnsCredential()` because the DNS credential router is under `AuthRequired()` but does not apply `RequireSecureSession()`.\n\nThe same missing step-up applies to the other route groups listed above.\n\n## Impact\n\nIf an attacker obtains the authenticated web session/JWT for a 2FA-enabled Nginx UI user, the secure-session boundary should limit what they can do until they also pass a fresh OTP/passkey challenge. The current route map leaves major administrative actions outside that boundary.\n\nDepending on the victim user\u0027s privileges and deployment configuration, this can allow the attacker to:\n\n- add, edit, or remove DNS provider credentials;\n- alter certificate and ACME automation;\n- create, modify, delete, or restore automatic backup jobs;\n- configure S3 backup destinations and trigger S3 connection tests;\n- alter DNS domains, records, or DDNS schedules through configured providers;\n- restart or reload Nginx;\n- enable/disable stub-status and change performance settings;\n- switch sites/streams into advanced mode or change site automation bindings.\n\nThis undermines the value of the secure-session step-up for OTP-enabled accounts. I am not claiming pre-authentication access or remote code execution from this issue. The issue is missing step-up authorization for sensitive authenticated mutations.\n\n## Suggested fix\n\nRecommended mitigation:\n\n1. Apply `middleware.RequireSecureSession()` to all high-impact authenticated mutation routes that alter server, Nginx, certificate, DNS, backup, credential, or automation state.\n2. At minimum, protect:\n - auto-backup create/modify/delete/restore/test-S3;\n - DNS credential create/edit/delete;\n - certificate and ACME user mutations;\n - DNS domain, record, and DDNS mutations;\n - Nginx reload/restart/stub-status/performance/module-cache mutation routes;\n - site/stream advanced-mode, batch, auto-cert, navigation order, and health-check mutations.\n3. Add regression tests mirroring the existing config/site/stream security tests:\n - OTP-enabled user without `X-Secure-Session-ID` receives `401`;\n - OTP-enabled user with a valid secure-session ID succeeds;\n - non-OTP users retain existing behavior if that is the intended product policy.\n4. Consider centralizing \"sensitive mutation\" routing to make future omissions harder.",
"id": "GHSA-5v7c-xpfp-p65m",
"modified": "2026-10-15T20:12:42Z",
"published": "2026-10-15T20:12:42Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-5v7c-xpfp-p65m"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-84315"
},
{
"type": "WEB",
"url": "https://github.com/0xJacky/nginx-ui/commit/23d1934099b05b2a01589b48129dc3d3159d91c2"
},
{
"type": "PACKAGE",
"url": "https://github.com/0xJacky/nginx-ui"
},
{
"type": "WEB",
"url": "https://github.com/0xJacky/nginx-ui/releases/tag/v2.4.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Nginx UI OTP secure-session step-up is missing on sensitive mutation routes"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
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.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
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.