Harden security: SSRF guard on all outbound fetches, auth fixes

- Route image-validation, Matrix image-download, and wayback/archive.today
  fetches through safehttp so feed-controlled URLs can't reach loopback/
  RFC1918/cloud-metadata IPs (incl. via redirects).
- Cap feed body size with safehttp.LimitedBody (16 MiB) to prevent OOM.
- Fail closed if matrix.pickle_key is unset/<16 chars; drop the hardcoded
  "pete_pickle_key" default that silently weakened E2EE-at-rest.
- Gate !post behind a matrix.admins allowlist; empty = disabled (channel
  rooms are public, so empty must not mean anyone).
- Reject /\host open-redirect bypass in post-login safeNext.
- Allowlist http(s) schemes for the Matrix link href; escape JSON embedded
  in inline <script> (prefs/sources blobs).
This commit is contained in:
prosolis
2026-06-21 16:21:03 -07:00
parent cbbedd9894
commit d5d0656dba
11 changed files with 127 additions and 35 deletions

View File

@@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"time"
"pete/internal/safehttp"
)
// NormalizeImageURL rewrites known CDN thumbnail URLs to a higher-resolution
@@ -36,15 +38,10 @@ func NormalizeImageURL(raw string) string {
return u.String()
}
var imageClient = &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
}
// imageClient validates feed-supplied image URLs. It routes through safehttp
// so a hostile feed can't steer the HEAD probe at loopback / RFC1918 / cloud
// metadata IPs — the dial-time guard also re-checks every redirect target.
var imageClient = safehttp.NewClient(10 * time.Second)
// ValidateImageURL checks that an image URL returns a valid image response.
// Returns false (with no error) on any failure — story posts without image.

View File

@@ -2,8 +2,10 @@ package ingestion
import (
"context"
"fmt"
"html"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
@@ -17,6 +19,11 @@ import (
const userAgent = "Pete/1.0 (newsbot; +https://github.com/reala-misaki/pete)"
// maxFeedBytes caps how much of a feed body we'll buffer into the parser, so a
// hostile feed streaming an endless body within the request timeout can't OOM
// the process. Generous headroom — real RSS/Atom feeds are well under this.
const maxFeedBytes = 16 << 20
var feedClient = safehttp.NewClient(30 * time.Second)
// FeedItem represents a parsed RSS item ready for routing.
@@ -44,11 +51,21 @@ func FetchFeed(feedURL string) ([]FeedItem, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fp := gofeed.NewParser()
fp.Client = feedClient
fp.UserAgent = userAgent
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
resp, err := feedClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed fetch %s: status %d", feedURL, resp.StatusCode)
}
feed, err := fp.ParseURLWithContext(feedURL, ctx)
feed, err := gofeed.NewParser().Parse(safehttp.LimitedBody(resp.Body, maxFeedBytes))
if err != nil {
return nil, err
}

View File

@@ -7,9 +7,11 @@ import (
"net/url"
"strings"
"time"
"pete/internal/safehttp"
)
var waybackClient = &http.Client{Timeout: 10 * time.Second}
var waybackClient = safehttp.NewClient(10 * time.Second)
// maxSnapshotAge bounds how stale a Wayback snapshot can be before we treat
// it as useless for a freshly-published news article. The "closest" snapshot
@@ -86,14 +88,16 @@ func snapshotFreshEnough(ts string) bool {
return time.Since(t) <= maxSnapshotAge
}
var archiveTodayClient = &http.Client{
Timeout: 10 * time.Second,
// Don't follow the redirect — we just want the Location header pointing
// at the most recent capture.
CheckRedirect: func(*http.Request, []*http.Request) error {
// archiveTodayClient routes through safehttp for the dial-time SSRF guard, but
// overrides CheckRedirect so we read the 302 Location header (the most recent
// capture) instead of following it.
var archiveTodayClient = func() *http.Client {
c := safehttp.NewClient(10 * time.Second)
c.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
return c
}()
// ResolveArchiveToday looks up the newest archive.today / archive.ph snapshot
// for the given URL. archive.ph has no public availability API, but its