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 07:08:02 +03: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 // initRouter registers HTTP routes for subscription links and JSON endpoints
// on the provided router group. // on the provided router group.
func (a *SUBController) initRouter(g *gin.RouterGroup) { func (a *SUBController) initRouter(g *gin.RouterGroup) {
gLink := g.Group(a.subPath) gLink := g.Group(a.subPath, setNoCacheHeaders)
gLink.GET(":subid", a.subs) gLink.GET(":subid", a.subs)
gLink.HEAD(":subid", a.subs) gLink.HEAD(":subid", a.subs)
gLink.GET(":subid/hwid-status", a.hwidStatus) gLink.GET(":subid/hwid-status", a.hwidStatus)
gLink.HEAD(":subid/hwid-status", a.hwidStatus) gLink.HEAD(":subid/hwid-status", a.hwidStatus)
if a.jsonEnabled { if a.jsonEnabled {
gJson := g.Group(a.subJsonPath) gJson := g.Group(a.subJsonPath, setNoCacheHeaders)
gJson.GET(":subid", a.subJsons) gJson.GET(":subid", a.subJsons)
gJson.HEAD(":subid", a.subJsons) gJson.HEAD(":subid", a.subJsons)
} }
if a.clashEnabled { if a.clashEnabled {
gClash := g.Group(a.subClashPath) gClash := g.Group(a.subClashPath, setNoCacheHeaders)
gClash.GET(":subid", a.subClashs) gClash.GET(":subid", a.subClashs)
gClash.HEAD(":subid", a.subClashs) gClash.HEAD(":subid", a.subClashs)
if sameSubscriptionPath(a.subClashPath, subMihomoPath) { if sameSubscriptionPath(a.subClashPath, subMihomoPath) {
@@ -354,14 +354,14 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
} else if owner := a.configuredSubscriptionPathOwner(subMihomoPath); owner != "" { } 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) logger.Warningf("Mihomo subscription alias %q is unavailable because it conflicts with the configured %s path", subMihomoPath, owner)
} else { } else {
gMihomo := g.Group(subMihomoPath) gMihomo := g.Group(subMihomoPath, setNoCacheHeaders)
gMihomo.GET(":subid", a.subClashs) gMihomo.GET(":subid", a.subClashs)
gMihomo.HEAD(":subid", a.subClashs) gMihomo.HEAD(":subid", a.subClashs)
} }
if owner := a.configuredSubscriptionPathOwner(subClashLegacyPath); owner != "" { 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) logger.Warningf("Legacy Clash subscription alias %q is unavailable because it conflicts with the configured %s path", subClashLegacyPath, owner)
} else { } else {
gLegacy := g.Group(subClashLegacyPath) gLegacy := g.Group(subClashLegacyPath, setNoCacheHeaders)
gLegacy.GET(":subid", a.subClashLegacy) gLegacy.GET(":subid", a.subClashLegacy)
gLegacy.HEAD(":subid", a.subClashLegacy) gLegacy.HEAD(":subid", a.subClashLegacy)
} }
@@ -416,7 +416,6 @@ func (a *SUBController) maybeServeSubInfo(c *gin.Context) bool {
info := a.subPageContext(page) info := a.subPageContext(page)
delete(info, "links") delete(info, "links")
info["emails"] = dedupeEmails(page.Emails) info["emails"] = dedupeEmails(page.Emails)
setNoCacheHeaders(c)
c.JSON(http.StatusOK, info) c.JSON(http.StatusOK, info)
return true 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 { if execErr := tmpl.Execute(&buf, subData); execErr != nil {
logger.Error("sub: custom template execution failed, using default page:", execErr) logger.Error("sub: custom template execution failed, using default page:", execErr)
} else { } else {
setNoCacheHeaders(c)
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
return return
} }
@@ -663,7 +661,6 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`) `window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
out := bytes.Replace(body, []byte("</head>"), inject, 1) out := bytes.Replace(body, []byte("</head>"), inject, 1)
setNoCacheHeaders(c)
c.Data(http.StatusOK, "text/html; charset=utf-8", out) c.Data(http.StatusOK, "text/html; charset=utf-8", out)
} }
@@ -750,12 +747,11 @@ func (a *SUBController) hwidStatus(c *gin.Context) {
writeSubError(c, err) writeSubError(c, err)
return return
} }
setNoCacheHeaders(c)
c.JSON(http.StatusOK, status) c.JSON(http.StatusOK, status)
} }
// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN // setNoCacheHeaders runs on every subscription route group: each one serves a per-subscriber body
// clients and browsers always fetch fresh traffic/expiry data. // with live traffic, expiry and routing headers that no cache may keep.
func setNoCacheHeaders(c *gin.Context) { func setNoCacheHeaders(c *gin.Context) {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate") c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache") 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)
}
})
}
}