Compare commits

...

2 Commits

Author SHA1 Message Date
yushen
660a0b3af9 fix(auth): address PR review — Strict cookies, HMAC-bound CSRF, origin sync
1. SameSite=Lax → SameSite=Strict per spec requirement
2. CSRF token now HMAC-signed with auth token (nonce.signature format),
   preventing subdomain cookie injection attacks
3. allowedWSOrigins uses atomic.Value to eliminate data race
4. Removed magic "cookie" sentinel string in WSProvider — pass null token
   and guard with boolean check instead
5. Removed dead delete uploadHeaders["Content-Type"] in API client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:06:47 +08:00
yushen
21c5516f2b feat(auth): migrate auth token to HttpOnly cookie & implement WebSocket Origin whitelist
Security improvements from the MUL-566 audit report:

1. Auth token is now set as an HttpOnly, SameSite=Lax cookie on login,
   preventing XSS-based token theft. Cookie-based auth includes CSRF
   protection via double-submit cookie pattern. The Authorization header
   path is preserved for Electron desktop app and CLI/PAT clients.

2. WebSocket upgrader now validates the Origin header against a
   configurable allowlist (ALLOWED_ORIGINS env var), rejecting
   connections from unauthorized origins.

Backend: new auth cookie helpers, middleware reads cookie as fallback,
WS handler accepts cookie auth, Origin whitelist, logout endpoint.
Frontend: CSRF token in API headers, cookie-aware auth store and WS
client, web app opts into cookieAuth mode while desktop keeps tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:36:00 +08:00
14 changed files with 359 additions and 38 deletions

View File

@@ -46,6 +46,12 @@ COOKIE_DOMAIN=
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

View File

@@ -12,6 +12,7 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
cookieAuth
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
>

View File

@@ -85,10 +85,20 @@ export class ApiClient {
this.workspaceId = id;
}
private readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((c) => c.startsWith("multica_csrf="));
return match ? match.split("=")[1] ?? null : null;
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
@@ -168,6 +178,10 @@ export class ApiClient {
});
}
async logout(): Promise<void> {
await this.fetch("/auth/logout", { method: "POST" });
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}

View File

