Skip to content

Commit 33c23ce

Browse files
committed
Protect sensitive settings with 2FA reveal flow
1 parent a67285b commit 33c23ce

File tree

12 files changed

+452
-50
lines changed

12 files changed

+452
-50
lines changed

api/settings/protected.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package settings
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/0xJacky/Nginx-UI/settings"
7+
"github.com/gin-gonic/gin"
8+
cSettings "github.com/uozi-tech/cosy/settings"
9+
)
10+
11+
var protectedSettingRevealAllowlist = map[string]func() string{
12+
"app.jwt_secret": func() string {
13+
return cSettings.AppSettings.JwtSecret
14+
},
15+
"node.secret": func() string {
16+
return settings.NodeSettings.Secret
17+
},
18+
"openai.token": func() string {
19+
return settings.OpenAISettings.Token
20+
},
21+
}
22+
23+
func GetProtectedSetting(c *gin.Context) {
24+
if _, ok := c.Get("Secret"); ok {
25+
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
26+
"message": "Node secret authentication is not allowed for protected settings",
27+
})
28+
return
29+
}
30+
31+
path := c.Query("path")
32+
getter, ok := protectedSettingRevealAllowlist[path]
33+
if !ok {
34+
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
35+
"message": "Protected setting path is invalid",
36+
})
37+
return
38+
}
39+
40+
c.JSON(http.StatusOK, gin.H{
41+
"value": getter(),
42+
})
43+
}

