sécurité : correctifs XSS, SSRF, CSWSH, rate-limit et durcissement

- XSS stocké (critique) : sanitisation bluemonday sur tous les endpoints
  d'articles (List, ListByFeed, Favorites, Search, Get), pas seulement Get
- SSRF (élevé) : nouveau utils/safehttp.go (ValidateExternalURL + client durci
  via Dialer.Control, bloque IP privées/loopback/link-local/metadata,
  anti-DNS-rebinding et limite de redirections) appliqué à l'extracteur et au parser
- WebSocket CSWSH (élevé) : politique same-origin + override WS_ALLOWED_ORIGINS
- rate-limiting (moyen) : token-bucket en mémoire sur /auth/*
- token de session retiré du corps JSON (json:"-"), livré uniquement par le
  cookie HttpOnly
- cookie Secure correct derrière un reverse-proxy (X-Forwarded-Proto +
  override COOKIE_SECURE)
- admin : interdiction de supprimer son propre compte
- en-têtes de sécurité (nosniff, X-Frame-Options, Referrer-Policy,
  Permissions-Policy)

Note : backend non compilé localement (pas de toolchain Go) ; à valider via Docker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-11 13:21:43 +02:00
1 parent 10fdfdeccb
commit 3402e53954
11 files changed
+346 -23

No files matched your search

+9
View File
@@ -42,6 +42,15 @@ func (h *AdminHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
// Prevent an admin from deleting their own account (lockout / accidental
// self-removal). The AdminOnly middleware has already verified admin rights.
if cookie, cErr := r.Cookie("session_id"); cErr == nil {
if current, _ := h.authService.GetUserByToken(cookie.Value); current != nil && current.ID == userID {
respondError(w, http.StatusForbidden, "You cannot delete your own account")
return
}
}
// Logic to delete user and all associated data
if err := h.userRepo.Delete(userID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to delete user")
+28 -6
View File
@@ -37,6 +37,28 @@ func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *servic
}
}
// sanitizeArticle cleans the user-facing HTML fields of a single article to
// prevent stored XSS from malicious feeds. AISummary is rendered as plain text
// by the client, so only Content and Summary need sanitization.
func (h *ArticleHandler) sanitizeArticle(a *domain.Article) {
if a == nil {
return
}
if a.Content != "" {
a.Content = h.sanitizer.Sanitize(a.Content)
}
if a.Summary != "" {
a.Summary = h.sanitizer.Sanitize(a.Summary)
}
}
// sanitizeArticles cleans a slice of articles in place.
func (h *ArticleHandler) sanitizeArticles(articles []*domain.Article) {
for _, a := range articles {
h.sanitizeArticle(a)
}
}
// getUserFromRequest extracts the authenticated user from the request.
func (h *ArticleHandler) getUserFromRequest(r *http.Request) (uuid.UUID, error) {
cookie, err := r.Cookie("session_id")
@@ -79,6 +101,7 @@ func (h *ArticleHandler) List(w http.ResponseWriter, r *http.Request) {
return
}
h.sanitizeArticles(articles)
respondJSON(w, http.StatusOK, articles)
}
@@ -120,6 +143,7 @@ func (h *ArticleHandler) ListByFeed(w http.ResponseWriter, r *http.Request) {
return
}
h.sanitizeArticles(articles)
respondJSON(w, http.StatusOK, articles)
}
@@ -150,12 +174,8 @@ func (h *ArticleHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
// Sanitize content
if article.Content != "" {
article.Content = h.sanitizer.Sanitize(article.Content)
} else if article.Summary != "" {
article.Summary = h.sanitizer.Sanitize(article.Summary)
}
// Sanitize user-facing HTML content (defense against stored XSS).
h.sanitizeArticle(article)
respondJSON(w, http.StatusOK, article)
}
@@ -358,6 +378,7 @@ func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
return
}
h.sanitizeArticles(articles)
respondJSON(w, http.StatusOK, articles)
}
@@ -391,6 +412,7 @@ func (h *ArticleHandler) Search(w http.ResponseWriter, r *http.Request) {
return
}
h.sanitizeArticles(articles)
respondJSON(w, http.StatusOK, articles)
}
+20 -2
View File
@@ -4,11 +4,29 @@ import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"github.com/michael/flowreader/internal/service"
)
// secureCookie reports whether the session cookie should carry the Secure flag.
// It honours TLS termination at a reverse proxy (X-Forwarded-Proto) and an
// explicit COOKIE_SECURE override, so HTTPS deployments behind a proxy still
// get Secure cookies even though r.TLS is nil.
func secureCookie(r *http.Request) bool {
switch strings.ToLower(os.Getenv("COOKIE_SECURE")) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
}
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// AuthHandler handles authentication-related HTTP requests.
type AuthHandler struct {
authService *service.AuthService
@@ -74,7 +92,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
Path: "/",
Expires: resp.ExpiresAt,
HttpOnly: true,
Secure: r.TLS != nil, // Secure only if HTTPS
Secure: secureCookie(r),
SameSite: http.SameSiteStrictMode,
})
@@ -98,7 +116,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: r.TLS != nil,
Secure: secureCookie(r),
SameSite: http.SameSiteStrictMode,
})
+92
View File
@@ -0,0 +1,92 @@
package handler
import (
"net/http"
"sync"
"time"
)
// rateLimiter is a simple in-memory token-bucket limiter keyed by client IP.
// It protects brute-force-prone endpoints (login/register) without external
// dependencies. Stale buckets are evicted periodically to bound memory.
type rateLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
rate float64 // tokens added per second
capacity float64 // max tokens (burst)
}
type bucket struct {
tokens float64
last time.Time
}
// newRateLimiter allows `burst` requests immediately, refilling at
// `perMinute` requests per minute thereafter.
func newRateLimiter(perMinute, burst int) *rateLimiter {
rl := &rateLimiter{
buckets: make(map[string]*bucket),
rate: float64(perMinute) / 60.0,
capacity: float64(burst),
}
go rl.cleanupLoop()
return rl
}
func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
b, ok := rl.buckets[key]
if !ok {
rl.buckets[key] = &bucket{tokens: rl.capacity - 1, last: now}
return true
}
// Refill based on elapsed time.
b.tokens += now.Sub(b.last).Seconds() * rl.rate
if b.tokens > rl.capacity {
b.tokens = rl.capacity
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func (rl *rateLimiter) cleanupLoop() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
for k, b := range rl.buckets {
// Drop buckets that have been idle long enough to be full again.
if time.Since(b.last) > 15*time.Minute {
delete(rl.buckets, k)
}
}
rl.mu.Unlock()
}
}
// Middleware returns a chi-compatible middleware enforcing the limit per IP.
func (rl *rateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !rl.allow(getClientIP(r)) {
w.Header().Set("Retry-After", "60")
respondError(w, http.StatusTooManyRequests, "Too many requests. Please slow down.")
return
}
next.ServeHTTP(w, r)
})
}
// NewAuthRateLimiter builds the limiter used for authentication routes:
// 10 requests/minute per IP with a small burst.
func NewAuthRateLimiter() func(http.Handler) http.Handler {
return newRateLimiter(10, 5).Middleware
}