mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-23 23:28:06 +02:00
fix(backend): harden security and speed up feeds and article API
Security: - WebSocket events are routed to their owner only (no cross-user leak); hub close is idempotent (fixes double-close panic), adds ping/pong and write deadlines. - Session tokens stored as SHA-256 (migration 008 keeps sessions valid); single-query auth middleware puts the user in the request context. - Client IP only trusts X-Forwarded-For from TRUSTED_PROXIES; rate limiter map is bounded; per-user limit on AI summaries. - Argon2id at OWASP minimum with a concurrency cap; constant-time login for unknown emails; atomic first-admin bootstrap; REGISTRATION_ENABLED. - CSP/HSTS/COOP headers, same-origin guard on mutations, body size limits, wider SSRF denylist, bounded feed/page/AI response reads, generic errors. - Upgrade chi, pgx, x/net, x/text, x/crypto (known CVEs); commit go.sum. Performance: - List endpoints return a plain-text excerpt and reading time instead of full HTML; content is sanitized once at ingest (legacy rows backfilled). - Keyset pagination on (sort_at, id) with matching partial indexes; redundant indexes dropped (migration 007). - Fetcher: bounded worker pool, conditional GET (ETag/Last-Modified), exponential backoff, dedupe before insert, column-safe truncation, retention-aware ingest, per-user refresh coalescing. - Read/favorite/read-all are single ownership-scoped statements. - gzip compression, immutable caching for hashed assets, path-safe SPA handler, server timeouts; expired sessions purged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d037e2be34
commit
03e57e4308
40 files changed
+2231
-1898
No files matched your search
+33
-12
@@ -3,7 +3,9 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -16,8 +18,21 @@ var (
|
||||
ErrFeedExists = errors.New("feed already exists")
|
||||
ErrFeedNotFound = errors.New("feed not found")
|
||||
ErrUnauthorized = errors.New("unauthorized access")
|
||||
ErrTooManyFeeds = errors.New("too many feeds in OPML file (max 500)")
|
||||
)
|
||||
|
||||
// maxOPMLFeeds bounds a single OPML import.
|
||||
const maxOPMLFeeds = 500
|
||||
|
||||
// validFeedURL accepts only absolute http(s) URLs.
|
||||
func validFeedURL(raw string) (string, bool) {
|
||||
u, err := url.ParseRequestURI(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || len(raw) > 2048 {
|
||||
return "", false
|
||||
}
|
||||
return u.String(), true
|
||||
}
|
||||
|
||||
// FeedService handles feed-related business logic.
|
||||
type FeedService struct {
|
||||
feedRepo domain.FeedRepository
|
||||
@@ -44,15 +59,12 @@ type AddFeedResponse struct {
|
||||
|
||||
// AddFeed creates a new feed subscription.
|
||||
func (s *FeedService) AddFeed(req AddFeedRequest) (*AddFeedResponse, error) {
|
||||
// Validate URL
|
||||
parsedURL, err := url.ParseRequestURI(req.URL)
|
||||
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
|
||||
// Validate and normalize URL
|
||||
normalizedURL, ok := validFeedURL(strings.TrimSpace(req.URL))
|
||||
if !ok {
|
||||
return nil, ErrInvalidURL
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
normalizedURL := parsedURL.String()
|
||||
|
||||
// Check if feed already exists for this user
|
||||
existing, err := s.feedRepo.GetByURL(req.UserID, normalizedURL)
|
||||
if err != nil {
|
||||
@@ -163,20 +175,28 @@ type ImportOPMLResult struct {
|
||||
// ImportOPML imports feeds from an OPML file.
|
||||
func (s *FeedService) ImportOPML(userID uuid.UUID, opmlFeeds []OPMLFeedInfo) (*ImportOPMLResult, error) {
|
||||
result := &ImportOPMLResult{}
|
||||
if len(opmlFeeds) > maxOPMLFeeds {
|
||||
return nil, ErrTooManyFeeds
|
||||
}
|
||||
|
||||
for _, opmlFeed := range opmlFeeds {
|
||||
// Validate URL
|
||||
_, err := url.ParseRequestURI(opmlFeed.URL)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Invalid URL: %s", opmlFeed.URL))
|
||||
// Validate URL (http/https only)
|
||||
feedURL, ok := validFeedURL(strings.TrimSpace(opmlFeed.URL))
|
||||
if !ok {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Invalid URL: %.200s", opmlFeed.URL))
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
opmlFeed.URL = feedURL
|
||||
if _, ok := validFeedURL(opmlFeed.SiteURL); !ok {
|
||||
opmlFeed.SiteURL = ""
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
existing, err := s.feedRepo.GetByURL(userID, opmlFeed.URL)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Error checking %s: %v", opmlFeed.URL, err))
|
||||
log.Printf("OPML import: checking %s: %v", opmlFeed.URL, err)
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Could not import %.200s", opmlFeed.URL))
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
@@ -202,7 +222,8 @@ func (s *FeedService) ImportOPML(userID uuid.UUID, opmlFeeds []OPMLFeedInfo) (*I
|
||||
}
|
||||
|
||||
if err := s.feedRepo.Create(feed); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Error creating %s: %v", opmlFeed.URL, err))
|
||||
log.Printf("OPML import: creating %s: %v", opmlFeed.URL, err)
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Could not import %.200s", opmlFeed.URL))
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user