fix(security): add JWT auth + visibility check to pages API

- GET /pages/:slug now requires JWT + checks custom_menu_items visibility
- GET /pages (list) is admin-only
- GET /pages/:slug/images/* uses visibility check without JWT (browser
  img tags cannot carry auth headers), blocks admin-only page images
- Frontend fetch adds Authorization header from authStore.token
- settingService nil guard changed to fail-closed (deny access)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael-Jetson 2026-05-05 07:00:08 -07:00
parent 4cbd4932a0
commit cf2d5067c3
4 changed files with 108 additions and 9 deletions

View File

@ -1,6 +1,7 @@
package handler package handler
import ( import (
"encoding/json"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -8,6 +9,8 @@ import (
"strings" "strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -16,13 +19,14 @@ var validSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
const maxPageFileSize = 1 << 20 // 1MB const maxPageFileSize = 1 << 20 // 1MB
type PageHandler struct { type PageHandler struct {
pagesDir string pagesDir string
settingService *service.SettingService
} }
func NewPageHandler(dataDir string) *PageHandler { func NewPageHandler(dataDir string, settingService *service.SettingService) *PageHandler {
pagesDir := filepath.Join(dataDir, "pages") pagesDir := filepath.Join(dataDir, "pages")
_ = os.MkdirAll(pagesDir, 0755) _ = os.MkdirAll(pagesDir, 0755)
return &PageHandler{pagesDir: pagesDir} return &PageHandler{pagesDir: pagesDir, settingService: settingService}
} }
// GetPageContent serves raw markdown content for a given slug. // GetPageContent serves raw markdown content for a given slug.
@ -34,6 +38,13 @@ func (h *PageHandler) GetPageContent(c *gin.Context) {
return return
} }
// Visibility check: slug must be configured in custom_menu_items
// and the user must have permission based on visibility setting
if !h.checkSlugVisibility(c, slug) {
c.JSON(http.StatusNotFound, gin.H{"error": "page not found"})
return
}
filePath := filepath.Join(h.pagesDir, slug+".md") filePath := filepath.Join(h.pagesDir, slug+".md")
cleaned := filepath.Clean(filePath) cleaned := filepath.Clean(filePath)
if !strings.HasPrefix(cleaned, filepath.Clean(h.pagesDir)) { if !strings.HasPrefix(cleaned, filepath.Clean(h.pagesDir)) {
@ -84,6 +95,7 @@ func (h *PageHandler) ListPages(c *gin.Context) {
// ServePageImage serves images from data/pages/{slug}/ directory. // ServePageImage serves images from data/pages/{slug}/ directory.
// GET /api/v1/pages/:slug/images/*filename // GET /api/v1/pages/:slug/images/*filename
// No JWT required (browser img tags can't carry tokens), but visibility is checked.
func (h *PageHandler) ServePageImage(c *gin.Context) { func (h *PageHandler) ServePageImage(c *gin.Context) {
slug := c.Param("slug") slug := c.Param("slug")
filename := c.Param("filename") filename := c.Param("filename")
@ -93,6 +105,12 @@ func (h *PageHandler) ServePageImage(c *gin.Context) {
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
return return
} }
if !h.checkImageSlugVisibility(c, slug) {
c.Status(http.StatusNotFound)
return
}
if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
return return
@ -115,13 +133,83 @@ func (h *PageHandler) ServePageImage(c *gin.Context) {
c.File(cleaned) c.File(cleaned)
} }
// findSlugVisibility looks up the slug in custom_menu_items and returns (visibility, found).
func (h *PageHandler) findSlugVisibility(c *gin.Context, slug string) (string, bool) {
if h.settingService == nil {
return "", false
}
raw := h.settingService.GetCustomMenuItemsRaw(c.Request.Context())
if raw == "" || raw == "[]" {
return "", false
}
var items []struct {
URL string `json:"url"`
PageSlug string `json:"page_slug"`
Visibility string `json:"visibility"`
}
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return "", false
}
for _, item := range items {
itemSlug := item.PageSlug
if itemSlug == "" && strings.HasPrefix(item.URL, "md:") {
itemSlug = strings.TrimPrefix(item.URL, "md:")
}
if itemSlug == slug {
return item.Visibility, true
}
}
return "", false
}
// checkSlugVisibility verifies the slug is configured in custom_menu_items
// and the authenticated user has permission to view it.
func (h *PageHandler) checkSlugVisibility(c *gin.Context, slug string) bool {
visibility, found := h.findSlugVisibility(c, slug)
if !found {
return false
}
if visibility == "admin" {
role, _ := middleware2.GetUserRoleFromContext(c)
return role == "admin"
}
return true
}
// checkImageSlugVisibility checks visibility for image requests (no JWT available).
// Only allows user-visible pages; admin-only pages are blocked.
func (h *PageHandler) checkImageSlugVisibility(c *gin.Context, slug string) bool {
visibility, found := h.findSlugVisibility(c, slug)
if !found {
return false
}
return visibility != "admin"
}
// RegisterPageRoutes registers page routes on a router group. // RegisterPageRoutes registers page routes on a router group.
func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string) { func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string, jwtAuth gin.HandlerFunc, adminAuth gin.HandlerFunc, settingService *service.SettingService) {
h := NewPageHandler(dataDir) h := NewPageHandler(dataDir, settingService)
// Authenticated page content (JWT required + visibility check)
pages := v1.Group("/pages") pages := v1.Group("/pages")
pages.Use(jwtAuth)
{ {
pages.GET("", h.ListPages)
pages.GET("/:slug", h.GetPageContent) pages.GET("/:slug", h.GetPageContent)
pages.GET("/:slug/images/*filename", h.ServePageImage) }
// Images: no JWT (browser img tags can't carry tokens), visibility check in handler
pageImages := v1.Group("/pages")
{
pageImages.GET("/:slug/images/*filename", h.ServePageImage)
}
// Admin-only: list all available pages
adminPages := v1.Group("/pages")
adminPages.Use(adminAuth)
{
adminPages.GET("", h.ListPages)
} }
} }

View File

@ -113,5 +113,5 @@ func registerRoutes(
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService) routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService)
handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir) handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService)
} }

View File

@ -1534,6 +1534,15 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
return value == "true" return value == "true"
} }
// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting.
func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems)
if err != nil {
return "[]"
}
return value
}
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关) // IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool { func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled) value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)

View File

@ -202,7 +202,9 @@ async function fetchAndRenderMarkdown(slug: string) {
tocItems.value = [] tocItems.value = []
activeHeadingId.value = '' activeHeadingId.value = ''
try { try {
const resp = await fetch(`/api/v1/pages/${encodeURIComponent(slug)}`) const resp = await fetch(`/api/v1/pages/${encodeURIComponent(slug)}`, {
headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {},
})
if (!resp.ok) { if (!resp.ok) {
renderedHtml.value = '<p class="text-red-500">Page not found</p>' renderedHtml.value = '<p class="text-red-500">Page not found</p>'
return return