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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user