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
This commit is contained in:
2026-07-20 13:13:09 -04:00
parent d4ca5f576e
commit 57aec2c6b3
4 changed files with 20 additions and 9 deletions
+6 -4
View File
@@ -30,10 +30,11 @@ func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
} }
token := generateToken() token := generateToken()
tokenHash := hashToken(token)
_, err = h.db.ExecContext(r.Context(), ` _, err = h.db.ExecContext(r.Context(), `
INSERT INTO user_tokens (user_id, token, type, expires_at) INSERT INTO user_tokens (user_id, token, type, expires_at)
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours') VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
`, userID, token) `, userID, tokenHash)
if err != nil { if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return return
@@ -63,7 +64,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
DELETE FROM user_tokens DELETE FROM user_tokens
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW() WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
RETURNING user_id RETURNING user_id
`, req.Token).Scan(&userID) `, hashToken(req.Token)).Scan(&userID)
if err != nil { if err != nil {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
return return
@@ -100,10 +101,11 @@ func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
} }
token := generateToken() token := generateToken()
tokenHash := hashToken(token)
_, err = h.db.ExecContext(r.Context(), ` _, err = h.db.ExecContext(r.Context(), `
INSERT INTO user_tokens (user_id, token, type, expires_at) INSERT INTO user_tokens (user_id, token, type, expires_at)
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour') VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
`, userID, token) `, userID, tokenHash)
if err != nil { if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return return
@@ -134,7 +136,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
DELETE FROM user_tokens DELETE FROM user_tokens
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW() WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
RETURNING user_id RETURNING user_id
`, req.Token).Scan(&userID) `, hashToken(req.Token)).Scan(&userID)
if err != nil { if err != nil {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
return return
+12 -3
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@@ -20,17 +21,25 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
return &SessionStore{db: db, cfg: cfg} 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) { func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate token: %w", err) return "", fmt.Errorf("generate token: %w", err)
} }
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
tokenHash := hashToken(token)
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (user_id, token, expires_at) INSERT INTO sessions (user_id, token, expires_at)
VALUES ($1, $2, $3) 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 { if err != nil {
return "", fmt.Errorf("insert session: %w", err) 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, ` err := s.db.QueryRowContext(ctx, `
SELECT user_id FROM sessions SELECT user_id FROM sessions
WHERE token = $1 AND expires_at > NOW() WHERE token = $1 AND expires_at > NOW()
`, token).Scan(&userID) `, hashToken(token)).Scan(&userID)
if err != nil { if err != nil {
return "", err 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 { 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 return err
} }
+1 -1
View File
@@ -23,7 +23,7 @@
} }
], ],
"security": { "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": { "bundle": {
+1 -1
View File
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
} }
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
} }
function inlineMarkdownToHtml(text: string): string { function inlineMarkdownToHtml(text: string): string {