Add omit_auth_context as a safe default for custom menu iframe pages, converge untrusted public output, and document the deployed shop menu configuration.
181 lines
6.6 KiB
Go
181 lines
6.6 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/url"
|
||
"strings"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
)
|
||
|
||
// CustomMenuAuthContextItem 是菜单认证上下文策略使用的最小数据契约。
|
||
type CustomMenuAuthContextItem struct {
|
||
ID string `json:"id"`
|
||
Label string `json:"label"`
|
||
IconSVG string `json:"icon_svg"`
|
||
URL string `json:"url"`
|
||
PageSlug string `json:"page_slug,omitempty"`
|
||
Visibility string `json:"visibility"`
|
||
SortOrder int `json:"sort_order"`
|
||
OmitAuthContext *bool `json:"omit_auth_context,omitempty"`
|
||
}
|
||
|
||
// NormalizeCustomMenuItemsForWrite 在保存 custom_menu_items 前补齐安全默认值并校验显式传认证上下文的 URL。
|
||
func NormalizeCustomMenuItemsForWrite(items []CustomMenuAuthContextItem, previousRaw, frontendURL string, cfg *config.Config) ([]CustomMenuAuthContextItem, error) {
|
||
previousItems, previousByID, previousByFingerprint := indexPreviousCustomMenuItems(previousRaw)
|
||
normalized := make([]CustomMenuAuthContextItem, len(items))
|
||
copy(normalized, items)
|
||
|
||
for i := range normalized {
|
||
item := &normalized[i]
|
||
oldItem, existed := matchPreviousCustomMenuItem(i, *item, previousItems, previousByID, previousByFingerprint)
|
||
newTrusted := IsCustomMenuAuthContextTrustedURL(item.URL, frontendURL, cfg)
|
||
|
||
if item.OmitAuthContext == nil {
|
||
switch {
|
||
case !existed:
|
||
item.OmitAuthContext = customMenuBoolPtr(true)
|
||
case oldItem.OmitAuthContext == nil && movedToNewUntrustedOrigin(oldItem.URL, item.URL, newTrusted):
|
||
item.OmitAuthContext = customMenuBoolPtr(true)
|
||
case oldItem.OmitAuthContext == nil:
|
||
// 旧缺省菜单只在跨到新的非受信 origin 时固化为 true;普通保存保持存储三态。
|
||
case oldItem.OmitAuthContext != nil:
|
||
item.OmitAuthContext = customMenuBoolPtr(*oldItem.OmitAuthContext)
|
||
}
|
||
}
|
||
|
||
if item.OmitAuthContext != nil && !*item.OmitAuthContext && !IsMarkdownCustomMenuURL(item.URL) && !newTrusted && !isPreservedExistingUntrustedAuthContextFalse(oldItem, existed, item.URL) {
|
||
return nil, ErrCustomMenuAuthContextUntrusted
|
||
}
|
||
}
|
||
|
||
return normalized, nil
|
||
}
|
||
|
||
// ConvergeCustomMenuItemsForRead 对输出值做安全收敛,避免受信配置撤销后继续向非受信 iframe 传 token。
|
||
func ConvergeCustomMenuItemsForRead(items []CustomMenuAuthContextItem, frontendURL string, cfg *config.Config) []CustomMenuAuthContextItem {
|
||
converged := make([]CustomMenuAuthContextItem, len(items))
|
||
copy(converged, items)
|
||
for i := range converged {
|
||
item := &converged[i]
|
||
if IsMarkdownCustomMenuURL(item.URL) || IsCustomMenuAuthContextTrustedURL(item.URL, frontendURL, cfg) {
|
||
continue
|
||
}
|
||
if item.OmitAuthContext == nil || !*item.OmitAuthContext {
|
||
item.OmitAuthContext = customMenuBoolPtr(true)
|
||
}
|
||
}
|
||
return converged
|
||
}
|
||
|
||
// ConvergeCustomMenuItemsJSONForRead 对 raw JSON 菜单做输出收敛,非法 JSON 保持空数组语义。
|
||
func ConvergeCustomMenuItemsJSONForRead(raw, frontendURL string, cfg *config.Config) string {
|
||
items := ParseCustomMenuAuthContextItems(raw)
|
||
if len(items) == 0 {
|
||
return "[]"
|
||
}
|
||
data, err := json.Marshal(ConvergeCustomMenuItemsForRead(items, frontendURL, cfg))
|
||
if err != nil {
|
||
return "[]"
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
// ParseCustomMenuAuthContextItems 解析菜单 JSON,保留 omit_auth_context 的 nil/false/true 三态。
|
||
func ParseCustomMenuAuthContextItems(raw string) []CustomMenuAuthContextItem {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" || raw == "[]" {
|
||
return []CustomMenuAuthContextItem{}
|
||
}
|
||
var items []CustomMenuAuthContextItem
|
||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||
return []CustomMenuAuthContextItem{}
|
||
}
|
||
return items
|
||
}
|
||
|
||
// IsCustomMenuAuthContextTrustedURL 判断菜单 URL 是否允许携带认证上下文;只看配置,不信请求 Origin。
|
||
func IsCustomMenuAuthContextTrustedURL(rawURL, frontendURL string, cfg *config.Config) bool {
|
||
origin := extractCustomMenuOrigin(rawURL)
|
||
if origin == "" {
|
||
return false
|
||
}
|
||
if cfg != nil {
|
||
if cfgFrontendOrigin := extractCustomMenuOrigin(cfg.Server.FrontendURL); cfgFrontendOrigin != "" && origin == cfgFrontendOrigin {
|
||
return true
|
||
}
|
||
for _, trusted := range cfg.Security.CustomMenuAuthContextTrustedOrigins {
|
||
if trustedOrigin := extractCustomMenuOrigin(trusted); trustedOrigin != "" && origin == trustedOrigin {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func IsMarkdownCustomMenuURL(rawURL string) bool {
|
||
return strings.HasPrefix(strings.TrimSpace(rawURL), "md:")
|
||
}
|
||
|
||
func indexPreviousCustomMenuItems(raw string) ([]CustomMenuAuthContextItem, map[string]CustomMenuAuthContextItem, map[string]CustomMenuAuthContextItem) {
|
||
previousItems := ParseCustomMenuAuthContextItems(raw)
|
||
byID := map[string]CustomMenuAuthContextItem{}
|
||
byFingerprint := map[string]CustomMenuAuthContextItem{}
|
||
for _, item := range previousItems {
|
||
if id := strings.TrimSpace(item.ID); id != "" {
|
||
byID[id] = item
|
||
}
|
||
byFingerprint[customMenuLegacyFingerprint(item)] = item
|
||
}
|
||
return previousItems, byID, byFingerprint
|
||
}
|
||
|
||
func matchPreviousCustomMenuItem(index int, item CustomMenuAuthContextItem, previousItems []CustomMenuAuthContextItem, byID, byFingerprint map[string]CustomMenuAuthContextItem) (CustomMenuAuthContextItem, bool) {
|
||
if id := strings.TrimSpace(item.ID); id != "" {
|
||
if oldItem, ok := byID[id]; ok {
|
||
return oldItem, true
|
||
}
|
||
}
|
||
if oldItem, ok := byFingerprint[customMenuLegacyFingerprint(item)]; ok {
|
||
return oldItem, true
|
||
}
|
||
if strings.TrimSpace(item.ID) == "" && index >= 0 && index < len(previousItems) && strings.TrimSpace(previousItems[index].ID) == "" {
|
||
return previousItems[index], true
|
||
}
|
||
return CustomMenuAuthContextItem{}, false
|
||
}
|
||
|
||
func customMenuLegacyFingerprint(item CustomMenuAuthContextItem) string {
|
||
return strings.TrimSpace(item.URL) + "\x00" + strings.TrimSpace(item.Label) + "\x00" + strings.TrimSpace(item.Visibility)
|
||
}
|
||
|
||
func movedToNewUntrustedOrigin(oldURL, newURL string, newTrusted bool) bool {
|
||
if newTrusted || IsMarkdownCustomMenuURL(newURL) {
|
||
return false
|
||
}
|
||
return extractCustomMenuOrigin(oldURL) != extractCustomMenuOrigin(newURL)
|
||
}
|
||
|
||
func isPreservedExistingUntrustedAuthContextFalse(oldItem CustomMenuAuthContextItem, existed bool, newURL string) bool {
|
||
if !existed || oldItem.OmitAuthContext == nil || *oldItem.OmitAuthContext {
|
||
return false
|
||
}
|
||
return extractCustomMenuOrigin(oldItem.URL) == extractCustomMenuOrigin(newURL)
|
||
}
|
||
|
||
func extractCustomMenuOrigin(raw string) string {
|
||
u, err := url.Parse(strings.TrimSpace(raw))
|
||
if err != nil || u.Host == "" {
|
||
return ""
|
||
}
|
||
scheme := strings.ToLower(u.Scheme)
|
||
if scheme != "http" && scheme != "https" {
|
||
return ""
|
||
}
|
||
return scheme + "://" + strings.ToLower(u.Host)
|
||
}
|
||
|
||
func customMenuBoolPtr(v bool) *bool {
|
||
return &v
|
||
}
|