From 0729fc6dbb7344f97803623edd61d898420dd1bc Mon Sep 17 00:00:00 2001 From: Yuri Khachaturyan Date: Sat, 24 Oct 2026 07:08:02 +0300 Subject: [PATCH] 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. --- internal/sub/controller.go | 18 +++++++----------- internal/sub/controller_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 79b3a5b11..e495bcb19 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -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) + `;`) out := bytes.Replace(body, []byte(""), 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") diff --git a/internal/sub/controller_test.go b/internal/sub/controller_test.go index 8d5735187..fb119c8a9 100644 --- a/internal/sub/controller_test.go +++ b/internal/sub/controller_test.go @@ -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) + } + }) + } +}