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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineMarkdownToHtml(text: string): string {
|
function inlineMarkdownToHtml(text: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user