sub2api/backend/internal/service/custom_menu_auth_context.go
zizi ac5ff854b2 feat(custom-menu): secure embedded shop pages
Add omit_auth_context as a safe default for custom menu iframe pages, converge untrusted public output, and document the deployed shop menu configuration.
2026-05-27 14:28:17 +08:00

181 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}