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
......@@ -616,6 +616,7 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
616616 Bucket: bucket,
617617 ImgProcessOpts: opts,
618618 HasPicoPlus: hasPicoPlus,
619+ HttpPass: project.Acl.Type == "http-pass",
619620 }
620621
621622 asset.ServeHTTP(w, r)
+12, -0
......@@ -30,6 +30,7 @@ type ApiAssetHandler struct {
3030 ImgProcessOpts *storage.ImgProcessOpts
3131 ProjectID string
3232 HasPicoPlus bool
33+ HttpPass bool
3334 }
3435
3536 func hasProtocol(url string) bool {
......@@ -289,6 +290,17 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
289290 w.Header().Add(hdr.Name, hdr.Value)
290291 }
291292 }
293+
294+ // Password-protected (http-pass) projects must never be stored in the
295+ // shared cache. Our cache keys on subdomain+method+uri with no auth
296+ // component, so a single authenticated request would populate the cache
297+ // and let subsequent unauthenticated visitors bypass the password gate
298+ // entirely. Force the response to be non-cacheable, overriding any
299+ // user-supplied _headers cache-control.
300+ if h.HttpPass {
301+ w.Header().Set("cache-control", "private, no-store")
302+ }
303+
292304 if w.Header().Get("content-type") == "" {
293305 w.Header().Set("content-type", contentType)
294306 }
+67, -0
......@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
13+ "github.com/picosh/pico/pkg/db"
1314 "github.com/picosh/pico/pkg/send/utils"
1415 "github.com/picosh/pico/pkg/shared"
1516 "github.com/picosh/pico/pkg/shared/mime"
......@@ -474,6 +475,72 @@ func TestApiBasic(t *testing.T) {
474475 }
475476 }
476477
478+// TestApiHttpPassNotCached verifies that responses for password-protected
479+// (http-pass) projects are served with `cache-control: private, no-store` so
480+// the shared cache never stores them. Without this, the first authenticated
481+// request would populate the cache and let later unauthenticated visitors
482+// bypass the password gate. The asset here ships a `_headers` file that tries
483+// to mark the response aggressively cacheable, to prove the http-pass override
484+// wins over user-supplied headers.
485+func TestApiHttpPassNotCached(t *testing.T) {
486+ logger := slog.Default()
487+ dbpool := NewPgsDb(logger)
488+ user := dbpool.Users[0]
489+ bucketName := shared.GetAssetBucketName(user.ID)
490+
491+ projectID, err := dbpool.InsertProject(user.ID, "secret", "secret")
492+ if err != nil {
493+ t.Fatal(err)
494+ }
495+ project, err := dbpool.FindProjectByName(user.ID, "secret")
496+ if err != nil {
497+ t.Fatal(err)
498+ }
499+ project.Acl = db.ProjectAcl{Type: "http-pass", Data: []string{"hunter2"}}
500+
501+ store := map[string]map[string]string{
502+ bucketName: {
503+ "/secret/index.html": "top secret!",
504+ "/secret/_headers": "/*\n\tcache-control: public, max-age=31536000, immutable",
505+ },
506+ }
507+
508+ memSt, err := storage.NewStorageMemory(store)
509+ if err != nil {
510+ t.Fatal(err)
511+ }
512+ st := newTestStorage(memSt)
513+ pubsub := NewPubsubChan()
514+ defer func() {
515+ _ = pubsub.Close()
516+ }()
517+ cfg := NewPgsConfig(logger, dbpool, st, pubsub)
518+ cfg.Domain = "pgs.test"
519+ router := NewWebRouter(cfg)
520+
521+ url := fmt.Sprintf("https://%s-secret.pgs.test/", user.Name)
522+ request := httptest.NewRequest("GET", url, strings.NewReader(""))
523+ // Supply a valid session cookie so we get past the password gate and
524+ // actually serve the protected asset (the path we need to not cache).
525+ request.AddCookie(&http.Cookie{
526+ Name: getCookieName("secret"),
527+ Value: projectID,
528+ })
529+ responseRecorder := httptest.NewRecorder()
530+ router.ServeHTTP(responseRecorder, request)
531+
532+ if responseRecorder.Code != http.StatusOK {
533+ t.Fatalf("Want status '%d', got '%d'", http.StatusOK, responseRecorder.Code)
534+ }
535+ if body := strings.TrimSpace(responseRecorder.Body.String()); body != "top secret!" {
536+ t.Fatalf("Want body 'top secret!', got '%s'", body)
537+ }
538+ cc := responseRecorder.Header().Get("cache-control")
539+ if cc != "private, no-store" {
540+ t.Errorf("http-pass response must be non-cacheable; want 'private, no-store', got '%s'", cc)
541+ }
542+}
543+
477544 func TestDirectoryListing(t *testing.T) {
478545 logger := slog.Default()
479546 dbpool := NewPgsDb(logger)