diff --git a/tide/normalize.go b/tide/normalize.go index dbfee0b..c4e8edb 100644 --- a/tide/normalize.go +++ b/tide/normalize.go @@ -117,7 +117,14 @@ func isIDKey(key string) bool { if key == "id" { return true } - return strings.HasSuffix(key, "_id") && !strings.HasSuffix(key, "_at") + if strings.HasSuffix(key, "_at") { + return false + } + // "_ids" covers plural raw-integer-array fields such as collection_ids: + // each array element still reaches maskLeaf individually (maskValue + // recurses into []any before calling maskLeaf), so this masks every + // element the same way a singular "_id" scalar would be masked. + return strings.HasSuffix(key, "_id") || strings.HasSuffix(key, "_ids") } func lastPathKey(path string) string { diff --git a/wristband/authorize.go b/wristband/authorize.go index cab9092..ffbabbf 100644 --- a/wristband/authorize.go +++ b/wristband/authorize.go @@ -164,16 +164,19 @@ func (s *Server) Authorize(w http.ResponseWriter, r *http.Request) { } spa := s.opts.Issuer + "/connect?request=" + rfc3986Escape(requestID) - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Location", spa) - w.WriteHeader(http.StatusFound) + // Laravel appends ", private" to every explicit Cache-Control this + // endpoint sets (session-cookie default merge); recorded PHP traffic is + // "no-store, private", never a bare "no-store" (D-04, live-recorded byte + // contract, 08-09-PLAN.md Task 2). + w.Header().Set("Cache-Control", "no-store, private") + writeRedirectHTML(w, http.StatusFound, spa) } // writeAuthorizeLocalError writes the PHP localError() response: a bare // text/plain 400 with no Location and no house envelope // (T-08-OPEN-REDIRECT). func writeAuthorizeLocalError(w http.ResponseWriter, message string) { - w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Cache-Control", "no-store, private") w.Header().Set("Content-Type", "text/plain; charset=UTF-8") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(message)) @@ -191,9 +194,8 @@ func (s *Server) authorizeErrorRedirect(w http.ResponseWriter, redirectURI, errC if state != nil { pairs = append(pairs, [2]string{"state", *state}) } - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Location", appendOrderedQuery(redirectURI, pairs)) - w.WriteHeader(http.StatusFound) + w.Header().Set("Cache-Control", "no-store, private") + writeRedirectHTML(w, http.StatusFound, appendOrderedQuery(redirectURI, pairs)) } // parseAuthorizeScopes ports OAuthAuthorizeController::parseScopes. An diff --git a/wristband/authorize_test.go b/wristband/authorize_test.go index 638c26d..614c772 100644 --- a/wristband/authorize_test.go +++ b/wristband/authorize_test.go @@ -113,8 +113,8 @@ func TestPhase8RedAuthorize(t *testing.T) { if _, has := q["code"]; has { t.Fatalf("PHASE8_RED:authorize: Location %q leaks a code onto our own redirect", loc) } - if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { - t.Fatalf("PHASE8_RED:authorize: Cache-Control = %q, want \"no-store\"", cc) + if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" { + t.Fatalf("PHASE8_RED:authorize: Cache-Control = %q, want \"no-store, private\"", cc) } } @@ -152,8 +152,8 @@ func TestAuthorizeUnknownClientReturnsLocal400NoLocation(t *testing.T) { if body := rec.Body.String(); body != "Unknown client." { t.Fatalf("body = %q, want %q", body, "Unknown client.") } - if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { - t.Fatalf("Cache-Control = %q, want no-store", cc) + if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" { + t.Fatalf("Cache-Control = %q, want no-store, private", cc) } } @@ -278,8 +278,8 @@ func TestPKCEChallengeMethodMustBeS256(t *testing.T) { if q["iss"] != "https://plytarium.com" { t.Fatalf("iss = %q, want https://plytarium.com", q["iss"]) } - if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { - t.Fatalf("Cache-Control = %q, want no-store", cc) + if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" { + t.Fatalf("Cache-Control = %q, want no-store, private", cc) } } @@ -358,8 +358,8 @@ func TestAuthorizeValidRequestRedirectsToConnectWithOpaqueHandleOnly(t *testing. if _, has := q["client_secret"]; has { t.Fatal("Location leaks client_secret") } - if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { - t.Fatalf("Cache-Control = %q, want no-store", cc) + if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" { + t.Fatalf("Cache-Control = %q, want no-store, private", cc) } backend.mu.Lock() diff --git a/wristband/redirect_html.go b/wristband/redirect_html.go new file mode 100644 index 0000000..f634eee --- /dev/null +++ b/wristband/redirect_html.go @@ -0,0 +1,60 @@ +package wristband + +import ( + "net/http" + "strings" +) + +// htmlEscapePHP ports PHP's htmlspecialchars($s, ENT_QUOTES, 'UTF-8') byte +// for byte: Go's stdlib html.EscapeString differs on the quote entities +// ('/" vs PHP's '/"), which would diverge from the +// recorded redirect body whenever a redirect_uri or state value contains a +// quote character. +func htmlEscapePHP(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '&': + b.WriteString("&") + case '"': + b.WriteString(""") + case '\'': + b.WriteString("'") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + default: + b.WriteRune(r) + } + } + return b.String() +} + +// writeRedirectHTML ports Laravel/Symfony's RedirectResponse default HTML +// body byte-for-byte (T-08-OPEN-REDIRECT/D-04). Go's net/http never emits a +// body for a 3xx Location redirect; every wristband redirect needs this +// exact body plus Content-Type because real recorded PHP traffic includes +// it, and an unchanged browser-based client (the Nuxt /connect handoff) +// observes it. Cache-Control is the caller's responsibility -- callers set +// it before invoking this helper because its value differs between the +// authorize success path and error redirects versus other endpoints. +func writeRedirectHTML(w http.ResponseWriter, status int, target string) { + escaped := htmlEscapePHP(target) + var b strings.Builder + b.WriteString("\n\n
\n \n \n\n