@@ -8,6 +8,7 @@ export class WSClient {
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private cookieAuth = false;
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
@@ -15,19 +16,23 @@ export class WSClient {
private anyHandlers = new Set<(msg: WSMessage) => void>();
private logger: Logger;
constructor(url: string, options?: { logger?: Logger }) {
constructor(url: string, options?: { logger?: Logger; cookieAuth?: boolean }) {
this.baseUrl = url;
this.logger = options?.logger ?? noopLogger;
this.cookieAuth = options?.cookieAuth ?? false;
}
setAuth(token: string, workspaceId: string) {
setAuth(token: string | null, workspaceId: string) {
this.token = token;
this.workspaceId = workspaceId;
}
connect() {
const url = new URL(this.baseUrl);
if (this.token) url.searchParams.set("token", this.token);
// In cookie mode, the browser sends the HttpOnly cookie automatically
// with the WebSocket upgrade request — no token in URL needed.
if (!this.cookieAuth && this.token)
url.searchParams.set("token", this.token);
if (this.workspaceId)
url.searchParams.set("workspace_id", this.workspaceId);

View File

@@ -7,6 +7,8 @@ export interface AuthStoreOptions {
storage: StorageAdapter;
onLogin?: () => void;
onLogout?: () => void;
/** When true, rely on HttpOnly cookies instead of localStorage for auth tokens. */
cookieAuth?: boolean;
}
export interface AuthState {
@@ -22,13 +24,26 @@ export interface AuthState {
}
export function createAuthStore(options: AuthStoreOptions) {
const { api, storage, onLogin, onLogout } = options;
const { api, storage, onLogin, onLogout, cookieAuth } = options;
return create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
if (cookieAuth) {
// In cookie mode, the HttpOnly cookie is sent automatically.
// Try to fetch the current user — if the cookie exists the server will accept it.
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch {
set({ user: null, isLoading: false });
}
return;
}
// Token mode: read from localStorage (Electron / legacy).
const token = storage.getItem("multica_token");
if (!token) {
set({ isLoading: false });
@@ -54,8 +69,11 @@ export function createAuthStore(options: AuthStoreOptions) {
verifyCode: async (email: string, code: string) => {
const { token, user } = await api.verifyCode(email, code);
storage.setItem("multica_token", token);
api.setToken(token);
if (!cookieAuth) {
// Token mode: persist for Electron / legacy.
storage.setItem("multica_token", token);
api.setToken(token);
}
onLogin?.();
set({ user });
return user;
@@ -63,14 +81,20 @@ export function createAuthStore(options: AuthStoreOptions) {
loginWithGoogle: async (code: string, redirectUri: string) => {
const { token, user } = await api.googleLogin(code, redirectUri);
storage.setItem("multica_token", token);
api.setToken(token);
if (!cookieAuth) {
storage.setItem("multica_token", token);
api.setToken(token);
}
onLogin?.();
set({ user });
return user;
},
logout: () => {
if (cookieAuth) {
// Clear server-side HttpOnly cookie.
api.logout().catch(() => {});
}
storage.removeItem("multica_token");
api.setToken(null);
api.setWorkspaceId(null);

View File

@@ -25,6 +25,7 @@ function initCore(
storage: StorageAdapter,
onLogin?: () => void,
onLogout?: () => void,
cookieAuth?: boolean,
) {
if (initialized) return;
@@ -37,13 +38,15 @@ function initCore(
});
setApiInstance(api);
// Hydrate token from storage
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
// In token mode, hydrate token from storage.
if (!cookieAuth) {
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
}
const wsId = storage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
authStore = createAuthStore({ api, storage, onLogin, onLogout });
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
registerAuthStore(authStore);
workspaceStore = createWorkspaceStore(api, { storage });
@@ -60,13 +63,14 @@ export function CoreProvider({
apiBaseUrl = "",
wsUrl = "ws://localhost:8080/ws",
storage = defaultStorage,
cookieAuth,
onLogin,
onLogout,
}: CoreProviderProps) {
// Initialize singletons on first render only. Dependencies are read-once:
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout), []);
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth), []);
return (
<QueryProvider>
@@ -76,6 +80,7 @@ export function CoreProvider({
authStore={authStore}
workspaceStore={workspaceStore}
storage={storage}
cookieAuth={cookieAuth}
>
{children}
</WSProvider>

View File

@@ -8,6 +8,8 @@ export interface CoreProviderProps {
wsUrl?: string;
/** Storage adapter. Default: SSR-safe localStorage wrapper. */
storage?: StorageAdapter;
/** Use HttpOnly cookies for auth instead of localStorage tokens. Default: false. */
cookieAuth?: boolean;
/** Called after successful login (e.g. set cookie for Next.js middleware). */
onLogin?: () => void;
/** Called after logout (e.g. clear cookie). */

View File

@@ -35,6 +35,8 @@ export interface WSProviderProps {
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
/** Platform-specific storage adapter for reading auth tokens */
storage: StorageAdapter;
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
cookieAuth?: boolean;
/** Optional callback for showing toast messages (platform-specific, e.g. sonner) */
onToast?: (message: string, type?: "info" | "error") => void;
}
@@ -45,6 +47,7 @@ export function WSProvider({
authStore,
workspaceStore,
storage,
cookieAuth,
onToast,
}: WSProviderProps) {
const user = authStore((s) => s.user);
@@ -54,10 +57,15 @@ export function WSProvider({
useEffect(() => {
if (!user || !workspace) return;
const token = storage.getItem("multica_token");
if (!token) return;
// In token mode we need a token from storage; in cookie mode the HttpOnly
// cookie is sent automatically with the WS upgrade request.
const token = cookieAuth ? null : storage.getItem("multica_token");
if (!cookieAuth && !token) return;
const ws = new WSClient(wsUrl, { logger: createLogger("ws") });
const ws = new WSClient(wsUrl, {
logger: createLogger("ws"),
cookieAuth,
});
ws.setAuth(token, workspace.id);
setWsClient(ws);
ws.connect();
@@ -66,7 +74,7 @@ export function WSProvider({
ws.disconnect();
setWsClient(null);
};
}, [user, workspace, wsUrl, storage]);
}, [user, workspace, wsUrl, storage, cookieAuth]);
const stores: RealtimeSyncStores = { authStore, workspaceStore };

View File

@@ -78,10 +78,15 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger)
r.Use(chimw.Recoverer)
origins := allowedOrigins()
// Share allowed origins with WebSocket origin checker.
realtime.SetAllowedOrigins(origins)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins(),
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300,
}))
@@ -111,6 +116,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)
r.Post("/auth/google", h.GoogleLogin)
r.Post("/auth/logout", h.Logout)
// Daemon API routes (require daemon token or valid user token)
r.Route("/api/daemon", func(r chi.Router) {

View File

@@ -0,0 +1,152 @@
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"os"
"strings"
"time"
)
const (
AuthCookieName = "multica_auth"
CSRFCookieName = "multica_csrf"
authCookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds
)
func cookieDomain() string {
return strings.TrimSpace(os.Getenv("COOKIE_DOMAIN"))
}
func isSecureCookie() bool {
env := os.Getenv("APP_ENV")
return env == "production" || env == "staging"
}
// generateCSRFToken creates a CSRF token bound to the auth token via HMAC.
// Format: hex(nonce) + "." + hex(HMAC-SHA256(nonce, authToken)).
// This ensures an attacker who can write cookies on a subdomain cannot forge
// a valid CSRF token without knowing the auth token.
func generateCSRFToken(authToken string) (string, error) {
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
nonceHex := hex.EncodeToString(nonce)
mac := hmac.New(sha256.New, []byte(authToken))
mac.Write(nonce)
sig := hex.EncodeToString(mac.Sum(nil))
return nonceHex + "." + sig, nil
}
// SetAuthCookies sets the HttpOnly auth cookie and the readable CSRF cookie on the response.
func SetAuthCookies(w http.ResponseWriter, token string) error {
secure := isSecureCookie()
domain := cookieDomain()
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: token,
Path: "/",
Domain: domain,
MaxAge: authCookieMaxAge,
Expires: time.Now().Add(30 * 24 * time.Hour),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
csrfToken, err := generateCSRFToken(token)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CSRFCookieName,
Value: csrfToken,
Path: "/",
Domain: domain,
MaxAge: authCookieMaxAge,
Expires: time.Now().Add(30 * 24 * time.Hour),
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
return nil
}
// ClearAuthCookies removes the auth and CSRF cookies.
func ClearAuthCookies(w http.ResponseWriter) {
domain := cookieDomain()
secure := isSecureCookie()
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: "",
Path: "/",
Domain: domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(w, &http.Cookie{
Name: CSRFCookieName,
Value: "",
Path: "/",
Domain: domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// ValidateCSRF checks the X-CSRF-Token header against the auth cookie.
// The CSRF token is HMAC-signed with the auth token, so the server verifies
// the signature rather than simply comparing cookie == header.
// Returns true if validation passes (including for safe methods that don't need CSRF).
func ValidateCSRF(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
csrfHeader := r.Header.Get("X-CSRF-Token")
if csrfHeader == "" {
return false
}
authCookie, err := r.Cookie(AuthCookieName)
if err != nil || authCookie.Value == "" {
return false
}
parts := strings.SplitN(csrfHeader, ".", 2)
if len(parts) != 2 {
return false
}
nonce, err := hex.DecodeString(parts[0])
if err != nil {
return false
}
expectedSig, err := hex.DecodeString(parts[1])
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(authCookie.Value))
mac.Write(nonce)
return hmac.Equal(mac.Sum(nil), expectedSig)
}

View File

@@ -303,6 +303,11 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
return
}
// Set HttpOnly auth cookie (browser clients) + CSRF cookie.
if err := auth.SetAuthCookies(w, tokenString); err != nil {
slog.Warn("failed to set auth cookies", "error", err)
}
// Set CloudFront signed cookies for CDN access.
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) {
@@ -485,6 +490,10 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if err := auth.SetAuthCookies(w, tokenString); err != nil {
slog.Warn("failed to set auth cookies", "error", err)
}
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
http.SetCookie(w, cookie)
@@ -498,6 +507,11 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
})
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
auth.ClearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {

View File

@@ -15,22 +15,26 @@ import (
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
// Auth middleware validates JWT tokens or Personal Access Tokens from the Authorization header.
// Auth middleware validates JWT tokens or Personal Access Tokens.
// Token sources (in priority order):
// 1. Authorization: Bearer <token> header (PAT or JWT)
// 2. multica_auth HttpOnly cookie (JWT) — requires valid CSRF token for state-changing requests
//
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
func Auth(queries *db.Queries) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
tokenString, fromCookie := extractToken(r)
if tokenString == "" {
slog.Debug("auth: no token found", "path", r.URL.Path)
http.Error(w, `{"error":"missing authorization"}`, http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
slog.Debug("auth: invalid format", "path", r.URL.Path)
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
// Cookie-based auth requires CSRF validation for state-changing methods.
if fromCookie && !auth.ValidateCSRF(r) {
slog.Debug("auth: CSRF validation failed", "path", r.URL.Path)
http.Error(w, `{"error":"CSRF validation failed"}`, http.StatusForbidden)
return
}
@@ -92,3 +96,20 @@ func Auth(queries *db.Queries) func(http.Handler) http.Handler {
})
}
}
// extractToken returns the bearer token and whether it came from a cookie.
// Priority: Authorization header > multica_auth cookie.
func extractToken(r *http.Request) (token string, fromCookie bool) {
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString != authHeader {
return tokenString, false
}
}
if cookie, err := r.Cookie(auth.AuthCookieName); err == nil && cookie.Value != "" {
return cookie.Value, true
}
return "", false
}

View File

@@ -41,7 +41,7 @@ func TestAuth_MissingHeader(t *testing.T) {
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
if body := w.Body.String(); body != `{"error":"missing authorization header"}`+"\n" {
if body := w.Body.String(); body != `{"error":"missing authorization"}`+"\n" {
t.Fatalf("unexpected body: %s", body)
}
}
@@ -59,7 +59,8 @@ func TestAuth_NoBearerPrefix(t *testing.T) {
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
if body := w.Body.String(); body != `{"error":"invalid authorization format"}`+"\n" {
// Non-Bearer Authorization header with no cookie falls through to "missing authorization".
if body := w.Body.String(); body != `{"error":"missing authorization"}`+"\n" {
t.Fatalf("unexpected body: %s", body)
}
}

View File

@@ -4,8 +4,10 @@ import (
"context"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
@@ -23,11 +25,61 @@ type PATResolver interface {
ResolveToken(ctx context.Context, token string) (userID string, ok bool)
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// TODO: Restrict origins in production
var allowedWSOrigins atomic.Value // holds []string
func init() {
allowedWSOrigins.Store(loadAllowedOrigins())
}
func loadAllowedOrigins() []string {
raw := strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS"))
if raw == "" {
raw = strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
}
if raw == "" {
raw = strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
}
if raw == "" {
return []string{
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
}
}
parts := strings.Split(raw, ",")
origins := make([]string, 0, len(parts))
for _, part := range parts {
origin := strings.TrimSpace(part)
if origin != "" {
origins = append(origins, origin)
}
}
return origins
}
// SetAllowedOrigins overrides the WebSocket origin whitelist (called from router setup).
func SetAllowedOrigins(origins []string) {
allowedWSOrigins.Store(origins)
}
func checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
},
}
origins := allowedWSOrigins.Load().([]string)
for _, allowed := range origins {
if origin == allowed {
return true
}
}
slog.Warn("ws: rejected origin", "origin", origin)
return false
}
var upgrader = websocket.Upgrader{
CheckOrigin: checkOrigin,
}
// Client represents a single WebSocket connection with identity.
@@ -220,13 +272,23 @@ func (h *Hub) Broadcast(message []byte) {
h.broadcast <- message
}
// HandleWebSocket upgrades an HTTP connection to WebSocket with JWT or PAT auth.
// HandleWebSocket upgrades an HTTP connection to WebSocket with JWT, PAT, or cookie auth.
func HandleWebSocket(hub *Hub, mc MembershipChecker, pr PATResolver, w http.ResponseWriter, r *http.Request) {
tokenStr := r.URL.Query().Get("token")
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
http.Error(w, `{"error":"workspace_id required"}`, http.StatusUnauthorized)
return
}
if tokenStr == "" || workspaceID == "" {
http.Error(w, `{"error":"token and workspace_id required"}`, http.StatusUnauthorized)
// Resolve token: query param first, then cookie fallback.
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
if cookie, err := r.Cookie(auth.AuthCookieName); err == nil && cookie.Value != "" {
tokenStr = cookie.Value
}
}
if tokenStr == "" {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}