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

@@ -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
htmlParts = append(htmlParts, fmt.Sprintf(
`<strong><a href="%s">%s</a></strong>`,
html.EscapeString(s.ArticleURL),
html.EscapeString(s.Headline),
))
if href := safeHref(s.ArticleURL); href != "" {
htmlParts = append(htmlParts, fmt.Sprintf(
`<strong><a href="%s">%s</a></strong>`,
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))
}