fix: address security and quality issues from code review

Security fixes:
- Fix XSS in Atom feed: escape user-supplied URLs in HTML content
- Wrap signup request approval in a transaction to prevent
  partial state on crash (user created but request still pending)
- Stop leaking internal error messages to admin UI
- Add request body size limit on API import endpoint
- Log SetMustResetPassword errors instead of silently discarding

Correctness fixes:
- Handle errors from API fave update/delete instead of returning
  success on failure
- Use actual data timestamp for feed <updated> instead of
  time.Now() (improves HTTP caching)
- Replace hardcoded 10000 export limit with named constant
  (maxExportFaves = 100000)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-29 16:19:44 +02:00
commit 395b1b7523
5 changed files with 63 additions and 21 deletions

View file

@ -101,8 +101,9 @@ func (h *Handler) handleAdminResetPassword(w http.ResponseWriter, r *http.Reques
return return
} }
// Force password reset on next login by setting the flag back. if err := h.deps.Users.SetMustResetPassword(id, true); err != nil {
h.deps.Users.SetMustResetPassword(id, true) slog.Error("set must-reset-password error", "error", err)
}
// Invalidate all sessions for this user. // Invalidate all sessions for this user.
if delErr := h.deps.Sessions.DeleteAllForUser(id); delErr != nil { if delErr := h.deps.Sessions.DeleteAllForUser(id); delErr != nil {
@ -252,7 +253,7 @@ func (h *Handler) handleAdminSignupRequestAction(w http.ResponseWriter, r *http.
case "approve": case "approve":
if err := h.deps.SignupRequests.Approve(id, admin.ID); err != nil { if err := h.deps.SignupRequests.Approve(id, admin.ID); err != nil {
slog.Error("approve signup request error", "error", err) slog.Error("approve signup request error", "error", err)
h.adminRequestsFlash(w, r, "Noe gikk galt: "+err.Error(), "error") h.adminRequestsFlash(w, r, "Noe gikk galt ved godkjenning.", "error")
return return
} }
h.adminRequestsFlash(w, r, "Forespørsel godkjent. Brukeren må endre passord ved første innlogging.", "success") h.adminRequestsFlash(w, r, "Forespørsel godkjent. Brukeren må endre passord ved første innlogging.", "success")

View file

@ -6,6 +6,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
@ -236,9 +237,15 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
req.Privacy = fave.Privacy req.Privacy = fave.Privacy
} }
h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Privacy) if err := h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Privacy); err != nil {
slog.Error("api: update fave error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
if req.Tags != nil { if req.Tags != nil {
h.deps.Tags.SetFaveTags(id, req.Tags) if err := h.deps.Tags.SetFaveTags(id, req.Tags); err != nil {
slog.Error("api: set tags error", "error", err)
}
} }
updated, _ := h.deps.Faves.GetByID(id) updated, _ := h.deps.Faves.GetByID(id)
@ -269,7 +276,11 @@ func (h *Handler) handleDeleteFave(w http.ResponseWriter, r *http.Request) {
return return
} }
h.deps.Faves.Delete(id) if err := h.deps.Faves.Delete(id); err != nil {
slog.Error("api: delete fave error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@ -349,7 +360,7 @@ func (h *Handler) handleGetUserFaves(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context()) user := middleware.UserFromContext(r.Context())
faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0) faves, _, err := h.deps.Faves.ListByUser(user.ID, 100000, 0)
if err != nil { if err != nil {
jsonError(w, "Internal error", http.StatusInternalServerError) jsonError(w, "Internal error", http.StatusInternalServerError)
return return
@ -361,6 +372,9 @@ func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context()) user := middleware.UserFromContext(r.Context())
// Limit request body to prevent memory exhaustion.
r.Body = io.NopCloser(io.LimitReader(r.Body, h.deps.Config.MaxUploadSize))
var faves []struct { var faves []struct {
Description string `json:"description"` Description string `json:"description"`
URL string `json:"url"` URL string `json:"url"`

View file

@ -4,6 +4,7 @@ package handler
import ( import (
"errors" "errors"
"html"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
@ -42,7 +43,7 @@ func (h *Handler) handleFeedGlobal(w http.ResponseWriter, r *http.Request) {
Title: siteName + " — Siste favoritter", Title: siteName + " — Siste favoritter",
Link: &feeds.Link{Href: baseURL}, Link: &feeds.Link{Href: baseURL},
Description: "Siste offentlige favoritter", Description: "Siste offentlige favoritter",
Updated: time.Now(), Updated: feedUpdatedTime(faves),
} }
feed.Items = favesToFeedItems(faves, baseURL) feed.Items = favesToFeedItems(faves, baseURL)
@ -84,7 +85,7 @@ func (h *Handler) handleFeedUser(w http.ResponseWriter, r *http.Request) {
feed := &feeds.Feed{ feed := &feeds.Feed{
Title: user.DisplayNameOrUsername() + " sine favoritter", Title: user.DisplayNameOrUsername() + " sine favoritter",
Link: &feeds.Link{Href: baseURL + "/u/" + user.Username}, Link: &feeds.Link{Href: baseURL + "/u/" + user.Username},
Updated: time.Now(), Updated: feedUpdatedTime(faves),
} }
feed.Items = favesToFeedItems(faves, baseURL) feed.Items = favesToFeedItems(faves, baseURL)
@ -110,7 +111,7 @@ func (h *Handler) handleFeedTag(w http.ResponseWriter, r *http.Request) {
feed := &feeds.Feed{ feed := &feeds.Feed{
Title: "Favoritter med merkelapp: " + tagName, Title: "Favoritter med merkelapp: " + tagName,
Link: &feeds.Link{Href: baseURL + "/tags/" + tagName}, Link: &feeds.Link{Href: baseURL + "/tags/" + tagName},
Updated: time.Now(), Updated: feedUpdatedTime(faves),
} }
feed.Items = favesToFeedItems(faves, baseURL) feed.Items = favesToFeedItems(faves, baseURL)
@ -129,7 +130,8 @@ func favesToFeedItems(faves []*model.Fave, baseURL string) []*feeds.Item {
} }
if f.URL != "" { if f.URL != "" {
item.Content = `<p><a href="` + f.URL + `">` + f.URL + `</a></p>` escaped := html.EscapeString(f.URL)
item.Content = `<p><a href="` + escaped + `">` + escaped + `</a></p>`
} }
if f.ImagePath != "" { if f.ImagePath != "" {
@ -155,6 +157,15 @@ func (h *Handler) writeAtom(w http.ResponseWriter, feed *feeds.Feed) {
w.Write([]byte(atom)) w.Write([]byte(atom))
} }
// feedUpdatedTime returns the most recent UpdatedAt from the faves,
// falling back to now if the list is empty.
func feedUpdatedTime(faves []*model.Fave) time.Time {
if len(faves) > 0 {
return faves[0].UpdatedAt
}
return time.Now()
}
func itoa(n int64) string { func itoa(n int64) string {
return strconv.FormatInt(n, 10) return strconv.FormatInt(n, 10)
} }

View file

@ -15,6 +15,8 @@ import (
"kode.naiv.no/olemd/favoritter/internal/render" "kode.naiv.no/olemd/favoritter/internal/render"
) )
const maxExportFaves = 100000
// ExportFave is the JSON representation for export/import. // ExportFave is the JSON representation for export/import.
type ExportFave struct { type ExportFave struct {
Description string `json:"description"` Description string `json:"description"`
@ -35,7 +37,7 @@ func (h *Handler) handleExportPage(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context()) user := middleware.UserFromContext(r.Context())
faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0) faves, _, err := h.deps.Faves.ListByUser(user.ID, maxExportFaves, 0)
if err != nil { if err != nil {
slog.Error("export: list faves error", "error", err) slog.Error("export: list faves error", "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError) http.Error(w, "Internal error", http.StatusInternalServerError)
@ -72,7 +74,7 @@ func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context()) user := middleware.UserFromContext(r.Context())
faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0) faves, _, err := h.deps.Faves.ListByUser(user.ID, maxExportFaves, 0)
if err != nil { if err != nil {
slog.Error("export: list faves error", "error", err) slog.Error("export: list faves error", "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError) http.Error(w, "Internal error", http.StatusInternalServerError)

View file

@ -102,18 +102,28 @@ func (s *SignupRequestStore) ListPending() ([]*model.SignupRequest, error) {
// Approve marks a request as approved and creates the user account. // Approve marks a request as approved and creates the user account.
// The new user will have must_reset_password=1. // The new user will have must_reset_password=1.
func (s *SignupRequestStore) Approve(id int64, adminID int64) error { func (s *SignupRequestStore) Approve(id int64, adminID int64) error {
sr, err := s.GetByID(id) tx, err := s.db.Begin()
if err != nil { if err != nil {
return err return fmt.Errorf("begin tx: %w", err)
} }
if sr.Status != "pending" { defer tx.Rollback()
return fmt.Errorf("request is not pending (status: %s)", sr.Status)
// Fetch and verify the request is still pending, within the transaction.
var username, passwordHash, status string
err = tx.QueryRow(
`SELECT username, password_hash, status FROM signup_requests WHERE id = ?`, id,
).Scan(&username, &passwordHash, &status)
if err != nil {
return ErrSignupRequestNotFound
}
if status != "pending" {
return fmt.Errorf("request is not pending (status: %s)", status)
} }
// Create the user with the already-hashed password. // Create the user with the already-hashed password.
_, err = s.db.Exec( _, err = tx.Exec(
`INSERT INTO users (username, password_hash, must_reset_password) VALUES (?, ?, 1)`, `INSERT INTO users (username, password_hash, must_reset_password) VALUES (?, ?, 1)`,
sr.Username, sr.PasswordHash, username, passwordHash,
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") { if strings.Contains(err.Error(), "UNIQUE constraint failed") {
@ -123,14 +133,18 @@ func (s *SignupRequestStore) Approve(id int64, adminID int64) error {
} }
// Mark the request as approved. // Mark the request as approved.
_, err = s.db.Exec( _, err = tx.Exec(
`UPDATE signup_requests SET status = 'approved', `UPDATE signup_requests SET status = 'approved',
reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
reviewed_by = ? reviewed_by = ?
WHERE id = ?`, WHERE id = ?`,
adminID, id, adminID, id,
) )
return err if err != nil {
return fmt.Errorf("update request status: %w", err)
}
return tx.Commit()
} }
// Reject marks a request as rejected. // Reject marks a request as rejected.