release: prepare v0.1.131
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.130
|
||||
0.1.131
|
||||
|
||||
@@ -1134,6 +1134,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
||||
return
|
||||
}
|
||||
switch item.Placement {
|
||||
case "", "sidebar":
|
||||
items[i].Placement = "sidebar"
|
||||
case "home_header", "both":
|
||||
default:
|
||||
response.BadRequest(c, "Custom menu item placement must be 'sidebar', 'home_header' or 'both'")
|
||||
return
|
||||
}
|
||||
if len(item.IconSVG) > maxMenuItemIconSVGLen {
|
||||
response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)")
|
||||
return
|
||||
|
||||
@@ -13,6 +13,7 @@ type CustomMenuItem struct {
|
||||
URL string `json:"url"`
|
||||
PageSlug string `json:"page_slug,omitempty"`
|
||||
Visibility string `json:"visibility"` // "user" or "admin"
|
||||
Placement string `json:"placement,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
@@ -367,9 +368,25 @@ func ParseCustomMenuItems(raw string) []CustomMenuItem {
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return []CustomMenuItem{}
|
||||
}
|
||||
for i := range items {
|
||||
items[i].Placement = normalizeCustomMenuPlacement(items[i].Placement)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func normalizeCustomMenuPlacement(raw string) string {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case "", "sidebar":
|
||||
return "sidebar"
|
||||
case "home_header":
|
||||
return "home_header"
|
||||
case "both":
|
||||
return "both"
|
||||
default:
|
||||
return "sidebar"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries.
|
||||
func ParseUserVisibleMenuItems(raw string) []CustomMenuItem {
|
||||
items := ParseCustomMenuItems(raw)
|
||||
|
||||
@@ -91,8 +91,17 @@ func SecurityHeaders(cfg config.CSPConfig, getFrameSrcOrigins func() []string) g
|
||||
}
|
||||
}
|
||||
|
||||
embeddedMode := isEmbeddedUIRequest(c)
|
||||
if embeddedMode {
|
||||
finalPolicy = setDirective(finalPolicy, "frame-ancestors", "'self'")
|
||||
}
|
||||
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
if embeddedMode {
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
} else {
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
}
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
if isAPIRoutePath(c) {
|
||||
c.Next()
|
||||
@@ -127,6 +136,13 @@ func isAPIRoutePath(c *gin.Context) bool {
|
||||
strings.HasPrefix(path, "/images")
|
||||
}
|
||||
|
||||
func isEmbeddedUIRequest(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil || c.Request.URL == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(c.Query("ui_mode")), "embedded")
|
||||
}
|
||||
|
||||
// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和支付 SDK 必需域名。
|
||||
// 这样旧配置文件没有及时补域名时,前端支付组件仍能正常加载。
|
||||
func enhanceCSPPolicy(policy string) string {
|
||||
@@ -195,3 +211,31 @@ func addToDirective(policy, directive, value string) string {
|
||||
insertPos := idx + endIdx
|
||||
return policy[:insertPos] + " " + value + policy[insertPos:]
|
||||
}
|
||||
|
||||
func setDirective(policy, directive, value string) string {
|
||||
directives := strings.Split(policy, ";")
|
||||
replaced := false
|
||||
for i, rawDirective := range directives {
|
||||
fields := strings.Fields(strings.TrimSpace(rawDirective))
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
if fields[0] == directive {
|
||||
directives[i] = directive + " " + value
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
directives = append(directives, directive+" "+value)
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(directives))
|
||||
for _, rawDirective := range directives {
|
||||
trimmed := strings.TrimSpace(rawDirective)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return strings.Join(result, "; ")
|
||||
}
|
||||
|
||||
@@ -151,6 +151,24 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
assert.Empty(t, GetNonceFromContext(c))
|
||||
})
|
||||
|
||||
t.Run("embedded_ui_allows_same_origin_frame", func(t *testing.T) {
|
||||
cfg := config.CSPConfig{
|
||||
Enabled: true,
|
||||
Policy: "default-src 'self'; frame-ancestors 'none'",
|
||||
}
|
||||
middleware := SecurityHeaders(cfg, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/docs/api.html?ui_mode=embedded", nil)
|
||||
|
||||
middleware(c)
|
||||
|
||||
assert.Equal(t, "SAMEORIGIN", w.Header().Get("X-Frame-Options"))
|
||||
assert.Contains(t, w.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'")
|
||||
assert.NotContains(t, w.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'")
|
||||
})
|
||||
|
||||
t.Run("csp_enabled_with_nonce_placeholder", func(t *testing.T) {
|
||||
cfg := config.CSPConfig{
|
||||
Enabled: true,
|
||||
@@ -410,6 +428,23 @@ func TestAddToDirective(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetDirective(t *testing.T) {
|
||||
t.Run("replaces_existing_directive", func(t *testing.T) {
|
||||
policy := "default-src 'self'; frame-ancestors 'none'; script-src 'self'"
|
||||
result := setDirective(policy, "frame-ancestors", "'self'")
|
||||
|
||||
assert.Contains(t, result, "frame-ancestors 'self'")
|
||||
assert.NotContains(t, result, "frame-ancestors 'none'")
|
||||
})
|
||||
|
||||
t.Run("adds_directive_when_missing", func(t *testing.T) {
|
||||
policy := "default-src 'self'; script-src 'self'"
|
||||
result := setDirective(policy, "frame-ancestors", "'self'")
|
||||
|
||||
assert.Contains(t, result, "frame-ancestors 'self'")
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkGenerateNonce(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
|
||||
@@ -720,7 +720,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
TableDefaultPageSize: tableDefaultPageSize,
|
||||
TablePageSizeOptions: tablePageSizeOptions,
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
CustomMenuItems: string(normalizeCustomMenuItemsRaw(settings[SettingKeyCustomMenuItems])),
|
||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
WeChatOAuthEnabled: weChatEnabled,
|
||||
@@ -987,7 +987,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
TableDefaultPageSize: settings.TableDefaultPageSize,
|
||||
TablePageSizeOptions: settings.TablePageSizeOptions,
|
||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
CustomMenuItems: filterUserVisibleMenuItems(string(normalizeCustomMenuItemsRaw(settings.CustomMenuItems))),
|
||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
||||
@@ -1200,6 +1200,32 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeCustomMenuItemsRaw(raw string) json.RawMessage {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
for _, item := range items {
|
||||
placement, _ := item["placement"].(string)
|
||||
switch strings.TrimSpace(placement) {
|
||||
case "", "sidebar":
|
||||
item["placement"] = "sidebar"
|
||||
case "home_header", "both":
|
||||
default:
|
||||
item["placement"] = "sidebar"
|
||||
}
|
||||
}
|
||||
normalized, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
|
||||
func safeRawJSONArray(raw string) json.RawMessage {
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
Reference in New Issue
Block a user