From a0485eac30e36b0651651354774d74e42a00a5d0 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Fri, 20 Mar 2026 15:49:56 +1100 Subject: [PATCH] Reject requests targeting a cachew playpen when not running as one Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019d0345-d564-743f-ad25-f1063ade61da --- .bk.yaml | 1 + cmd/cachewd/main.go | 3 ++ internal/httputil/playpen_guard.go | 31 +++++++++++++++++++ internal/httputil/playpen_guard_test.go | 41 +++++++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 .bk.yaml create mode 100644 internal/httputil/playpen_guard.go create mode 100644 internal/httputil/playpen_guard_test.go diff --git a/.bk.yaml b/.bk.yaml new file mode 100644 index 0000000..813fb3c --- /dev/null +++ b/.bk.yaml @@ -0,0 +1 @@ +selected_org: cash diff --git a/cmd/cachewd/main.go b/cmd/cachewd/main.go index 26d8625..9f91584 100644 --- a/cmd/cachewd/main.go +++ b/cmd/cachewd/main.go @@ -214,6 +214,9 @@ func newServer(ctx context.Context, muxHandler http.Handler, bind string, metric )(handler) handler = httputil.LoggingMiddleware(handler) + if os.Getenv("IS_PLAYPEN") != "true" { + handler = httputil.PlaypenGuardMiddleware(handler) + } logger := logging.FromContext(ctx) return &http.Server{ diff --git a/internal/httputil/playpen_guard.go b/internal/httputil/playpen_guard.go new file mode 100644 index 0000000..2456a6f --- /dev/null +++ b/internal/httputil/playpen_guard.go @@ -0,0 +1,31 @@ +package httputil + +import ( + "net/http" + "strings" +) + +// PlaypenGuardMiddleware rejects requests that target a playpen instance +// (via the Baggage header) when this instance is not a playpen. +// This prevents requests from silently falling through to staging. +func PlaypenGuardMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if baggage := r.Header.Get("Baggage"); hasCachewPlaypenKey(baggage) { + http.Error(w, "no matching cachew playpen found — start one with: sq playpen sync", http.StatusServiceUnavailable) + return + } + next.ServeHTTP(w, r) + }) +} + +func hasCachewPlaypenKey(baggage string) bool { + if baggage == "" { + return false + } + for entry := range strings.SplitSeq(baggage, ",") { + if strings.HasPrefix(strings.TrimSpace(entry), "cachew-playpen=") { + return true + } + } + return false +} diff --git a/internal/httputil/playpen_guard_test.go b/internal/httputil/playpen_guard_test.go new file mode 100644 index 0000000..c90576d --- /dev/null +++ b/internal/httputil/playpen_guard_test.go @@ -0,0 +1,41 @@ +package httputil_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/internal/httputil" +) + +func TestPlaypenGuardMiddleware(t *testing.T) { + ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + guard := httputil.PlaypenGuardMiddleware(ok) + + t.Run("allows normal requests", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + guard.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("blocks requests with cachew playpen baggage", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Baggage", "cachew-playpen=jrobotham") + rr := httptest.NewRecorder() + guard.ServeHTTP(rr, req) + assert.Equal(t, http.StatusServiceUnavailable, rr.Code) + }) + + t.Run("allows requests with unrelated baggage", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Baggage", "blox-playpen=jrobotham") + rr := httptest.NewRecorder() + guard.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) +}