pico

created pr with 106.1 on 2026-02-17T01:20:13Z · by c8ef7d19
cmds
checkout latest patchset:
ssh pr.pico.sh print 106 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print 106.[rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 106

Patchset 106.1 on 2026-02-17T01:20:13Z · commit 036a911

fix(pgs): prevent infinite redirects
Eric Bower 2026-02-17T01:15:23Z
Now when we perform a redirect for a user, we add a special header
`X-Pgs-Redirect-Depth` that contains the number of times a request
has been redirected.  If that number reaches 5 then we send a status 508
Loop Detected.
Semantic diff summary
1 added, 2 modified, 0 signature changed, 0 removed across 2 analyzed files
+24 -0 pkg/apps/pgs/web_asset_handler.go #
......@@ -18,6 +18,11 @@ import (
1818 "github.com/picosh/pico/pkg/shared/storage"
1919 )
2020
21+const (
22+ redirectDepthHeader = "X-Pgs-Redirect-Depth"
23+ maxRedirectDepth = 5
24+)
25+
2126 type ApiAssetHandler struct {
2227 *WebRouter
2328 Logger *slog.Logger
......@@ -100,6 +105,24 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
100105 destUrl.RawQuery = r.URL.RawQuery
101106
102107 if checkIsRedirect(fp.Status) {
108+ // Check for redirect loops
109+ redirectDepth := 0
110+ if depthStr := r.Header.Get(redirectDepthHeader); depthStr != "" {
111+ if d, err := strconv.Atoi(depthStr); err == nil {
112+ redirectDepth = d
113+ }
114+ }
115+
116+ if redirectDepth > maxRedirectDepth {
117+ logger.Error(
118+ "redirect loop detected",
119+ "depth", redirectDepth,
120+ "destination", destUrl.String(),
121+ )
122+ http.Error(w, "Too many redirects", http.StatusLoopDetected)
123+ return
124+ }
125+
103126 // hack: check to see if there's an index file in the requested directory
104127 // before redirecting, this saves a hop that will just end up a 404
105128 if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
......@@ -115,6 +138,7 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
115138 "destination", destUrl.String(),
116139 "status", fp.Status,
117140 )
141+ w.Header().Set(redirectDepthHeader, strconv.Itoa(redirectDepth+1))
118142 http.Redirect(w, r, destUrl.String(), fp.Status)
119143 return
120144 } else if hasProtocol(fp.Filepath) {
+89 -0 pkg/apps/pgs/web_test.go #
......@@ -572,3 +572,92 @@ func TestImageManipulation(t *testing.T) {
572572 })
573573 }
574574 }
575+
576+func TestRedirectLoopDetection(t *testing.T) {
577+ logger := slog.Default()
578+ dbpool := NewPgsDb(logger)
579+ bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
580+
581+ tt := []struct {
582+ name string
583+ path string
584+ redirectDepth string
585+ status int
586+ maxDepth int
587+ shouldContain string
588+ }{
589+ {
590+ name: "no-redirect-depth-header-on-first-request",
591+ path: "/anything",
592+ redirectDepth: "",
593+ status: http.StatusMovedPermanently,
594+ maxDepth: 5,
595+ shouldContain: `<a href="https://example.com">Moved Permanently</a>.`,
596+ },
597+ {
598+ name: "allow-request-at-max-depth",
599+ path: "/anything",
600+ redirectDepth: "5",
601+ status: http.StatusMovedPermanently,
602+ maxDepth: 5,
603+ shouldContain: `<a href="https://example.com">Moved Permanently</a>.`,
604+ },
605+ {
606+ name: "allow-request-below-max-depth",
607+ path: "/anything",
608+ redirectDepth: "2",
609+ status: http.StatusMovedPermanently,
610+ maxDepth: 5,
611+ shouldContain: `<a href="https://example.com">Moved Permanently</a>.`,
612+ },
613+ {
614+ name: "reject-at-depth-6-with-maxdepth-5",
615+ path: "/anything",
616+ redirectDepth: "6",
617+ status: http.StatusLoopDetected,
618+ maxDepth: 5,
619+ shouldContain: "Too many redirects",
620+ },
621+ }
622+
623+ for _, tc := range tt {
624+ t.Run(tc.name, func(t *testing.T) {
625+ request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
626+ if tc.redirectDepth != "" {
627+ request.Header.Set("X-Pgs-Redirect-Depth", tc.redirectDepth)
628+ }
629+ responseRecorder := httptest.NewRecorder()
630+
631+ st, _ := storage.NewStorageMemory(map[string]map[string]string{
632+ bucketName: {
633+ "/test/_redirects": "/anything https://example.com 301",
634+ },
635+ })
636+ pubsub := NewPubsubChan()
637+ defer func() {
638+ _ = pubsub.Close()
639+ }()
640+ cfg := NewPgsConfig(logger, dbpool, st, pubsub)
641+ cfg.Domain = "pgs.test"
642+ router := NewWebRouter(cfg)
643+ router.ServeHTTP(responseRecorder, request)
644+
645+ if responseRecorder.Code != tc.status {
646+ t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
647+ }
648+
649+ body := strings.TrimSpace(responseRecorder.Body.String())
650+ if !strings.Contains(body, tc.shouldContain) {
651+ t.Errorf("Want body to contain '%s', got '%s'", tc.shouldContain, body)
652+ }
653+
654+ // When redirecting, verify the header is incremented for next hop
655+ if tc.status == http.StatusMovedPermanently {
656+ nextDepth := responseRecorder.Header().Get("X-Pgs-Redirect-Depth")
657+ if nextDepth == "" {
658+ t.Error("Expected X-Pgs-Redirect-Depth header in redirect response")
659+ }
660+ }
661+ })
662+ }
663+}
Back to top