From 0fd0320482a41d9e6d20cf239ff666dc25748f65 Mon Sep 17 00:00:00 2001 From: hailey Date: Mon, 24 Aug 2026 23:47:18 +0000 Subject: [PATCH 1/6] Accept nonce-bearing Space DPoP proofs --- space/auth_test.go | 17 +++++++++++++++++ space/dpop_auth.go | 27 ++++++++++++++++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/space/auth_test.go b/space/auth_test.go index 1d81d87..8fdb731 100644 --- a/space/auth_test.go +++ b/space/auth_test.go @@ -225,6 +225,23 @@ func TestDPoPProofChecksAndReplay(t *testing.T) { } } +func TestDPoPProofAcceptsOptionalNonce(t *testing.T) { + signer := testP256Signer(t) + proof, err := CreateDpopProof(signer, CreateDpopProofOptions{ + Htm: "POST", Htu: "https://host.example/xrpc/com.atproto.space.getSpaceCredential", + Nonce: "oauth-nonce", JTI: "dpop-with-nonce", Now: testClock(testAuthNow), + }) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyDpopProof(context.Background(), proof, VerifyDpopProofOptions{ + Htm: "POST", Htu: "https://host.example/xrpc/com.atproto.space.getSpaceCredential", + Now: testClock(testAuthNow), + }); err != nil { + t.Fatalf("nonce-bearing issuance proof rejected: %v", err) + } +} + func TestAtprotoES256KTokenAdapter(t *testing.T) { key, err := atcrypto.GeneratePrivateKeyK256() if err != nil { diff --git a/space/dpop_auth.go b/space/dpop_auth.go index b321fea..d4d0ced 100644 --- a/space/dpop_auth.go +++ b/space/dpop_auth.go @@ -41,6 +41,7 @@ type CreateDpopProofOptions struct { Credential string CredentialPresent bool JTI string + Nonce string Now func() time.Time Random io.Reader } @@ -101,6 +102,9 @@ func CreateDpopProof(signer DPoPSigner, opts CreateDpopProofOptions) (string, er return "", fmt.Errorf("invalid DPoP public JWK: %w", err) } claims := dpopClaims{JTI: opts.JTI, HTM: method, HTU: normalized, IAT: opts.Now().Unix()} + if opts.Nonce != "" { + claims.Nonce = &opts.Nonce + } if opts.CredentialPresent || opts.Credential != "" { if opts.Credential == "" { return "", errors.New("credential must not be empty") @@ -144,7 +148,9 @@ type VerifyDpopProofOptions struct { } // VerifyDpopProof checks signature, method, normalized URL, age, embedded-key -// thumbprint, and ath. Replay is consumed atomically after every other check. +// thumbprint, ath, and the optional nonce claim's type. Replay is consumed +// atomically after every other check. Space hosts may accept a nonce issued for +// another DPoP-protected endpoint without requiring one of their own. func VerifyDpopProof(ctx context.Context, raw string, opts VerifyDpopProofOptions) (DpopProof, error) { method := opts.Htm if method == "" { @@ -181,7 +187,7 @@ func VerifyDpopProof(ctx context.Context, raw string, opts VerifyDpopProofOption if err != nil { return DpopProof{}, authErr("BadDpopProof", "invalid DPoP header", err) } - cm, err := strictObject(cb, map[string]bool{"jti": true, "htm": true, "htu": true, "iat": true, "ath": true}) + cm, err := strictObject(cb, map[string]bool{"jti": true, "htm": true, "htu": true, "iat": true, "ath": true, "nonce": true}) if err != nil { return DpopProof{}, authErr("BadDpopProof", "invalid DPoP claims", err) } @@ -224,6 +230,12 @@ func VerifyDpopProof(ctx context.Context, raw string, opts VerifyDpopProofOption if err != nil || iat <= 0 { return DpopProof{}, authErr("BadDpopProof", "DPoP iat is required", err) } + if rawNonce, ok := cm["nonce"]; ok { + nonce, nonceErr := stringValue(rawNonce) + if nonceErr != nil || nonce == "" { + return DpopProof{}, authErr("BadDpopNonce", "invalid DPoP nonce", nonceErr) + } + } if opts.Now == nil { opts.Now = time.Now } @@ -378,11 +390,12 @@ type dpopHeader struct { JWK json.RawMessage `json:"jwk"` } type dpopClaims struct { - JTI string `json:"jti"` - HTM string `json:"htm"` - HTU string `json:"htu"` - Ath *string `json:"ath,omitempty"` - IAT int64 `json:"iat"` + JTI string `json:"jti"` + HTM string `json:"htm"` + HTU string `json:"htu"` + Ath *string `json:"ath,omitempty"` + Nonce *string `json:"nonce,omitempty"` + IAT int64 `json:"iat"` } type p256JWK struct { Kty string `json:"kty"` From d0d14352bf7e5890f4813a1999e78bc1fe18e90c Mon Sep 17 00:00:00 2001 From: hailey Date: Tue, 25 Aug 2026 00:00:27 +0000 Subject: [PATCH 2/6] Fix browser CORS for Space auth --- server/auth_principal_test.go | 35 +++++++++++++++++++++++++++++ server/server.go | 42 +++++++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/server/auth_principal_test.go b/server/auth_principal_test.go index a9044fb..02da39a 100644 --- a/server/auth_principal_test.go +++ b/server/auth_principal_test.go @@ -20,6 +20,7 @@ import ( "github.com/haileyok/cocoon/models" "github.com/haileyok/cocoon/space" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" ) type testDIDDocumentFetcher func(context.Context, string) (*cocoon_identity.DidDoc, error) @@ -645,6 +646,40 @@ func TestEnabledSpaceRoutesUseNativeAuthPolicies(t *testing.T) { } } +func TestCORSAllowsBrowserSpaceCredentialExchange(t *testing.T) { + e := echo.New() + e.Use(middleware.CORSWithConfig(cocoonCORSConfig())) + e.POST("/xrpc/com.atproto.space.getSpaceCredential", func(c echo.Context) error { + return c.NoContent(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodOptions, "/xrpc/com.atproto.space.getSpaceCredential", nil) + req.Header.Set(echo.HeaderOrigin, "https://pdsls.dev") + req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodPost) + req.Header.Set(echo.HeaderAccessControlRequestHeaders, "authorization,content-type,dpop") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d, want %d", rec.Code, http.StatusNoContent) + } + if got := rec.Header().Get(echo.HeaderAccessControlAllowOrigin); got != "https://pdsls.dev" { + t.Fatalf("allow-origin = %q, want reflected origin", got) + } + if got := rec.Header().Get(echo.HeaderAccessControlAllowCredentials); got != "true" { + t.Fatalf("allow-credentials = %q, want true", got) + } + allowHeaders := strings.ToLower(rec.Header().Get(echo.HeaderAccessControlAllowHeaders)) + for _, want := range []string{"authorization", "content-type", "dpop"} { + if !strings.Contains(allowHeaders, want) { + t.Fatalf("allow-headers = %q, missing %q", allowHeaders, want) + } + } + if got := rec.Header().Get(echo.HeaderAccessControlAllowMethods); !strings.Contains(got, http.MethodPost) { + t.Fatalf("allow-methods = %q, missing POST", got) + } +} + func mustDecodeSegment(t *testing.T, segment string) []byte { t.Helper() decoded, err := base64.RawURLEncoding.DecodeString(segment) diff --git a/server/server.go b/server/server.go index 416cb91..d05e3bf 100644 --- a/server/server.go +++ b/server/server.go @@ -252,6 +252,40 @@ func (h *filteredHandler) WithGroup(name string) slog.Handler { return &filteredHandler{level: h.level, handler: h.handler.WithGroup(name)} } +func cocoonCORSConfig() middleware.CORSConfig { + return middleware.CORSConfig{ + // Cocoon intentionally accepts requests from arbitrary web clients. Reflect + // the requested origin instead of emitting `*` alongside credentials, which + // browsers reject for credentialed CORS requests. + AllowOriginFunc: func(origin string) (bool, error) { + return origin != "", nil + }, + AllowHeaders: []string{ + echo.HeaderOrigin, + echo.HeaderAccept, + echo.HeaderContentType, + echo.HeaderAuthorization, + "DPoP", + "atproto-proxy", + }, + AllowMethods: []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodOptions, + }, + AllowCredentials: true, + ExposeHeaders: []string{ + "DPoP-Nonce", + "WWW-Authenticate", + }, + MaxAge: 100_000_000, + } +} + func New(args *Args) (*Server, error) { if args.Logger == nil { args.Logger = slog.Default() @@ -304,13 +338,7 @@ func New(args *Args) (*Server, error) { e.Pre(slogecho.New(args.Logger.With("component", "slogecho"))) e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret)))) e.Use(echoprometheus.NewMiddleware("cocoon")) - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{"*"}, - AllowHeaders: []string{"*"}, - AllowMethods: []string{"*"}, - AllowCredentials: true, - MaxAge: 100_000_000, - })) + e.Use(middleware.CORSWithConfig(cocoonCORSConfig())) vdtor := validator.New() vdtor.RegisterValidation("atproto-handle", func(fl validator.FieldLevel) bool { From e0729ee62d7e365c9797cf5d898353ac6fcb122a Mon Sep 17 00:00:00 2001 From: hailey Date: Tue, 25 Aug 2026 00:33:22 +0000 Subject: [PATCH 3/6] Fix proxy CORS and atcute DPoP proofs --- server/auth_principal_test.go | 35 +++++++++++++++++++++++++++ server/handle_proxy.go | 24 ++++++++++++++++--- space/auth_test.go | 45 +++++++++++++++++++++++++++++++++++ space/dpop_auth.go | 30 ++++++++++++++++++++++- 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/server/auth_principal_test.go b/server/auth_principal_test.go index 02da39a..fa0ffd0 100644 --- a/server/auth_principal_test.go +++ b/server/auth_principal_test.go @@ -680,6 +680,41 @@ func TestCORSAllowsBrowserSpaceCredentialExchange(t *testing.T) { } } +func TestProxyResponseHeadersPreserveCocoonCORS(t *testing.T) { + dst := make(http.Header) + dst.Set(echo.HeaderAccessControlAllowOrigin, "https://bsky.app") + dst.Set(echo.HeaderAccessControlAllowCredentials, "true") + dst.Set(echo.HeaderAccessControlExposeHeaders, "DPoP-Nonce,WWW-Authenticate") + dst.Set(echo.HeaderVary, echo.HeaderOrigin) + + src := make(http.Header) + src.Set(echo.HeaderAccessControlAllowOrigin, "https://bsky.social") + src.Set(echo.HeaderAccessControlAllowCredentials, "false") + src.Set(echo.HeaderAccessControlExposeHeaders, "upstream-only") + src.Set(echo.HeaderVary, "Accept-Encoding") + src.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + src.Set("X-Upstream", "preserved") + + copyProxyResponseHeaders(dst, src) + + if got := dst.Get(echo.HeaderAccessControlAllowOrigin); got != "https://bsky.app" { + t.Fatalf("allow-origin = %q, want Cocoon origin", got) + } + if got := dst.Get(echo.HeaderAccessControlAllowCredentials); got != "true" { + t.Fatalf("allow-credentials = %q, want true", got) + } + if got := dst.Get(echo.HeaderAccessControlExposeHeaders); got != "DPoP-Nonce,WWW-Authenticate" { + t.Fatalf("expose-headers = %q, want Cocoon headers", got) + } + vary := strings.Join(dst.Values(echo.HeaderVary), ",") + if !strings.Contains(vary, echo.HeaderOrigin) || !strings.Contains(vary, "Accept-Encoding") { + t.Fatalf("vary = %q, want both local and upstream dimensions", vary) + } + if got := dst.Get("X-Upstream"); got != "preserved" { + t.Fatalf("upstream header = %q, want preserved", got) + } +} + func mustDecodeSegment(t *testing.T, segment string) []byte { t.Helper() decoded, err := base64.RawURLEncoding.DecodeString(segment) diff --git a/server/handle_proxy.go b/server/handle_proxy.go index 5a56ea9..6b9390c 100644 --- a/server/handle_proxy.go +++ b/server/handle_proxy.go @@ -161,9 +161,27 @@ func (s *Server) handleProxy(e echo.Context) error { } defer resp.Body.Close() - for k, v := range resp.Header { - e.Response().Header().Set(k, strings.Join(v, ",")) - } + copyProxyResponseHeaders(e.Response().Header(), resp.Header) return e.Stream(resp.StatusCode, e.Response().Header().Get("content-type"), resp.Body) } + +// copyProxyResponseHeaders copies upstream response metadata without allowing +// an AppView's CORS policy to replace Cocoon's policy for the browser request. +// Vary is merged because the local CORS middleware already adds Origin and, +// for preflights, the Access-Control-Request-* dimensions. +func copyProxyResponseHeaders(dst, src http.Header) { + for k, v := range src { + lower := strings.ToLower(k) + if strings.HasPrefix(lower, "access-control-") { + continue + } + if lower == "vary" { + for _, value := range v { + dst.Add(k, value) + } + continue + } + dst.Set(k, strings.Join(v, ",")) + } +} diff --git a/space/auth_test.go b/space/auth_test.go index 8fdb731..851e8d5 100644 --- a/space/auth_test.go +++ b/space/auth_test.go @@ -6,6 +6,7 @@ import ( "crypto/elliptic" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "path/filepath" "strings" @@ -242,6 +243,50 @@ func TestDPoPProofAcceptsOptionalNonce(t *testing.T) { } } +func TestDPoPProofAcceptsStandardPublicJWKMetadata(t *testing.T) { + signer := testP256Signer(t) + proof, err := CreateDpopProof(signer, CreateDpopProofOptions{ + Htm: "POST", Htu: "https://host.example/xrpc/com.atproto.space.getSpaceCredential", + JTI: "dpop-atcute-jwk", Now: testClock(testAuthNow), + }) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(proof, ".") + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatal(err) + } + var header map[string]any + if err := json.Unmarshal(headerJSON, &header); err != nil { + t.Fatal(err) + } + jwk, ok := header["jwk"].(map[string]any) + if !ok { + t.Fatalf("DPoP header jwk = %T, want object", header["jwk"]) + } + jwk["alg"] = "ES256" + jwk["kid"] = "dpop-key" + jwk["use"] = "sig" + headerBytes, err := json.Marshal(header) + if err != nil { + t.Fatal(err) + } + parts[0] = base64.RawURLEncoding.EncodeToString(headerBytes) + signature, err := signer.Sign([]byte(parts[0] + "." + parts[1])) + if err != nil { + t.Fatal(err) + } + parts[2] = base64.RawURLEncoding.EncodeToString(signature) + + if _, err := VerifyDpopProof(context.Background(), strings.Join(parts, "."), VerifyDpopProofOptions{ + Htm: "POST", Htu: "https://host.example/xrpc/com.atproto.space.getSpaceCredential", + Now: testClock(testAuthNow), + }); err != nil { + t.Fatalf("standard public JWK metadata rejected: %v", err) + } +} + func TestAtprotoES256KTokenAdapter(t *testing.T) { key, err := atcrypto.GeneratePrivateKeyK256() if err != nil { diff --git a/space/dpop_auth.go b/space/dpop_auth.go index d4d0ced..1cad36c 100644 --- a/space/dpop_auth.go +++ b/space/dpop_auth.go @@ -418,10 +418,38 @@ func marshalP256JWK(key *ecdsa.PublicKey) ([]byte, error) { return json.Marshal(p256JWK{Kty: "EC", Crv: "P-256", X: base64.RawURLEncoding.EncodeToString(x), Y: base64.RawURLEncoding.EncodeToString(y)}) } func parseP256JWK(raw []byte) (*ecdsa.PublicKey, string, error) { - m, err := strictObject(raw, map[string]bool{"kty": true, "crv": true, "x": true, "y": true}) + m, err := strictObject(raw, map[string]bool{ + "kty": true, "crv": true, "x": true, "y": true, + // These are standard optional public-JWK members. atcute includes alg + // and use in DPoP proof keys, and some clients also include kid/key_ops. + "alg": true, "kid": true, "use": true, "key_ops": true, + }) if err != nil { return nil, "", err } + if rawAlg, ok := m["alg"]; ok { + alg, algErr := stringValue(rawAlg) + if algErr != nil || alg != "ES256" { + return nil, "", errors.New("JWK alg must be ES256") + } + } + if rawUse, ok := m["use"]; ok { + use, useErr := stringValue(rawUse) + if useErr != nil || use != "sig" { + return nil, "", errors.New("JWK use must be sig") + } + } + if rawKid, ok := m["kid"]; ok { + if _, kidErr := stringValue(rawKid); kidErr != nil { + return nil, "", errors.New("JWK kid must be a string") + } + } + if rawKeyOps, ok := m["key_ops"]; ok { + var keyOps []string + if err := json.Unmarshal(rawKeyOps, &keyOps); err != nil { + return nil, "", errors.New("JWK key_ops must be an array of strings") + } + } kty, err := requiredString(m, "kty") if err != nil || kty != "EC" { return nil, "", errors.New("JWK kty must be EC") From d374b1ca455be6b5785384d715856e9a2ad211bd Mon Sep 17 00:00:00 2001 From: hailey Date: Tue, 25 Aug 2026 01:01:36 +0000 Subject: [PATCH 4/6] Allow Bluesky labeler CORS header --- server/auth_principal_test.go | 4 ++-- server/server.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/auth_principal_test.go b/server/auth_principal_test.go index fa0ffd0..90737f1 100644 --- a/server/auth_principal_test.go +++ b/server/auth_principal_test.go @@ -656,7 +656,7 @@ func TestCORSAllowsBrowserSpaceCredentialExchange(t *testing.T) { req := httptest.NewRequest(http.MethodOptions, "/xrpc/com.atproto.space.getSpaceCredential", nil) req.Header.Set(echo.HeaderOrigin, "https://pdsls.dev") req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodPost) - req.Header.Set(echo.HeaderAccessControlRequestHeaders, "authorization,content-type,dpop") + req.Header.Set(echo.HeaderAccessControlRequestHeaders, "atproto-accept-labelers,authorization,content-type,dpop") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) @@ -670,7 +670,7 @@ func TestCORSAllowsBrowserSpaceCredentialExchange(t *testing.T) { t.Fatalf("allow-credentials = %q, want true", got) } allowHeaders := strings.ToLower(rec.Header().Get(echo.HeaderAccessControlAllowHeaders)) - for _, want := range []string{"authorization", "content-type", "dpop"} { + for _, want := range []string{"authorization", "content-type", "dpop", "atproto-accept-labelers"} { if !strings.Contains(allowHeaders, want) { t.Fatalf("allow-headers = %q, missing %q", allowHeaders, want) } diff --git a/server/server.go b/server/server.go index d05e3bf..00a41cd 100644 --- a/server/server.go +++ b/server/server.go @@ -267,6 +267,7 @@ func cocoonCORSConfig() middleware.CORSConfig { echo.HeaderAuthorization, "DPoP", "atproto-proxy", + "atproto-accept-labelers", }, AllowMethods: []string{ http.MethodGet, From a56e53c4f1220b2df484eb8120cc52f04fef6f6a Mon Sep 17 00:00:00 2001 From: hailey Date: Tue, 25 Aug 2026 01:16:24 +0000 Subject: [PATCH 5/6] Allow Bluesky topics CORS header --- server/auth_principal_test.go | 4 ++-- server/server.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/auth_principal_test.go b/server/auth_principal_test.go index 90737f1..a2a2460 100644 --- a/server/auth_principal_test.go +++ b/server/auth_principal_test.go @@ -656,7 +656,7 @@ func TestCORSAllowsBrowserSpaceCredentialExchange(t *testing.T) { req := httptest.NewRequest(http.MethodOptions, "/xrpc/com.atproto.space.getSpaceCredential", nil) req.Header.Set(echo.HeaderOrigin, "https://pdsls.dev") req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodPost) - req.Header.Set(echo.HeaderAccessControlRequestHeaders, "atproto-accept-labelers,authorization,content-type,dpop") + req.Header.Set(echo.HeaderAccessControlRequestHeaders, "atproto-accept-labelers,authorization,content-type,dpop,x-bsky-topics") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) @@ -670,7 +670,7 @@ func TestCORSAllowsBrowserSpaceCredentialExchange(t *testing.T) { t.Fatalf("allow-credentials = %q, want true", got) } allowHeaders := strings.ToLower(rec.Header().Get(echo.HeaderAccessControlAllowHeaders)) - for _, want := range []string{"authorization", "content-type", "dpop", "atproto-accept-labelers"} { + for _, want := range []string{"authorization", "content-type", "dpop", "atproto-accept-labelers", "x-bsky-topics"} { if !strings.Contains(allowHeaders, want) { t.Fatalf("allow-headers = %q, missing %q", allowHeaders, want) } diff --git a/server/server.go b/server/server.go index 00a41cd..05c6c12 100644 --- a/server/server.go +++ b/server/server.go @@ -268,6 +268,7 @@ func cocoonCORSConfig() middleware.CORSConfig { "DPoP", "atproto-proxy", "atproto-accept-labelers", + "x-bsky-topics", }, AllowMethods: []string{ http.MethodGet, From e4b3965aa2b8549f05e03435ba1557d49a6901cc Mon Sep 17 00:00:00 2001 From: hailey Date: Tue, 25 Aug 2026 01:21:39 +0000 Subject: [PATCH 6/6] Reflect requested CORS headers --- server/auth_principal_test.go | 4 ++-- server/server.go | 12 ++---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/server/auth_principal_test.go b/server/auth_principal_test.go index a2a2460..524598b 100644 --- a/server/auth_principal_test.go +++ b/server/auth_principal_test.go @@ -656,7 +656,7 @@ func TestCORSAllowsBrowserSpaceCredentialExchange(t *testing.T) { req := httptest.NewRequest(http.MethodOptions, "/xrpc/com.atproto.space.getSpaceCredential", nil) req.Header.Set(echo.HeaderOrigin, "https://pdsls.dev") req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodPost) - req.Header.Set(echo.HeaderAccessControlRequestHeaders, "atproto-accept-labelers,authorization,content-type,dpop,x-bsky-topics") + req.Header.Set(echo.HeaderAccessControlRequestHeaders, "atproto-accept-labelers,authorization,content-type,dpop,x-bsky-topics,x-future-client-header") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) @@ -670,7 +670,7 @@ func TestCORSAllowsBrowserSpaceCredentialExchange(t *testing.T) { t.Fatalf("allow-credentials = %q, want true", got) } allowHeaders := strings.ToLower(rec.Header().Get(echo.HeaderAccessControlAllowHeaders)) - for _, want := range []string{"authorization", "content-type", "dpop", "atproto-accept-labelers", "x-bsky-topics"} { + for _, want := range []string{"authorization", "content-type", "dpop", "atproto-accept-labelers", "x-bsky-topics", "x-future-client-header"} { if !strings.Contains(allowHeaders, want) { t.Fatalf("allow-headers = %q, missing %q", allowHeaders, want) } diff --git a/server/server.go b/server/server.go index 05c6c12..40130db 100644 --- a/server/server.go +++ b/server/server.go @@ -260,16 +260,8 @@ func cocoonCORSConfig() middleware.CORSConfig { AllowOriginFunc: func(origin string) (bool, error) { return origin != "", nil }, - AllowHeaders: []string{ - echo.HeaderOrigin, - echo.HeaderAccept, - echo.HeaderContentType, - echo.HeaderAuthorization, - "DPoP", - "atproto-proxy", - "atproto-accept-labelers", - "x-bsky-topics", - }, + // Leave AllowHeaders empty so Echo reflects the browser's requested + // headers instead of requiring every client-specific header here. AllowMethods: []string{ http.MethodGet, http.MethodHead,