pico
created pr with
106.1
cmds
checkout latest patchset:
ssh pr.pico.sh print 106 | git am -3checkout any patchset in a patch request:
ssh pr.pico.sh print 106.[rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 106
Patchset
106.1
fix(pgs): prevent infinite redirects
Eric Bower
2026-02-17T01:15:23ZNow 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
#
| ... | ... | @@ -100,6 +105,24 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 100 | 105 | destUrl.RawQuery = r.URL.RawQuery | |
| 101 | 106 | ||
| 102 | 107 | 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 | + | ||
| 103 | 126 | // hack: check to see if there's an index file in the requested directory | |
| 104 | 127 | // before redirecting, this saves a hop that will just end up a 404 | |
| 105 | 128 | if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") { |
| ... | ... | @@ -115,6 +138,7 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 115 | 138 | "destination", destUrl.String(), | |
| 116 | 139 | "status", fp.Status, | |
| 117 | 140 | ) | |
| 141 | + | w.Header().Set(redirectDepthHeader, strconv.Itoa(redirectDepth+1)) | |
| 118 | 142 | http.Redirect(w, r, destUrl.String(), fp.Status) | |
| 119 | 143 | return | |
| 120 | 144 | } else if hasProtocol(fp.Filepath) { |
+89
-0
pkg/apps/pgs/web_test.go
#
| ... | ... | @@ -572,3 +572,92 @@ func TestImageManipulation(t *testing.T) { | |
| 572 | 572 | }) | |
| 573 | 573 | } | |
| 574 | 574 | } | |
| 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 | + | } |