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
}
// Force password reset on next login by setting the flag back.
h.deps.Users.SetMustResetPassword(id, true)
if err := h.deps.Users.SetMustResetPassword(id, true); err != nil {
slog.Error("set must-reset-password error", "error", err)
}
// Invalidate all sessions for this user.
if delErr := h.deps.Sessions.DeleteAllForUser(id); delErr != nil {
@ -252,7 +253,7 @@ func (h *Handler) handleAdminSignupRequestAction(w http.ResponseWriter, r *http.
case "approve":
if err := h.deps.SignupRequests.Approve(id, admin.ID); err != nil {
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
}
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 (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
@ -236,9 +237,15 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
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 {
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)
@ -269,7 +276,11 @@ func (h *Handler) handleDeleteFave(w http.ResponseWriter, r *http.Request) {
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)
}
@ -349,7 +360,7 @@ func (h *Handler) handleGetUserFaves(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
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 {
jsonError(w, "Internal error", http.StatusInternalServerError)
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) {
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 {
Description string `json:"description"`
URL string `json:"url"`

View file

@ -4,6 +4,7 @@ package handler
import (
"errors"
"html"
"log/slog"
"net/http"
"strconv"
@ -42,7 +43,7 @@ func (h *Handler) handleFeedGlobal(w http.ResponseWriter, r *http.Request) {
Title: siteName + " — Siste favoritter",
Link: &feeds.Link{Href: baseURL},
Description: "Siste offentlige favoritter",
Updated: time.Now(),
Updated: feedUpdatedTime(faves),
}
feed.Items = favesToFeedItems(faves, baseURL)
@ -84,7 +85,7 @@ func (h *Handler) handleFeedUser(w http.ResponseWriter, r *http.Request) {
feed := &feeds.Feed{
Title: user.DisplayNameOrUsername() + " sine favoritter",
Link: &feeds.Link{Href: baseURL + "/u/" + user.Username},
Updated: time.Now(),
Updated: feedUpdatedTime(faves),
}
feed.Items = favesToFeedItems(faves, baseURL)
@ -110,7 +111,7 @@ func (h *Handler) handleFeedTag(w http.ResponseWriter, r *http.Request) {
feed := &feeds.Feed{
Title: "Favoritter med merkelapp: " + tagName,
Link: &feeds.Link{Href: baseURL + "/tags/" + tagName},
Updated: time.Now(),
Updated: feedUpdatedTime(faves),
}
feed.Items = favesToFeedItems(faves, baseURL)
@ -129,7 +130,8 @@ func favesToFeedItems(faves []*model.Fave, baseURL string) []*feeds.Item {
}
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 != "" {
@ -155,6 +157,15 @@ func (h *Handler) writeAtom(w http.ResponseWriter, feed *feeds.Feed) {
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 {
return strconv.FormatInt(n, 10)
}

View file

@ -15,6 +15,8 @@ import (
"kode.naiv.no/olemd/favoritter/internal/render"
)
const maxExportFaves = 100000
// ExportFave is the JSON representation for export/import.
type ExportFave struct {
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) {
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 {
slog.Error("export: list faves error", "error", err)
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) {
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 {
slog.Error("export: list faves error", "error", err)
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.
// The new user will have must_reset_password=1.
func (s *SignupRequestStore) Approve(id int64, adminID int64) error {
sr, err := s.GetByID(id)
tx, err := s.db.Begin()
if err != nil {
return err
return fmt.Errorf("begin tx: %w", err)
}
if sr.Status != "pending" {
return fmt.Errorf("request is not pending (status: %s)", sr.Status)
defer tx.Rollback()
// 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.
_, err = s.db.Exec(
_, err = tx.Exec(
`INSERT INTO users (username, password_hash, must_reset_password) VALUES (?, ?, 1)`,
sr.Username, sr.PasswordHash,
username, passwordHash,
)
if err != nil {
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.
_, err = s.db.Exec(
_, err = tx.Exec(
`UPDATE signup_requests SET status = 'approved',
reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
reviewed_by = ?
WHERE 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.