diff --git a/config.example.toml b/config.example.toml index 1ce0e4b..71b7ce0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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" diff --git a/internal/config/config.go b/internal/config/config.go index 4550c19..8df1e65 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 57c95e5..6c6b57e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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] diff --git a/internal/ingestion/image.go b/internal/ingestion/image.go index 3f036db..7ebd515 100644 --- a/internal/ingestion/image.go +++ b/internal/ingestion/image.go @@ -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. diff --git a/internal/ingestion/parser.go b/internal/ingestion/parser.go index 19c7f55..2872432 100644 --- a/internal/ingestion/parser.go +++ b/internal/ingestion/parser.go @@ -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 } diff --git a/internal/ingestion/wayback.go b/internal/ingestion/wayback.go index f8f6cc3..a42a2c5 100644 --- a/internal/ingestion/wayback.go +++ b/internal/ingestion/wayback.go @@ -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 diff --git a/internal/matrix/client.go b/internal/matrix/client.go index 0d4c01f..ef73eab 100644 --- a/internal/matrix/client.go +++ b/internal/matrix/client.go @@ -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) } diff --git a/internal/matrix/post.go b/internal/matrix/post.go index d1f0642..8639aa0 100644 --- a/internal/matrix/post.go +++ b/internal/matrix/post.go @@ -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 - htmlParts = append(htmlParts, fmt.Sprintf( - `%s`, - html.EscapeString(s.ArticleURL), - html.EscapeString(s.Headline), - )) + if href := safeHref(s.ArticleURL); href != "" { + htmlParts = append(htmlParts, fmt.Sprintf( + `%s`, + html.EscapeString(href), + html.EscapeString(s.Headline), + )) + } else { + // Non-http(s) link: render the headline without an anchor. + htmlParts = append(htmlParts, fmt.Sprintf(`%s`, html.EscapeString(s.Headline))) + } if s.Lede != "" { htmlParts = append(htmlParts, html.EscapeString(s.Lede)) } diff --git a/internal/web/auth.go b/internal/web/auth.go index 13918d0..b4d9b6e 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -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 diff --git a/internal/web/handlers.go b/internal/web/handlers.go index cff5b5d..d54a9a8 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -82,6 +82,21 @@ type channelStat struct { Total int } +// jsForScript marks already-valid JSON as safe to embed in an inline , HTML comments, and the JS line separators U+2028/U+2029). Without +// this, a stored prefs blob containing "" 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)) } } } diff --git a/main.go b/main.go index cf5d7f9..d432874 100644 --- a/main.go +++ b/main.go @@ -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