Commit 668e3cf
Silvio Tomatis
·
2026-06-11 10:24:13 -0400 EDT
parent e848548
fix(pgs): never cache http-pass responses Password-protected (http-pass) projects could be read without the password. The shared cache keys entries on subdomain+method+uri with no auth component, and only declines to store responses marked private or no-store. http-pass assets were served as 200 with `cache-control: max-age=60, s-maxage=600, must-revalidate`, so the first authenticated request populated the cache and every subsequent unauthenticated visitor got a cache hit that bypassed the password gate for the duration of the TTL. Mark http-pass asset responses `private, no-store` so the shared cache refuses to store them. The override is applied after the user `_headers` are merged, so a project's own cache-control cannot re-enable caching of protected content. Adds a regression test asserting an http-pass response is served non-cacheable even when a `_headers` file requests aggressive caching.
3 files changed,
+80,
-0
+1,
-0
1@@ -616,6 +616,7 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
2 Bucket: bucket,
3 ImgProcessOpts: opts,
4 HasPicoPlus: hasPicoPlus,
5+ HttpPass: project.Acl.Type == "http-pass",
6 }
7
8 asset.ServeHTTP(w, r)
+12,
-0
1@@ -30,6 +30,7 @@ type ApiAssetHandler struct {
2 ImgProcessOpts *storage.ImgProcessOpts
3 ProjectID string
4 HasPicoPlus bool
5+ HttpPass bool
6 }
7
8 func hasProtocol(url string) bool {
9@@ -289,6 +290,17 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
10 w.Header().Add(hdr.Name, hdr.Value)
11 }
12 }
13+
14+ // Password-protected (http-pass) projects must never be stored in the
15+ // shared cache. Our cache keys on subdomain+method+uri with no auth
16+ // component, so a single authenticated request would populate the cache
17+ // and let subsequent unauthenticated visitors bypass the password gate
18+ // entirely. Force the response to be non-cacheable, overriding any
19+ // user-supplied _headers cache-control.
20+ if h.HttpPass {
21+ w.Header().Set("cache-control", "private, no-store")
22+ }
23+
24 if w.Header().Get("content-type") == "" {
25 w.Header().Set("content-type", contentType)
26 }
+67,
-0
1@@ -10,6 +10,7 @@ import (
2 "time"
3
4 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
5+ "github.com/picosh/pico/pkg/db"
6 "github.com/picosh/pico/pkg/send/utils"
7 "github.com/picosh/pico/pkg/shared"
8 "github.com/picosh/pico/pkg/shared/mime"
9@@ -474,6 +475,72 @@ func TestApiBasic(t *testing.T) {
10 }
11 }
12
13+// TestApiHttpPassNotCached verifies that responses for password-protected
14+// (http-pass) projects are served with `cache-control: private, no-store` so
15+// the shared cache never stores them. Without this, the first authenticated
16+// request would populate the cache and let later unauthenticated visitors
17+// bypass the password gate. The asset here ships a `_headers` file that tries
18+// to mark the response aggressively cacheable, to prove the http-pass override
19+// wins over user-supplied headers.
20+func TestApiHttpPassNotCached(t *testing.T) {
21+ logger := slog.Default()
22+ dbpool := NewPgsDb(logger)
23+ user := dbpool.Users[0]
24+ bucketName := shared.GetAssetBucketName(user.ID)
25+
26+ projectID, err := dbpool.InsertProject(user.ID, "secret", "secret")
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ project, err := dbpool.FindProjectByName(user.ID, "secret")
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ project.Acl = db.ProjectAcl{Type: "http-pass", Data: []string{"hunter2"}}
35+
36+ store := map[string]map[string]string{
37+ bucketName: {
38+ "/secret/index.html": "top secret!",
39+ "/secret/_headers": "/*\n\tcache-control: public, max-age=31536000, immutable",
40+ },
41+ }
42+
43+ memSt, err := storage.NewStorageMemory(store)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ st := newTestStorage(memSt)
48+ pubsub := NewPubsubChan()
49+ defer func() {
50+ _ = pubsub.Close()
51+ }()
52+ cfg := NewPgsConfig(logger, dbpool, st, pubsub)
53+ cfg.Domain = "pgs.test"
54+ router := NewWebRouter(cfg)
55+
56+ url := fmt.Sprintf("https://%s-secret.pgs.test/", user.Name)
57+ request := httptest.NewRequest("GET", url, strings.NewReader(""))
58+ // Supply a valid session cookie so we get past the password gate and
59+ // actually serve the protected asset (the path we need to not cache).
60+ request.AddCookie(&http.Cookie{
61+ Name: getCookieName("secret"),
62+ Value: projectID,
63+ })
64+ responseRecorder := httptest.NewRecorder()
65+ router.ServeHTTP(responseRecorder, request)
66+
67+ if responseRecorder.Code != http.StatusOK {
68+ t.Fatalf("Want status '%d', got '%d'", http.StatusOK, responseRecorder.Code)
69+ }
70+ if body := strings.TrimSpace(responseRecorder.Body.String()); body != "top secret!" {
71+ t.Fatalf("Want body 'top secret!', got '%s'", body)
72+ }
73+ cc := responseRecorder.Header().Get("cache-control")
74+ if cc != "private, no-store" {
75+ t.Errorf("http-pass response must be non-cacheable; want 'private, no-store', got '%s'", cc)
76+ }
77+}
78+
79 func TestDirectoryListing(t *testing.T) {
80 logger := slog.Default()
81 dbpool := NewPgsDb(logger)