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 | |
| 616 | 616 | Bucket: bucket, | |
| 617 | 617 | ImgProcessOpts: opts, | |
| 618 | 618 | HasPicoPlus: hasPicoPlus, | |
| 619 | + | HttpPass: project.Acl.Type == "http-pass", | |
| 619 | 620 | } | |
| 620 | 621 | ||
| 621 | 622 | asset.ServeHTTP(w, r) |
+12,
-0
| ... | ... | @@ -30,6 +30,7 @@ type ApiAssetHandler struct { | |
| 30 | 30 | ImgProcessOpts *storage.ImgProcessOpts | |
| 31 | 31 | ProjectID string | |
| 32 | 32 | HasPicoPlus bool | |
| 33 | + | HttpPass bool | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | func hasProtocol(url string) bool { |
| ... | ... | @@ -289,6 +290,17 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 289 | 290 | w.Header().Add(hdr.Name, hdr.Value) | |
| 290 | 291 | } | |
| 291 | 292 | } | |
| 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 | + | ||
| 292 | 304 | if w.Header().Get("content-type") == "" { | |
| 293 | 305 | w.Header().Set("content-type", contentType) | |
| 294 | 306 | } |
+67,
-0
| ... | ... | @@ -10,6 +10,7 @@ import ( | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | pgsdb "github.com/picosh/pico/pkg/apps/pgs/db" | |
| 13 | + | "github.com/picosh/pico/pkg/db" | |
| 13 | 14 | "github.com/picosh/pico/pkg/send/utils" | |
| 14 | 15 | "github.com/picosh/pico/pkg/shared" | |
| 15 | 16 | "github.com/picosh/pico/pkg/shared/mime" |
| ... | ... | @@ -474,6 +475,72 @@ func TestApiBasic(t *testing.T) { | |
| 474 | 475 | } | |
| 475 | 476 | } | |
| 476 | 477 | ||
| 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 | + | ||
| 477 | 544 | func TestDirectoryListing(t *testing.T) { | |
| 478 | 545 | logger := slog.Default() | |
| 479 | 546 | dbpool := NewPgsDb(logger) |