fix(sub): send no-store on every subscription route (#6862)

The raw, JSON and Clash bodies carry live traffic, expiry and routing headers
but went out without Cache-Control, so any cache in front of the sub server
could serve stale data, such as old routing rules after an admin edit. The rule
lived in scattered per-branch calls that new branches missed. setNoCacheHeaders
now runs as middleware on the sub, JSON, Clash, Mihomo and legacy Clash route
groups, covering every current and future response of those per-subscriber
routes.
This commit is contained in:
Yuri Khachaturyan
2026-10-24 06:08:02 +02:00
committed by GitHub
parent f43645656c
commit 0729fc6dbb
2 changed files with 35 additions and 11 deletions
+7 -11
View File
@@ -335,18 +335,18 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
// initRouter registers HTTP routes for subscription links and JSON endpoints
// on the provided router group.
func (a *SUBController) initRouter(g *gin.RouterGroup) {
gLink := g.Group(a.subPath)
gLink := g.Group(a.subPath, setNoCacheHeaders)
gLink.GET(":subid", a.subs)
gLink.HEAD(":subid", a.subs)
gLink.GET(":subid/hwid-status", a.hwidStatus)
gLink.HEAD(":subid/hwid-status", a.hwidStatus)
if a.jsonEnabled {
gJson := g.Group(a.subJsonPath)
gJson := g.Group(a.subJsonPath, setNoCacheHeaders)
gJson.GET(":subid", a.subJsons)
gJson.HEAD(":subid", a.subJsons)
}
if a.clashEnabled {
gClash := g.Group(a.subClashPath)
gClash := g.Group(a.subClashPath, setNoCacheHeaders)
gClash.GET(":subid", a.subClashs)
gClash.HEAD(":subid", a.subClashs)
if sameSubscriptionPath(a.subClashPath, subMihomoPath) {
@@ -354,14 +354,14 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
} else if owner := a.configuredSubscriptionPathOwner(subMihomoPath); owner != "" {
logger.Warningf("Mihomo subscription alias %q is unavailable because it conflicts with the configured %s path", subMihomoPath, owner)
} else {
gMihomo := g.Group(subMihomoPath)
gMihomo := g.Group(subMihomoPath, setNoCacheHeaders)
gMihomo.GET(":subid", a.subClashs)
gMihomo.HEAD(":subid", a.subClashs)
}
if owner := a.configuredSubscriptionPathOwner(subClashLegacyPath); owner != "" {
logger.Warningf("Legacy Clash subscription alias %q is unavailable because it conflicts with the configured %s path", subClashLegacyPath, owner)
} else {
gLegacy := g.Group(subClashLegacyPath)
gLegacy := g.Group(subClashLegacyPath, setNoCacheHeaders)
gLegacy.GET(":subid", a.subClashLegacy)
gLegacy.HEAD(":subid", a.subClashLegacy)
}
@@ -416,7 +416,6 @@ func (a *SUBController) maybeServeSubInfo(c *gin.Context) bool {
info := a.subPageContext(page)
delete(info, "links")
info["emails"] = dedupeEmails(page.Emails)
setNoCacheHeaders(c)
c.JSON(http.StatusOK, info)
return true
}
@@ -634,7 +633,6 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
if execErr := tmpl.Execute(&buf, subData); execErr != nil {
logger.Error("sub: custom template execution failed, using default page:", execErr)
} else {
setNoCacheHeaders(c)
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
return
}
@@ -663,7 +661,6 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
setNoCacheHeaders(c)
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
}
@@ -750,12 +747,11 @@ func (a *SUBController) hwidStatus(c *gin.Context) {
writeSubError(c, err)
return
}
setNoCacheHeaders(c)
c.JSON(http.StatusOK, status)
}
// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
// clients and browsers always fetch fresh traffic/expiry data.
// setNoCacheHeaders runs on every subscription route group: each one serves a per-subscriber body
// with live traffic, expiry and routing headers that no cache may keep.
func setNoCacheHeaders(c *gin.Context) {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
+28
View File
@@ -857,3 +857,31 @@ func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) {
})
}
}
// Each route group serves live per-subscriber data; a group registered without the no-store
// middleware lets a cache in front of the sub server hand out stale traffic and routing.
func TestSubscriptionRouteGroupsAreNotCacheable(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "cache", 4482, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
oldDistFS := distFS
distFS = testDistFS
t.Cleanup(func() { distFS = oldDistFS })
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
for _, path := range []string{"/sub/s1", "/json/s1", "/clash/s1", "/mihomo/s1", "/clash-legacy/s1"} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
req.Header.Set("User-Agent", "curl/8.0")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code == http.StatusNotFound {
t.Fatalf("route %s is not registered", path)
}
if cc := resp.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Fatalf("Cache-Control = %q (status %d), want a no-store directive", cc, resp.Code)
}
})
}
}