From 57aec2c6b3bd9fe6e2da7fed063f0f468f94fa9b Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 20 Jul 2026 13:13:09 -0400 Subject: [PATCH] sec: hash tokens in DB, set Tauri CSP, escape quotes in XSS guard - Session and email verification/reset tokens stored as SHA-256 hash in DB (raw token stays client-side in cookie/email link) - Tauri CSP set: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https: - escapeHtml now handles double and single quotes to prevent attribute-based XSS --- internal/auth/email_handlers.go | 10 ++++++---- internal/auth/session.go | 15 ++++++++++++--- web/src-tauri/tauri.conf.json | 2 +- web/src/components/MessageInput.tsx | 2 +- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/auth/email_handlers.go b/internal/auth/email_handlers.go index 142ec09..eb76829 100644 --- a/internal/auth/email_handlers.go +++ b/internal/auth/email_handlers.go @@ -30,10 +30,11 @@ func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) { } token := generateToken() + tokenHash := hashToken(token) _, err = h.db.ExecContext(r.Context(), ` INSERT INTO user_tokens (user_id, token, type, expires_at) VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours') - `, userID, token) + `, userID, tokenHash) if err != nil { http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) return @@ -63,7 +64,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) { DELETE FROM user_tokens WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW() RETURNING user_id - `, req.Token).Scan(&userID) + `, hashToken(req.Token)).Scan(&userID) if err != nil { http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) return @@ -100,10 +101,11 @@ func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { } token := generateToken() + tokenHash := hashToken(token) _, err = h.db.ExecContext(r.Context(), ` INSERT INTO user_tokens (user_id, token, type, expires_at) VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour') - `, userID, token) + `, userID, tokenHash) if err != nil { http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) return @@ -134,7 +136,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) { DELETE FROM user_tokens WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW() RETURNING user_id - `, req.Token).Scan(&userID) + `, hashToken(req.Token)).Scan(&userID) if err != nil { http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) return diff --git a/internal/auth/session.go b/internal/auth/session.go index a8717fa..45cf98c 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -3,6 +3,7 @@ package auth import ( "context" "crypto/rand" + "crypto/sha256" "database/sql" "encoding/hex" "fmt" @@ -20,17 +21,25 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore { return &SessionStore{db: db, cfg: cfg} } +// hashToken computes a SHA-256 hash of a token string. +// Tokens are stored hashed in the DB so a DB leak doesn't expose active sessions. +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generate token: %w", err) } token := hex.EncodeToString(b) + tokenHash := hashToken(token) _, err := s.db.ExecContext(ctx, ` INSERT INTO sessions (user_id, token, expires_at) VALUES ($1, $2, $3) - `, userID, token, time.Now().Add(s.cfg.Session.Duration)) + `, userID, tokenHash, time.Now().Add(s.cfg.Session.Duration)) if err != nil { return "", fmt.Errorf("insert session: %w", err) } @@ -43,7 +52,7 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri err := s.db.QueryRowContext(ctx, ` SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW() - `, token).Scan(&userID) + `, hashToken(token)).Scan(&userID) if err != nil { return "", err } @@ -51,6 +60,6 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri } func (s *SessionStore) Delete(ctx context.Context, token string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token) + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token)) return err } diff --git a/web/src-tauri/tauri.conf.json b/web/src-tauri/tauri.conf.json index e9f34d5..e398dfe 100644 --- a/web/src-tauri/tauri.conf.json +++ b/web/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;" } }, "bundle": { diff --git a/web/src/components/MessageInput.tsx b/web/src/components/MessageInput.tsx index 0385482..16c1bb8 100644 --- a/web/src/components/MessageInput.tsx +++ b/web/src/components/MessageInput.tsx @@ -155,7 +155,7 @@ function markdownToHtml(md: string): string { } function escapeHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">"); + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } function inlineMarkdownToHtml(text: string): string {