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

@@ -6,6 +6,9 @@ pickle_key = "${PETE_PICKLE_KEY}"
display_name = "Pete"
data_dir = "./data"
admin_room = "!adminroomid:matrix.example.org"
# Matrix user IDs allowed to run in-room commands like !post. Empty disables
# commands entirely (channel rooms may be public, so empty != "anyone").
admins = ["@you:matrix.example.org"]
[matrix.channels]
tech = "!techroomid:matrix.example.org"

View File

@@ -50,6 +50,10 @@ type MatrixConfig struct {
DataDir string `toml:"data_dir"`
AdminRoom string `toml:"admin_room"`
Channels map[string]string `toml:"channels"`
// Admins is the allowlist of Matrix user IDs permitted to run in-room
// commands like !post. Empty means commands are disabled — the channel
// rooms are public, so an empty list must NOT mean "anyone".
Admins []string `toml:"admins"`
}
type PostingConfig struct {
@@ -130,6 +134,12 @@ func (c *Config) validate() error {
if c.Matrix.Password == "" {
return fmt.Errorf("matrix.password is required")
}
// pickle_key encrypts the E2EE crypto store (olm/megolm + cross-signing
// keys) at rest. Never fall back to a hardcoded default: a key baked into
// the source is no protection if crypto.db ever leaks.
if len(c.Matrix.PickleKey) < 16 {
return fmt.Errorf("matrix.pickle_key must be set to a strong secret (>=16 chars); it encrypts the E2EE crypto store at rest")
}
if len(c.Matrix.Channels) == 0 {
return fmt.Errorf("matrix.channels must have at least one entry")
}
@@ -184,9 +194,6 @@ func (c *Config) applyDefaults() {
if c.Matrix.DisplayName == "" {
c.Matrix.DisplayName = "Pete"
}
if c.Matrix.PickleKey == "" {
c.Matrix.PickleKey = "pete_pickle_key"
}
if c.Posting.MinIntervalSeconds == 0 {
c.Posting.MinIntervalSeconds = 300
}

View File

@@ -12,6 +12,7 @@ const validMatrix = `
homeserver = "https://matrix.example.org"
user_id = "@pete:example.org"
password = "testpass"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!tech:example.org"
politics = "!politics:example.org"
@@ -57,6 +58,7 @@ func TestEnvVarExpansion(t *testing.T) {
homeserver = "https://matrix.example.org"
user_id = "@pete:example.org"
password = "${TEST_PETE_PASS}"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!tech:example.org"
@@ -114,6 +116,7 @@ func TestValidationErrors(t *testing.T) {
[matrix]
user_id = "@p:h"
password = "pw"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!t:e"
[storage]
@@ -123,6 +126,7 @@ db_path = "/tmp/t.db"
[matrix]
homeserver = "https://h"
password = "pw"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!t:e"
[storage]
@@ -142,6 +146,7 @@ db_path = "/tmp/t.db"
homeserver = "https://h"
user_id = "@p:h"
password = "pw"
pickle_key = "test_pickle_key_1234"
[storage]
db_path = "/tmp/t.db"
`},
@@ -150,6 +155,7 @@ db_path = "/tmp/t.db"
homeserver = "https://h"
user_id = "@p:h"
password = "pw"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!t:e"
[storage]
@@ -159,6 +165,7 @@ tech = "!t:e"
homeserver = "https://h"
user_id = "@p:h"
password = "pw"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!t:e"
[storage]
@@ -176,6 +183,7 @@ enabled = true
homeserver = "https://h"
user_id = "@p:h"
password = "pw"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!t:e"
[storage]
@@ -192,6 +200,7 @@ enabled = true
homeserver = "https://h"
user_id = "@p:h"
password = "pw"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!t:e"
[storage]
@@ -225,6 +234,7 @@ func TestDisabledSourceSkipsDirectRouteCheck(t *testing.T) {
homeserver = "https://h"
user_id = "@p:h"
password = "pw"
pickle_key = "test_pickle_key_1234"
[matrix.channels]
tech = "!t:e"
[storage]

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

View File

@@ -19,6 +19,7 @@ import (
"time"
"pete/internal/config"
"pete/internal/safehttp"
_ "go.mau.fi/util/dbutil/litestream" // registers "sqlite3-fk-wal" driver used by cryptohelper
@@ -430,14 +431,18 @@ func (c *Client) PostStory(channel string, story *PostableStory) (eventID id.Eve
return resp.EventID, imageSent, nil
}
// imageDownloadClient fetches feed-supplied image URLs for re-upload to Matrix.
// It routes through safehttp so a hostile feed can't point ImageURL at internal
// services (loopback / RFC1918 / cloud metadata) — including via redirects.
var imageDownloadClient = safehttp.NewClient(15 * time.Second)
// sendImage downloads an image, uploads it to Matrix, and sends it as m.image.
func (c *Client) sendImage(ctx context.Context, roomID id.RoomID, imageURL string) error {
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
if err != nil {
return fmt.Errorf("create image request: %w", err)
}
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Do(req)
resp, err := imageDownloadClient.Do(req)
if err != nil {
return fmt.Errorf("download image: %w", err)
}

View File

@@ -3,9 +3,21 @@ package matrix
import (
"fmt"
"html"
"net/url"
"strings"
)
// safeHref returns raw if it is an http(s) URL, else "". Keeps feed-supplied
// links from injecting javascript:/data: schemes into the formatted_body href
// (html.EscapeString blocks attribute breakout but not the scheme itself).
func safeHref(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return ""
}
return raw
}
// PostableStory contains the data needed to format and send a story.
type PostableStory struct {
ImageURL string
@@ -30,11 +42,16 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
// HTML body
var htmlParts []string
if href := safeHref(s.ArticleURL); href != "" {
htmlParts = append(htmlParts, fmt.Sprintf(
`<strong><a href="%s">%s</a></strong>`,
html.EscapeString(s.ArticleURL),
html.EscapeString(href),
html.EscapeString(s.Headline),
))
} else {
// Non-http(s) link: render the headline without an anchor.
htmlParts = append(htmlParts, fmt.Sprintf(`<strong>%s</strong>`, html.EscapeString(s.Headline)))
}
if s.Lede != "" {
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
}

View File

@@ -261,9 +261,12 @@ func (a *Authenticator) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
// safeNext keeps post-login redirects on-site (no open redirects).
// safeNext keeps post-login redirects on-site (no open redirects). It rejects
// "//host" and "/\host" — browsers normalize the backslash to a slash, so the
// latter would otherwise resolve to a protocol-relative off-site URL.
func safeNext(next string) string {
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
if next == "" || !strings.HasPrefix(next, "/") ||
strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/"
}
return next

View File

@@ -82,6 +82,21 @@ type channelStat struct {
Total int
}
// jsForScript marks already-valid JSON as safe to embed in an inline <script>,
// after neutralizing the byte sequences that could break out of that context
// (</script>, HTML comments, and the JS line separators U+2028/U+2029). Without
// this, a stored prefs blob containing "</script>" could inject markup.
func jsForScript(b []byte) template.JS {
r := strings.NewReplacer(
"<", "\\u003c",
">", "\\u003e",
"&", "\\u0026",
"", "\\u2028",
"", "\\u2029",
)
return template.JS(r.Replace(string(b)))
}
func (s *Server) base(r *http.Request) pageData {
srcJSON, err := json.Marshal(s.sources)
if err != nil {
@@ -91,7 +106,7 @@ func (s *Server) base(r *http.Request) pageData {
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Weather: currentWeather(time.Now()),
AllSources: template.JS(srcJSON),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
UserPrefs: template.JS("null"),
Path: r.URL.Path,
@@ -100,7 +115,7 @@ func (s *Server) base(r *http.Request) pageData {
if u := s.auth.userFromRequest(r); u != nil {
d.User = u
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
d.UserPrefs = template.JS(blob)
d.UserPrefs = jsForScript([]byte(blob))
}
}
}

14
main.go
View File

@@ -88,11 +88,25 @@ func main() {
// Wire reaction handler
mx.SetReactionHandler(poster.HandleReaction)
// Allowlist of users permitted to run in-room commands. The channel rooms
// are public, so commands are gated to configured admins; an empty list
// disables them entirely (fail closed) rather than allowing anyone.
admins := make(map[id.UserID]bool, len(cfg.Matrix.Admins))
for _, a := range cfg.Matrix.Admins {
if a = strings.TrimSpace(a); a != "" {
admins[id.UserID(a)] = true
}
}
// Wire !post command: force-publish next queued story for the room's channel.
mx.SetMessageHandler(func(channel string, roomID id.RoomID, eventID id.EventID, sender id.UserID, body string) {
if strings.TrimSpace(body) != "!post" {
return
}
if !admins[sender] {
slog.Warn("!post ignored: sender not in matrix.admins allowlist", "channel", channel, "sender", sender)
return
}
slog.Info("!post requested", "channel", channel, "sender", sender)
if queue.ForcePost(channel) {
return