api/settings/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
func InitRouter(r *gin.RouterGroup) {
99
r.GET("settings/server/name", GetServerName)
1010
r.GET("settings", GetSettings)
11+
r.GET("settings/protected", middleware.RequireSecureSession(), GetProtectedSetting)
1112
r.POST("settings", middleware.RequireSecureSession(), SaveSettings)
1213

1314
r.GET("settings/auth/banned_ips", GetBanLoginIP)

api/settings/settings.go

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package settings
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"net/http"
67

@@ -15,6 +16,77 @@ import (
1516
cSettings "github.com/uozi-tech/cosy/settings"
1617
)
1718

19+
const redactedSensitiveValue = "__NGINX_UI_REDACTED__"
20+
21+
type saveSettingsPayload struct {
22+
App cSettings.App `json:"app"`
23+
Server cSettings.Server `json:"server"`
24+
Auth settings.Auth `json:"auth"`
25+
Cert settings.Cert `json:"cert"`
26+
Http settings.HTTP `json:"http"`
27+
Node settings.Node `json:"node"`
28+
Openai settings.OpenAI `json:"openai"`
29+
Logrotate settings.Logrotate `json:"logrotate"`
30+
Nginx settings.Nginx `json:"nginx"`
31+
Oidc settings.OIDC `json:"oidc"`
32+
}
33+
34+
func cloneSettingsSection(section any) gin.H {
35+
raw, err := json.Marshal(section)
36+
if err != nil {
37+
return gin.H{}
38+
}
39+
40+
var cloned gin.H
41+
if err := json.Unmarshal(raw, &cloned); err != nil {
42+
return gin.H{}
43+
}
44+
45+
return cloned
46+
}
47+
48+
func buildSettingsResponse() gin.H {
49+
app := cloneSettingsSection(cSettings.AppSettings)
50+
app["jwt_secret"] = redactedSensitiveValue
51+
52+
node := cloneSettingsSection(settings.NodeSettings)
53+
node["secret"] = redactedSensitiveValue
54+
55+
openai := cloneSettingsSection(settings.OpenAISettings)
56+
openai["token"] = redactedSensitiveValue
57+
58+
return gin.H{
59+
"app": app,
60+
"server": cSettings.ServerSettings,
61+
"database": settings.DatabaseSettings,
62+
"auth": settings.AuthSettings,
63+
"casdoor": settings.CasdoorSettings,
64+
"oidc": settings.OIDCSettings,
65+
"cert": settings.CertSettings,
66+
"http": settings.HTTPSettings,
67+
"logrotate": settings.LogrotateSettings,
68+
"nginx": settings.NginxSettings,
69+
"node": node,
70+
"openai": openai,
71+
"terminal": settings.TerminalSettings,
72+
"webauthn": settings.WebAuthnSettings,
73+
}
74+
}
75+
76+
func restoreRedactedSensitiveSettings(payload *saveSettingsPayload) {
77+
if payload.App.JwtSecret == redactedSensitiveValue {
78+
payload.App.JwtSecret = cSettings.AppSettings.JwtSecret
79+
}
80+
81+
if payload.Node.Secret == redactedSensitiveValue {
82+
payload.Node.Secret = settings.NodeSettings.Secret
83+
}
84+
85+
if payload.Openai.Token == redactedSensitiveValue {
86+
payload.Openai.Token = settings.OpenAISettings.Token
87+
}
88+
}
89+
1890
func GetServerName(c *gin.Context) {
1991
c.JSON(http.StatusOK, gin.H{
2092
"name": settings.NodeSettings.Name,
@@ -46,42 +118,18 @@ func GetSettings(c *gin.Context) {
46118
fmt.Sprintf("start-stop-daemon --start --quiet --pidfile %s --exec %s", pidPath, daemon)
47119
}
48120

49-
c.JSON(http.StatusOK, gin.H{
50-
"app": cSettings.AppSettings,
51-
"server": cSettings.ServerSettings,
52-
"database": settings.DatabaseSettings,
53-
"auth": settings.AuthSettings,
54-
"casdoor": settings.CasdoorSettings,
55-
"oidc": settings.OIDCSettings,
56-
"cert": settings.CertSettings,
57-
"http": settings.HTTPSettings,
58-
"logrotate": settings.LogrotateSettings,
59-
"nginx": settings.NginxSettings,
60-
"node": settings.NodeSettings,
61-
"openai": settings.OpenAISettings,
62-
"terminal": settings.TerminalSettings,
63-
"webauthn": settings.WebAuthnSettings,
64-
})
121+
c.JSON(http.StatusOK, buildSettingsResponse())
65122
}
66123

67124
func SaveSettings(c *gin.Context) {
68-
var json struct {
69-
App cSettings.App `json:"app"`
70-
Server cSettings.Server `json:"server"`
71-
Auth settings.Auth `json:"auth"`
72-
Cert settings.Cert `json:"cert"`
73-
Http settings.HTTP `json:"http"`
74-
Node settings.Node `json:"node"`
75-
Openai settings.OpenAI `json:"openai"`
76-
Logrotate settings.Logrotate `json:"logrotate"`
77-
Nginx settings.Nginx `json:"nginx"`
78-
Oidc settings.OIDC `json:"oidc"`
79-
}
125+
var json saveSettingsPayload
80126

81127
if !cosy.BindAndValid(c, &json) {
82128
return
83129
}
84130

131+
restoreRedactedSensitiveSettings(&json)
132+
85133
if settings.LogrotateSettings.Enabled != json.Logrotate.Enabled ||
86134
settings.LogrotateSettings.Interval != json.Logrotate.Interval {
87135
go cron.RestartLogrotate()

api/settings/settings_test.go

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ package settings
22

33
import (
44
"bytes"
5+
"encoding/json"
56
"net/http"
67
"net/http/httptest"
78
"testing"
89

10+
"github.com/0xJacky/Nginx-UI/internal/cache"
11+
"github.com/0xJacky/Nginx-UI/internal/middleware"
12+
internaluser "github.com/0xJacky/Nginx-UI/internal/user"
13+
"github.com/0xJacky/Nginx-UI/model"
914
appsettings "github.com/0xJacky/Nginx-UI/settings"
1015
"github.com/gin-gonic/gin"
1116
"github.com/stretchr/testify/assert"
17+
cSettings "github.com/uozi-tech/cosy/settings"
1218
)
1319

1420
func TestSaveSettingsRejectsNegativeLogrotateInterval(t *testing.T) {
@@ -26,6 +32,163 @@ func TestSaveSettingsRejectsNegativeLogrotateInterval(t *testing.T) {
2632

2733
SaveSettings(c)
2834

29-
assert.Equal(t, http.StatusBadRequest, w.Code)
30-
assert.Contains(t, w.Body.String(), appsettings.InvalidLogrotateIntervalMessage)
35+
assert.Equal(t, http.StatusNotAcceptable, w.Code)
36+
assert.Contains(t, w.Body.String(), "\"interval\":\"min\"")
37+
}
38+
39+
func TestGetSettingsRedactsSensitiveFields(t *testing.T) {
40+
gin.SetMode(gin.TestMode)
41+
42+
originalJWTSecret := cSettings.AppSettings.JwtSecret
43+
originalPageSize := cSettings.AppSettings.PageSize
44+
originalNodeSecret := appsettings.NodeSettings.Secret
45+
originalNodeName := appsettings.NodeSettings.Name
46+
originalOpenAIToken := appsettings.OpenAISettings.Token
47+
originalReloadCmd := appsettings.NginxSettings.ReloadCmd
48+
originalRestartCmd := appsettings.NginxSettings.RestartCmd
49+
defer func() {
50+
cSettings.AppSettings.JwtSecret = originalJWTSecret
51+
cSettings.AppSettings.PageSize = originalPageSize
52+
appsettings.NodeSettings.Secret = originalNodeSecret
53+
appsettings.NodeSettings.Name = originalNodeName
54+
appsettings.OpenAISettings.Token = originalOpenAIToken
55+
appsettings.NginxSettings.ReloadCmd = originalReloadCmd
56+
appsettings.NginxSettings.RestartCmd = originalRestartCmd
57+
}()
58+
59+
cSettings.AppSettings.JwtSecret = "jwt-secret"
60+
cSettings.AppSettings.PageSize = 50
61+
appsettings.NodeSettings.Secret = "node-secret"
62+
appsettings.NodeSettings.Name = "local-node"
63+
appsettings.OpenAISettings.Token = "openai-secret"
64+
appsettings.NginxSettings.ReloadCmd = "nginx -s reload"
65+
appsettings.NginxSettings.RestartCmd = "nginx -s restart"
66+
67+
w := httptest.NewRecorder()
68+
c, _ := gin.CreateTestContext(w)
69+
c.Request = httptest.NewRequest(http.MethodGet, "/api/settings", nil)
70+
71+
GetSettings(c)
72+
73+
assert.Equal(t, http.StatusOK, w.Code)
74+
75+
var body map[string]map[string]any
76+
err := json.Unmarshal(w.Body.Bytes(), &body)
77+
assert.NoError(t, err)
78+
assert.Equal(t, redactedSensitiveValue, body["app"]["jwt_secret"])
79+
assert.Equal(t, float64(50), body["app"]["page_size"])
80+
assert.Equal(t, redactedSensitiveValue, body["node"]["secret"])
81+
assert.Equal(t, "local-node", body["node"]["name"])
82+
assert.Equal(t, redactedSensitiveValue, body["openai"]["token"])
83+
}
84+
85+
func TestRestoreRedactedSensitiveSettings(t *testing.T) {
86+
originalJWTSecret := cSettings.AppSettings.JwtSecret
87+
originalNodeSecret := appsettings.NodeSettings.Secret
88+
originalOpenAIToken := appsettings.OpenAISettings.Token
89+
defer func() {
90+
cSettings.AppSettings.JwtSecret = originalJWTSecret
91+
appsettings.NodeSettings.Secret = originalNodeSecret
92+
appsettings.OpenAISettings.Token = originalOpenAIToken
93+
}()
94+
95+
cSettings.AppSettings.JwtSecret = "jwt-secret"
96+
appsettings.NodeSettings.Secret = "node-secret"
97+
appsettings.OpenAISettings.Token = "openai-secret"
98+
99+
payload := saveSettingsPayload{}
100+
payload.App.JwtSecret = redactedSensitiveValue
101+
payload.Node.Secret = redactedSensitiveValue
102+
payload.Openai.Token = redactedSensitiveValue
103+
104+
restoreRedactedSensitiveSettings(&payload)
105+
106+
assert.Equal(t, "jwt-secret", payload.App.JwtSecret)
107+
assert.Equal(t, "node-secret", payload.Node.Secret)
108+
assert.Equal(t, "openai-secret", payload.Openai.Token)
109+
}
110+
111+
func TestGetProtectedSetting(t *testing.T) {
112+
gin.SetMode(gin.TestMode)
113+
cache.InitInMemoryCache()
114+
defer cache.Shutdown()
115+
116+
originalJWTSecret := cSettings.AppSettings.JwtSecret
117+
defer func() {
118+
cSettings.AppSettings.JwtSecret = originalJWTSecret
119+
}()
120+
cSettings.AppSettings.JwtSecret = "jwt-secret"
121+
122+
t.Run("rejects missing secure session", func(t *testing.T) {
123+
r := gin.New()
124+
r.GET("/api/settings/protected", func(c *gin.Context) {
125+
c.Set("user", &model.User{
126+
Model: model.Model{ID: 1},
127+
OTPSecret: []byte("otp-enabled"),
128+
})
129+
}, middleware.RequireSecureSession(), GetProtectedSetting)
130+
131+
req := httptest.NewRequest(http.MethodGet, "/api/settings/protected?path=app.jwt_secret", nil)
132+
w := httptest.NewRecorder()
133+
r.ServeHTTP(w, req)
134+
135+
assert.Equal(t, http.StatusUnauthorized, w.Code)
136+
})
137+
138+
t.Run("rejects node secret authentication", func(t *testing.T) {
139+
r := gin.New()
140+
r.GET("/api/settings/protected", func(c *gin.Context) {
141+
c.Set("user", &model.User{
142+
Model: model.Model{ID: 1},
143+
})
144+
c.Set("Secret", "node-secret")
145+
}, middleware.RequireSecureSession(), GetProtectedSetting)
146+
147+
req := httptest.NewRequest(http.MethodGet, "/api/settings/protected?path=app.jwt_secret", nil)
148+
w := httptest.NewRecorder()
149+
r.ServeHTTP(w, req)
150+
151+
assert.Equal(t, http.StatusForbidden, w.Code)
152+
})
153+
154+
t.Run("rejects invalid path", func(t *testing.T) {
155+
r := gin.New()
156+
r.GET("/api/settings/protected", func(c *gin.Context) {
157+
user := &model.User{
158+
Model: model.Model{ID: 2},
159+
OTPSecret: []byte("otp-enabled"),
160+
}
161+
c.Set("user", user)
162+
}, middleware.RequireSecureSession(), GetProtectedSetting)
163+
164+
req := httptest.NewRequest(http.MethodGet, "/api/settings/protected?path=node.name", nil)
165+
req.Header.Set("X-Secure-Session-ID", internaluser.SetSecureSessionID(2))
166+
w := httptest.NewRecorder()
167+
r.ServeHTTP(w, req)
168+
169+
assert.Equal(t, http.StatusBadRequest, w.Code)
170+
})
171+
172+
t.Run("returns protected value", func(t *testing.T) {
173+
r := gin.New()
174+
r.GET("/api/settings/protected", func(c *gin.Context) {
175+
user := &model.User{
176+
Model: model.Model{ID: 3},
177+
OTPSecret: []byte("otp-enabled"),
178+
}
179+
c.Set("user", user)
180+
}, middleware.RequireSecureSession(), GetProtectedSetting)
181+
182+
req := httptest.NewRequest(http.MethodGet, "/api/settings/protected?path=app.jwt_secret", nil)
183+
req.Header.Set("X-Secure-Session-ID", internaluser.SetSecureSessionID(3))
184+
w := httptest.NewRecorder()
185+
r.ServeHTTP(w, req)
186+
187+
assert.Equal(t, http.StatusOK, w.Code)
188+
189+
var body map[string]string
190+
err := json.Unmarshal(w.Body.Bytes(), &body)
191+
assert.NoError(t, err)
192+
assert.Equal(t, "jwt-secret", body["value"])
193+
})
31194
}

app/src/api/settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { http } from '@uozi-admin/request'
22

3+
export const PROTECTED_VALUE_PLACEHOLDER = '__NGINX_UI_REDACTED__'
4+
35
export interface AppSettings {
46
page_size: number
57
jwt_secret: string
@@ -141,6 +143,11 @@ const settings = {
141143
get(): Promise<Settings> {
142144
return http.get('/settings')
143145
},
146+
get_protected_value(path: string): Promise<{ value: string }> {
147+
return http.get('/settings/protected', {
148+
params: { path },
149+
})
150+
},
144151
save(data: Settings) {
145152
return http.post('/settings', data)
146153
},

0 commit comments

Comments
 (0)