fix(cors): open API to any origin for token auth, keep cookies same-origin

Reflect any Origin so token-authenticated clients (Access Token V2 / PAT)
can call the API cross-origin, but emit Access-Control-Allow-Credentials
only for trusted origins (same host / configured InstanceURL). This keeps
the SameSite=Lax refresh cookie unreadable by untrusted (incl. same-site
subdomain) origins. Origin: null is not reflected.

Note for operators: cross-origin token access is now open by default; if
you front memos with a caching proxy, ensure it honors `Vary: Origin`.
This commit is contained in:
boojack
2026-06-14 23:20:34 +08:00
parent 00225db922
commit 385fa22056
2 changed files with 56 additions and 7 deletions
+20 -4
View File
@@ -12,13 +12,29 @@ import (
func newCORSMiddleware(profile *profile.Profile) echo.MiddlewareFunc {
return middleware.CORSWithConfig(middleware.CORSConfig{
// The API is open to any origin so that token-authenticated clients
// (Access Token V2 / PAT in the Authorization header) can call it from
// anywhere. Credentials — i.e. the SameSite=Lax refresh-token cookie — are
// granted only to trusted origins (same host or the configured InstanceURL).
//
// AllowCredentials stays false here on purpose: the per-origin
// Access-Control-Allow-Credentials header is set inside the func below.
// Do NOT switch this to AllowCredentials:true — emitting that header for
// every reflected origin would let a malicious same-site subdomain read the
// cookie-authenticated /auth/refresh response and steal an access token.
AllowCredentials: false,
UnsafeAllowOriginFunc: func(c *echo.Context, origin string) (string, bool, error) {
if isAllowedCORSOrigin(profile, c.Request().Host, origin) {
return origin, true, nil
// Never reflect the opaque "null" origin (sandboxed iframes, file://).
if origin == "null" {
return "", false, nil
}
return "", false, nil
// Trusted origins additionally get credentialed (cookie) access.
if isAllowedCORSOrigin(profile, c.Request().Host, origin) {
c.Response().Header().Set(echo.HeaderAccessControlAllowCredentials, "true")
}
// Reflect every origin; only trusted ones carry the credentials header.
return origin, true, nil
},
AllowCredentials: true,
})
}
+36 -3
View File
@@ -3,6 +3,7 @@ package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v5"
@@ -85,7 +86,7 @@ func TestCORSMiddleware(t *testing.T) {
return c.NoContent(http.StatusOK)
})
t.Run("allows instance URL origin on preflight", func(t *testing.T) {
t.Run("trusted origin gets credentialed access", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/api/v1/test", nil)
req.Header.Set("Origin", "https://memos.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
@@ -104,7 +105,7 @@ func TestCORSMiddleware(t *testing.T) {
}
})
t.Run("omits CORS headers for unknown origin preflight", func(t *testing.T) {
t.Run("arbitrary origin is reflected without credentials", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/api/v1/test", nil)
req.Header.Set("Origin", "https://evil.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
@@ -115,8 +116,40 @@ func TestCORSMiddleware(t *testing.T) {
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code)
}
// The API is open to any origin (token auth), so the origin is reflected...
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://evil.example" {
t.Fatalf("expected origin to be reflected, got %q", got)
}
// ...but an untrusted origin must NOT be granted credentialed (cookie) access.
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("expected no Access-Control-Allow-Credentials for untrusted origin, got %q", got)
}
})
t.Run("arbitrary origin may send Authorization header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/api/v1/test", nil)
req.Header.Set("Origin", "https://app.third-party.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
req.Header.Set("Access-Control-Request-Headers", "Authorization")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(strings.ToLower(got), "authorization") {
t.Fatalf("expected Authorization to be allowed for a cross-origin token client, got %q", got)
}
})
t.Run("null origin is not reflected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/api/v1/test", nil)
req.Header.Set("Origin", "null")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("expected no Access-Control-Allow-Origin, got %q", got)
t.Fatalf("expected null origin not to be reflected, got %q", got)
}
})
}