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

@@ -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))
}
}
}