GHSA-44XR-MRHH-QM78
Vulnerability from github – Published: 2026-10-15 20:12 – Updated: 2026-10-15 20:12Summary
In the Casdoor SSO flow, the OAuth state parameter is hard-coded to settings.CasdoorSettings.Application — a value identical for every user and every login. The callback handler accepts whatever state the client sends without comparing it to anything stored in the user agent, enabling a login-CSRF: an attacker can force the victim's browser to authenticate as the attacker.
Details
api/user/casdoor.go:92-117 (GetCasdoorUri):
func GetCasdoorUri(c *gin.Context) {
clientId := settings.CasdoorSettings.ClientId
redirectUri := settings.CasdoorSettings.RedirectUri
state := settings.CasdoorSettings.Application // ← constant
...
c.JSON(http.StatusOK, gin.H{
"uri": fmt.Sprintf(
"%s/login/oauth/authorize?client_id=%s&response_type=code&redirect_uri=%s&state=%s&scope=read",
endpoint, clientId, encodedRedirectUri, state),
})
}
api/user/casdoor.go:24-90 (CasdoorCallback):
type CasdoorLoginUser struct {
Code string `json:"code" binding:"required,max=255"`
State string `json:"state" binding:"required,max=255"`
}
func CasdoorCallback(c *gin.Context) {
var loginUser CasdoorLoginUser
if !cosy.BindAndValid(c, &loginUser) { return }
...
// state is NEVER compared to a per-session value
token, err := casdoorsdk.GetOAuthToken(loginUser.Code, loginUser.State)
...
u, err := user.GetUser(claims.Name) // username from Casdoor's JWT
...
userToken, err := user.GenerateJWT(u) // JWT minted for whatever user came back
}
Compare with the OIDC implementation in api/user/oidc.go:35-48, which generates a random state, stores it in an oidc_state cookie, and validates it on the callback. Casdoor has no such check.
Proof of Concept
- Attacker initiates a Casdoor authorisation flow on their own browser, obtaining an authorisation
codefor their Casdoor account. - Attacker hosts a page that auto-submits the captured
codeto the victim's nginx-ui:
```html
document.getElementById('x').submit()
```
- Server exchanges the attacker's
code, looks up the username fromclaims.Namein the local user table, and mints a JWT for that row. The callback also sets the_nginx_ui_secure_sessioncookie viamiddleware.EnsureSecureSessionCookie(c)(casdoor.go:84) — meaning the victim is now silently authenticated as the attacker. - Victim, believing they are using their own account, configures sites / TLS keys; the attacker reads them.
Impact
Login CSRF — the victim is forced to authenticate as the attacker, leading to data exfiltration of anything the victim subsequently uploads or configures. Particularly dangerous when nginx-ui is used by an organisation where Casdoor maps internal identities — the attacker can attribute their changes to the victim.
Patches
Mirror the OIDC implementation:
+ import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/hex"
+ ...
+ )
func GetCasdoorUri(c *gin.Context) {
...
- state := settings.CasdoorSettings.Application
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ cosy.ErrHandler(c, err); return
+ }
+ state := hex.EncodeToString(b)
+ c.SetSameSite(http.SameSiteLaxMode)
+ c.SetCookie("casdoor_state", state, 600, "/", "",
+ cSettings.ServerSettings.EnableHTTPS, true)
...
}
func CasdoorCallback(c *gin.Context) {
+ expected, err := c.Cookie("casdoor_state")
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest,
+ gin.H{"message":"missing state cookie"}); return
+ }
var loginUser CasdoorLoginUser
if !cosy.BindAndValid(c, &loginUser) { return }
+ if subtle.ConstantTimeCompare([]byte(expected), []byte(loginUser.State)) != 1 {
+ c.AbortWithStatusJSON(http.StatusBadRequest,
+ gin.H{"message":"invalid state"}); return
+ }
...
}
Workarounds
Disable Casdoor SSO until patched. The OIDC integration in api/user/oidc.go is correctly implemented and can be used as a substitute.
References
api/user/casdoor.go:92-117api/user/casdoor.go:24-90api/user/oidc.go:35-48(correct implementation for comparison)
Credits
Vishal Shukla (@shukla304) and Sechub.dev AI Agent.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c 1.9.10-0.20260703131214-23d1934099b0"
},
"package": {
"ecosystem": "Go",
"name": "Nginx-UI"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-84312"
],
"database_specific": {
"cwe_ids": [
"CWE-287",
"CWE-352"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-15T20:12:52Z",
"nvd_published_at": "2026-10-12T22:17:05Z",
"severity": "HIGH"
},
"details": "## Summary\n\nIn the Casdoor SSO flow, the OAuth `state` parameter is hard-coded to `settings.CasdoorSettings.Application` \u2014 a value identical for every user and every login. The callback handler accepts whatever `state` the client sends without comparing it to anything stored in the user agent, enabling a login-CSRF: an attacker can force the victim\u0027s browser to authenticate as the *attacker*.\n\n## Details\n\n`api/user/casdoor.go:92-117` (`GetCasdoorUri`):\n\n```go\nfunc GetCasdoorUri(c *gin.Context) {\n clientId := settings.CasdoorSettings.ClientId\n redirectUri := settings.CasdoorSettings.RedirectUri\n state := settings.CasdoorSettings.Application // \u2190 constant\n ...\n c.JSON(http.StatusOK, gin.H{\n \"uri\": fmt.Sprintf(\n \"%s/login/oauth/authorize?client_id=%s\u0026response_type=code\u0026redirect_uri=%s\u0026state=%s\u0026scope=read\",\n endpoint, clientId, encodedRedirectUri, state),\n })\n}\n```\n\n`api/user/casdoor.go:24-90` (`CasdoorCallback`):\n\n```go\ntype CasdoorLoginUser struct {\n Code string `json:\"code\" binding:\"required,max=255\"`\n State string `json:\"state\" binding:\"required,max=255\"`\n}\n\nfunc CasdoorCallback(c *gin.Context) {\n var loginUser CasdoorLoginUser\n if !cosy.BindAndValid(c, \u0026loginUser) { return }\n ...\n // state is NEVER compared to a per-session value\n token, err := casdoorsdk.GetOAuthToken(loginUser.Code, loginUser.State)\n ...\n u, err := user.GetUser(claims.Name) // username from Casdoor\u0027s JWT\n ...\n userToken, err := user.GenerateJWT(u) // JWT minted for whatever user came back\n}\n```\n\nCompare with the OIDC implementation in `api/user/oidc.go:35-48`, which generates a random state, stores it in an `oidc_state` cookie, and validates it on the callback. Casdoor has no such check.\n\n## Proof of Concept\n\n1. Attacker initiates a Casdoor authorisation flow on their own browser, obtaining an authorisation `code` for *their* Casdoor account.\n2. Attacker hosts a page that auto-submits the captured `code` to the victim\u0027s nginx-ui:\n\n ```html\n \u003chtml\u003e\u003cbody\u003e\u003cform action=\"https://victim.example/api/casdoor_callback\" method=\"POST\"\n enctype=\"text/plain\" id=\"x\"\u003e\n \u003cinput name=\u0027{\"code\":\"\u003cattacker_code\u003e\",\"state\":\"\u003cApp\u003e\",\"junk\":\"\u0027 value=\u0027\"}\u0027\u003e\n \u003c/form\u003e\n \u003cscript\u003edocument.getElementById(\u0027x\u0027).submit()\u003c/script\u003e\n \u003c/body\u003e\u003c/html\u003e\n ```\n\n3. Server exchanges the attacker\u0027s `code`, looks up the username from `claims.Name` in the local user table, and mints a JWT for *that* row. The callback also sets the `_nginx_ui_secure_session` cookie via `middleware.EnsureSecureSessionCookie(c)` (`casdoor.go:84`) \u2014 meaning the victim is now silently authenticated as the attacker.\n4. Victim, believing they are using their own account, configures sites / TLS keys; the attacker reads them.\n\n## Impact\n\nLogin CSRF \u2014 the victim is forced to authenticate as the attacker, leading to data exfiltration of anything the victim subsequently uploads or configures. Particularly dangerous when nginx-ui is used by an organisation where Casdoor maps internal identities \u2014 the attacker can attribute their changes to the victim.\n\n## Patches\n\nMirror the OIDC implementation:\n\n```diff\n+ import (\n+ \"crypto/rand\"\n+ \"crypto/subtle\"\n+ \"encoding/hex\"\n+ ...\n+ )\n\n func GetCasdoorUri(c *gin.Context) {\n ...\n- state := settings.CasdoorSettings.Application\n+ b := make([]byte, 16)\n+ if _, err := rand.Read(b); err != nil {\n+ cosy.ErrHandler(c, err); return\n+ }\n+ state := hex.EncodeToString(b)\n+ c.SetSameSite(http.SameSiteLaxMode)\n+ c.SetCookie(\"casdoor_state\", state, 600, \"/\", \"\",\n+ cSettings.ServerSettings.EnableHTTPS, true)\n ...\n }\n\n func CasdoorCallback(c *gin.Context) {\n+ expected, err := c.Cookie(\"casdoor_state\")\n+ if err != nil {\n+ c.AbortWithStatusJSON(http.StatusBadRequest,\n+ gin.H{\"message\":\"missing state cookie\"}); return\n+ }\n var loginUser CasdoorLoginUser\n if !cosy.BindAndValid(c, \u0026loginUser) { return }\n+ if subtle.ConstantTimeCompare([]byte(expected), []byte(loginUser.State)) != 1 {\n+ c.AbortWithStatusJSON(http.StatusBadRequest,\n+ gin.H{\"message\":\"invalid state\"}); return\n+ }\n ...\n }\n```\n\n## Workarounds\n\nDisable Casdoor SSO until patched. The OIDC integration in `api/user/oidc.go` is correctly implemented and can be used as a substitute.\n\n## References\n\n* [`api/user/casdoor.go:92-117`](https://github.com/0xJacky/nginx-ui/blob/dev/api/user/casdoor.go#L92-L117)\n* [`api/user/casdoor.go:24-90`](https://github.com/0xJacky/nginx-ui/blob/dev/api/user/casdoor.go#L24-L90)\n* [`api/user/oidc.go:35-48`](https://github.com/0xJacky/nginx-ui/blob/dev/api/user/oidc.go#L35-L48) (correct implementation for comparison)\n\n## Credits\n\nVishal Shukla ([@shukla304](https://github.com/shukla304)) and Sechub.dev AI Agent.",
"id": "GHSA-44xr-mrhh-qm78",
"modified": "2026-10-15T20:12:52Z",
"published": "2026-10-15T20:12:52Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-44xr-mrhh-qm78"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-84312"
},
{
"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:N/UI:R/S:U/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Nginx UI: Casdoor SSO callback uses a constant `state` parameter (login CSRF)"
}
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.