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)