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:
Antigravity AgentandClaude Opus 5.5 committed 2026-10-09 07:34:08 +02:00
1 parent d037e2be34
commit 03e57e4308
40 files changed
+2231 -1898

No files matched your search

+33 -12
View File
@@ -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
}