Nginx-UI arbitrary file write through the Import Certificate feature
Description
Nginx-UI is a web interface to manage Nginx configurations. The Import Certificate feature allows arbitrary write into the system. The feature does not check if the provided user input is a certification/key and allows to write into arbitrary paths in the system. It's possible to leverage the vulnerability into a remote code execution overwriting the config file app.ini. Version 2.0.0.beta.12 fixed the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
The Import Certificate feature in Nginx-UI before 2.0.0.beta.12 allows arbitrary file write, enabling remote code execution via overwriting app.ini.
Vulnerability
Overview
The Import Certificate feature in Nginx-UI (a web interface for managing Nginx configurations) contains an arbitrary file write vulnerability. The feature does not validate whether the provided user input is actually a certificate or key, and it writes the input content to user-specified file paths without restrictions [1][3][4]. This allows an attacker to write arbitrary data to any location on the filesystem.
Exploitation
The vulnerability is triggered through the AddCert API endpoint, which accepts ssl_certificate_path and ssl_certificate_key_path parameters to define the destination paths, and ssl_certificate and ssl_certificate_key fields for the content to write. The WriteFile method then writes the content directly to the specified paths [4]. No authentication is required to access this endpoint, and the attacker can specify any path, such as /etc/nginx-ui/app.ini [3][4].
Impact
By overwriting the app.ini configuration file, an attacker can inject malicious settings that lead to remote code execution (RCE) when the application re-reads its configuration. This can result in full system compromise [3][4].
Mitigation
Version 2.0.0.beta.12 fixes the issue by validating that the provided content is a legitimate certificate before saving it to disk [2]. Users are strongly advised to upgrade to this version or later. As a temporary workaround, restrict access to the import certificate API to trusted users only.
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/0xJacky/Nginx-UIGo | < 2.0.0-beta.12 | 2.0.0-beta.12 |
Affected products
2- 0xJacky/nginx-uiv5Range: < 2.0.0.beta.12
Patches
18581bdd3c6f4enhance: validate certificate content before save
7 files changed · +305 −168
api/api.go+11 −24 modified@@ -4,28 +4,12 @@ import ( "errors" "github.com/0xJacky/Nginx-UI/internal/logger" "github.com/gin-gonic/gin" - "github.com/gin-gonic/gin/binding" val "github.com/go-playground/validator/v10" "net/http" "reflect" - "regexp" "strings" ) -func init() { - if v, ok := binding.Validator.Engine().(*val.Validate); ok { - err := v.RegisterValidation("alphanumdash", func(fl val.FieldLevel) bool { - return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String()) - }) - - if err != nil { - logger.Fatal(err) - } - return - } - logger.Fatal("binding validator engine is not initialized") -} - func ErrHandler(c *gin.Context, err error) { logger.GetLogger().Errorln(err) c.JSON(http.StatusInternalServerError, gin.H{ @@ -54,11 +38,18 @@ func BindAndValid(c *gin.Context, target interface{}) bool { return false } - t := reflect.TypeOf(target).Elem() + t := reflect.TypeOf(target) errorsMap := make(map[string]interface{}) for _, value := range verrs { var path []string - getJsonPath(t, value.StructNamespace(), &path) + + namespace := strings.Split(value.StructNamespace(), ".") + + if t.Name() == "" && len(namespace) > 1 { + namespace = namespace[1:] + } + + getJsonPath(t.Elem(), namespace, &path) insertError(errorsMap, path, value.Tag()) } @@ -75,11 +66,7 @@ func BindAndValid(c *gin.Context, target interface{}) bool { } // findField recursively finds the field in a nested struct -func getJsonPath(t reflect.Type, namespace string, path *[]string) { - fields := strings.Split(namespace, ".") - if len(fields) == 0 { - return - } +func getJsonPath(t reflect.Type, fields []string, path *[]string) { f, ok := t.FieldByName(fields[0]) if !ok { return @@ -88,7 +75,7 @@ func getJsonPath(t reflect.Type, namespace string, path *[]string) { *path = append(*path, f.Tag.Get("json")) if len(fields) > 1 { - subFields := strings.Join(fields[1:], ".") + subFields := fields[1:] getJsonPath(f.Type, subFields, path) } }
api/certificate/certificate.go+144 −144 modified@@ -1,174 +1,174 @@ package certificate import ( - "github.com/0xJacky/Nginx-UI/api" - "github.com/0xJacky/Nginx-UI/api/cosy" - "github.com/0xJacky/Nginx-UI/internal/cert" - "github.com/0xJacky/Nginx-UI/model" - "github.com/0xJacky/Nginx-UI/query" - "github.com/gin-gonic/gin" - "github.com/spf13/cast" - "net/http" - "os" + "github.com/0xJacky/Nginx-UI/api" + "github.com/0xJacky/Nginx-UI/api/cosy" + "github.com/0xJacky/Nginx-UI/internal/cert" + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + "github.com/gin-gonic/gin" + "github.com/spf13/cast" + "net/http" + "os" ) type APICertificate struct { - *model.Cert - SSLCertificate string `json:"ssl_certificate,omitempty"` - SSLCertificateKey string `json:"ssl_certificate_key,omitempty"` - CertificateInfo *cert.Info `json:"certificate_info,omitempty"` + *model.Cert + SSLCertificate string `json:"ssl_certificate,omitempty"` + SSLCertificateKey string `json:"ssl_certificate_key,omitempty"` + CertificateInfo *cert.Info `json:"certificate_info,omitempty"` } func Transformer(certModel *model.Cert) (certificate *APICertificate) { - var sslCertificationBytes, sslCertificationKeyBytes []byte - var certificateInfo *cert.Info - if certModel.SSLCertificatePath != "" { - if _, err := os.Stat(certModel.SSLCertificatePath); err == nil { - sslCertificationBytes, _ = os.ReadFile(certModel.SSLCertificatePath) - } - - certificateInfo, _ = cert.GetCertInfo(certModel.SSLCertificatePath) - } - - if certModel.SSLCertificateKeyPath != "" { - if _, err := os.Stat(certModel.SSLCertificateKeyPath); err == nil { - sslCertificationKeyBytes, _ = os.ReadFile(certModel.SSLCertificateKeyPath) - } - } - - return &APICertificate{ - Cert: certModel, - SSLCertificate: string(sslCertificationBytes), - SSLCertificateKey: string(sslCertificationKeyBytes), - CertificateInfo: certificateInfo, - } + var sslCertificationBytes, sslCertificationKeyBytes []byte + var certificateInfo *cert.Info + if certModel.SSLCertificatePath != "" { + if _, err := os.Stat(certModel.SSLCertificatePath); err == nil { + sslCertificationBytes, _ = os.ReadFile(certModel.SSLCertificatePath) + if !cert.IsPublicKey(string(sslCertificationBytes)) { + sslCertificationBytes = []byte{} + } + } + + certificateInfo, _ = cert.GetCertInfo(certModel.SSLCertificatePath) + } + + if certModel.SSLCertificateKeyPath != "" { + if _, err := os.Stat(certModel.SSLCertificateKeyPath); err == nil { + sslCertificationKeyBytes, _ = os.ReadFile(certModel.SSLCertificateKeyPath) + if !cert.IsPrivateKey(string(sslCertificationKeyBytes)) { + sslCertificationKeyBytes = []byte{} + } + } + } + + return &APICertificate{ + Cert: certModel, + SSLCertificate: string(sslCertificationBytes), + SSLCertificateKey: string(sslCertificationKeyBytes), + CertificateInfo: certificateInfo, + } } func GetCertList(c *gin.Context) { - cosy.Core[model.Cert](c).SetFussy("name", "domain").SetTransformer(func(m *model.Cert) any { + cosy.Core[model.Cert](c).SetFussy("name", "domain").SetTransformer(func(m *model.Cert) any { - info, _ := cert.GetCertInfo(m.SSLCertificatePath) + info, _ := cert.GetCertInfo(m.SSLCertificatePath) - return APICertificate{ - Cert: m, - CertificateInfo: info, - } - }).PagingList() + return APICertificate{ + Cert: m, + CertificateInfo: info, + } + }).PagingList() } func GetCert(c *gin.Context) { - q := query.Cert + q := query.Cert - certModel, err := q.FirstByID(cast.ToInt(c.Param("id"))) + certModel, err := q.FirstByID(cast.ToInt(c.Param("id"))) - if err != nil { - api.ErrHandler(c, err) - return - } + if err != nil { + api.ErrHandler(c, err) + return + } - c.JSON(http.StatusOK, Transformer(certModel)) + c.JSON(http.StatusOK, Transformer(certModel)) +} + +type certJson struct { + Name string `json:"name"` + SSLCertificatePath string `json:"ssl_certificate_path" binding:"publickey_path"` + SSLCertificateKeyPath string `json:"ssl_certificate_key_path" binding:"privatekey_path"` + SSLCertificate string `json:"ssl_certificate" binding:"omitempty,publickey"` + SSLCertificateKey string `json:"ssl_certificate_key" binding:"omitempty,privatekey"` + ChallengeMethod string `json:"challenge_method"` + DnsCredentialID int `json:"dns_credential_id"` } func AddCert(c *gin.Context) { - var json struct { - Name string `json:"name"` - SSLCertificatePath string `json:"ssl_certificate_path" binding:"required"` - SSLCertificateKeyPath string `json:"ssl_certificate_key_path" binding:"required"` - SSLCertificate string `json:"ssl_certificate"` - SSLCertificateKey string `json:"ssl_certificate_key"` - ChallengeMethod string `json:"challenge_method"` - DnsCredentialID int `json:"dns_credential_id"` - } - if !api.BindAndValid(c, &json) { - return - } - certModel := &model.Cert{ - Name: json.Name, - SSLCertificatePath: json.SSLCertificatePath, - SSLCertificateKeyPath: json.SSLCertificateKeyPath, - ChallengeMethod: json.ChallengeMethod, - DnsCredentialID: json.DnsCredentialID, - } - - err := certModel.Insert() - - if err != nil { - api.ErrHandler(c, err) - return - } - - content := &cert.Content{ - SSLCertificatePath: json.SSLCertificatePath, - SSLCertificateKeyPath: json.SSLCertificateKeyPath, - SSLCertificate: json.SSLCertificate, - SSLCertificateKey: json.SSLCertificateKey, - } - - err = content.WriteFile() - - if err != nil { - api.ErrHandler(c, err) - return - } - - c.JSON(http.StatusOK, Transformer(certModel)) + var json certJson + if !api.BindAndValid(c, &json) { + return + } + certModel := &model.Cert{ + Name: json.Name, + SSLCertificatePath: json.SSLCertificatePath, + SSLCertificateKeyPath: json.SSLCertificateKeyPath, + ChallengeMethod: json.ChallengeMethod, + DnsCredentialID: json.DnsCredentialID, + } + + err := certModel.Insert() + + if err != nil { + api.ErrHandler(c, err) + return + } + + content := &cert.Content{ + SSLCertificatePath: json.SSLCertificatePath, + SSLCertificateKeyPath: json.SSLCertificateKeyPath, + SSLCertificate: json.SSLCertificate, + SSLCertificateKey: json.SSLCertificateKey, + } + + err = content.WriteFile() + + if err != nil { + api.ErrHandler(c, err) + return + } + + c.JSON(http.StatusOK, Transformer(certModel)) } func ModifyCert(c *gin.Context) { - id := cast.ToInt(c.Param("id")) - - var json struct { - Name string `json:"name"` - SSLCertificatePath string `json:"ssl_certificate_path" binding:"required"` - SSLCertificateKeyPath string `json:"ssl_certificate_key_path" binding:"required"` - SSLCertificate string `json:"ssl_certificate"` - SSLCertificateKey string `json:"ssl_certificate_key"` - ChallengeMethod string `json:"challenge_method"` - DnsCredentialID int `json:"dns_credential_id"` - } - - if !api.BindAndValid(c, &json) { - return - } - - q := query.Cert - - certModel, err := q.FirstByID(id) - if err != nil { - api.ErrHandler(c, err) - return - } - - err = certModel.Updates(&model.Cert{ - Name: json.Name, - SSLCertificatePath: json.SSLCertificatePath, - SSLCertificateKeyPath: json.SSLCertificateKeyPath, - ChallengeMethod: json.ChallengeMethod, - DnsCredentialID: json.DnsCredentialID, - }) - - if err != nil { - api.ErrHandler(c, err) - return - } - - content := &cert.Content{ - SSLCertificatePath: json.SSLCertificatePath, - SSLCertificateKeyPath: json.SSLCertificateKeyPath, - SSLCertificate: json.SSLCertificate, - SSLCertificateKey: json.SSLCertificateKey, - } - - err = content.WriteFile() - - if err != nil { - api.ErrHandler(c, err) - return - } - - GetCert(c) + id := cast.ToInt(c.Param("id")) + + var json certJson + + if !api.BindAndValid(c, &json) { + return + } + + q := query.Cert + + certModel, err := q.FirstByID(id) + if err != nil { + api.ErrHandler(c, err) + return + } + + err = certModel.Updates(&model.Cert{ + Name: json.Name, + SSLCertificatePath: json.SSLCertificatePath, + SSLCertificateKeyPath: json.SSLCertificateKeyPath, + ChallengeMethod: json.ChallengeMethod, + DnsCredentialID: json.DnsCredentialID, + }) + + if err != nil { + api.ErrHandler(c, err) + return + } + + content := &cert.Content{ + SSLCertificatePath: json.SSLCertificatePath, + SSLCertificateKeyPath: json.SSLCertificateKeyPath, + SSLCertificate: json.SSLCertificate, + SSLCertificateKey: json.SSLCertificateKey, + } + + err = content.WriteFile() + + if err != nil { + api.ErrHandler(c, err) + return + } + + GetCert(c) } func RemoveCert(c *gin.Context) { - cosy.Core[model.Cert](c).Destroy() + cosy.Core[model.Cert](c).Destroy() }
internal/cert/helper.go+70 −0 added@@ -0,0 +1,70 @@ +package cert + +import ( + "crypto/x509" + "encoding/pem" + "os" +) + +func IsPublicKey(pemStr string) bool { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return false + } + + _, err := x509.ParsePKIXPublicKey(block.Bytes) + return err == nil +} + +func IsPrivateKey(pemStr string) bool { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return false + } + + _, errRSA := x509.ParsePKCS1PrivateKey(block.Bytes) + if errRSA == nil { + return true + } + + _, errECDSA := x509.ParseECPrivateKey(block.Bytes) + return errECDSA == nil +} + +// IsPublicKeyPath checks if the file at the given path is a public key or not exists. +func IsPublicKeyPath(path string) bool { + _, err := os.Stat(path) + + if err != nil { + if os.IsNotExist(err) { + return true + } + return false + } + + bytes, err := os.ReadFile(path) + if err != nil { + return false + } + + return IsPublicKey(string(bytes)) +} + +// IsPrivateKeyPath checks if the file at the given path is a private key or not exists. +func IsPrivateKeyPath(path string) bool { + _, err := os.Stat(path) + + if err != nil { + if os.IsNotExist(err) { + return true + } + return false + } + + bytes, err := os.ReadFile(path) + if err != nil { + return false + } + + return IsPrivateKey(string(bytes)) +}
internal/kernal/boot.go+2 −0 modified@@ -4,6 +4,7 @@ import ( "github.com/0xJacky/Nginx-UI/internal/analytic" "github.com/0xJacky/Nginx-UI/internal/cert" "github.com/0xJacky/Nginx-UI/internal/logger" + "github.com/0xJacky/Nginx-UI/internal/validation" "github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/query" "github.com/0xJacky/Nginx-UI/settings" @@ -21,6 +22,7 @@ func Boot() { InitJsExtensionType, InitDatabase, InitNodeSecret, + validation.Init, } syncs := []func(){
internal/validation/alphanumdash.go+10 −0 added@@ -0,0 +1,10 @@ +package validation + +import ( + val "github.com/go-playground/validator/v10" + "regexp" +) + +func alphaNumDash(fl val.FieldLevel) bool { + return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String()) +}
internal/validation/certificate.go+22 −0 added@@ -0,0 +1,22 @@ +package validation + +import ( + "github.com/0xJacky/Nginx-UI/internal/cert" + val "github.com/go-playground/validator/v10" +) + +func isPublicKey(fl val.FieldLevel) bool { + return cert.IsPublicKey(fl.Field().String()) +} + +func isPrivateKey(fl val.FieldLevel) bool { + return cert.IsPrivateKey(fl.Field().String()) +} + +func isPublicKeyPath(fl val.FieldLevel) bool { + return cert.IsPublicKeyPath(fl.Field().String()) +} + +func isPrivateKeyPath(fl val.FieldLevel) bool { + return cert.IsPrivateKeyPath(fl.Field().String()) +}
internal/validation/validation.go+46 −0 added@@ -0,0 +1,46 @@ +package validation + +import ( + "github.com/0xJacky/Nginx-UI/internal/logger" + "github.com/gin-gonic/gin/binding" + val "github.com/go-playground/validator/v10" +) + +func Init() { + v, ok := binding.Validator.Engine().(*val.Validate) + if !ok { + logger.Fatal("binding validator engine is not initialized") + } + + err := v.RegisterValidation("alphanumdash", alphaNumDash) + + if err != nil { + logger.Fatal(err) + } + + err = v.RegisterValidation("publickey", isPublicKey) + + if err != nil { + logger.Fatal(err) + } + + err = v.RegisterValidation("privatekey", isPrivateKey) + + if err != nil { + logger.Fatal(err) + } + + err = v.RegisterValidation("publickey_path", isPublicKeyPath) + + if err != nil { + logger.Fatal(err) + } + + err = v.RegisterValidation("privatekey_path", isPrivateKeyPath) + + if err != nil { + logger.Fatal(err) + } + + return +}
Vulnerability mechanics
Root cause
"Missing input validation on certificate content and path fields allows arbitrary file writes to any path on the filesystem."
Attack vector
An attacker with access to the Nginx-UI web interface can call the Import Certificate (AddCert or ModifyCert) endpoints. The original code accepted arbitrary values for `ssl_certificate_path`, `ssl_certificate_key_path`, `ssl_certificate`, and `ssl_certificate_key` without verifying that the content was a valid certificate or private key [patch_id=436507]. By supplying a crafted payload (e.g., a configuration file body) and pointing the path to a sensitive location such as `app.ini`, the attacker can overwrite arbitrary files on the server. This can be escalated to remote code execution by overwriting the application configuration file.
Affected code
The vulnerable code is in `api/certificate/certificate.go` in the `AddCert` and `ModifyCert` functions, which accepted arbitrary `SSLCertificatePath`, `SSLCertificateKeyPath`, `SSLCertificate`, and `SSLCertificateKey` values without validation. The `cert.Content.WriteFile()` method then wrote the content to the user-supplied path without any checks.
What the fix does
The patch introduces a new `certJson` struct that adds Gin binding validators (`publickey`, `privatekey`, `publickey_path`, `privatekey_path`) to the certificate content and path fields [patch_id=436507]. These validators, implemented in `internal/cert/helper.go` and `internal/validation/certificate.go`, parse the PEM content and verify it is a valid x509 public key or private key using Go's crypto/x509 library. The `Transformer` function in `api/certificate/certificate.go` also now checks `IsPublicKey` and `IsPrivateKey` before returning certificate content, preventing non-certificate data from being displayed. Additionally, the validation engine initialization was moved from `api/api.go` to a dedicated `internal/validation` package called during boot [patch_id=436507].
Preconditions
- authAttacker must have access to the Nginx-UI web interface (authenticated session)
- networkAttacker must be able to send HTTP requests to the Nginx-UI server
- inputAttacker must supply a non-certificate payload (e.g., config file content) and a path pointing to a sensitive location
Generated on May 19, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-xvq9-4vpv-227mghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-23827ghsaADVISORY
- github.com/0xJacky/nginx-ui/blob/f20d97a9fdc2a83809498b35b6abc0239ec7fdda/api/certificate/certificate.goghsaWEB
- github.com/0xJacky/nginx-ui/blob/f20d97a9fdc2a83809498b35b6abc0239ec7fdda/internal/cert/write_file.goghsaWEB
- github.com/0xJacky/nginx-ui/commit/8581bdd3c6f49ab345b773517ba9173fa7fc6199ghsaWEB
- github.com/0xJacky/nginx-ui/security/advisories/GHSA-xvq9-4vpv-227mghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.