Files
palette/internal/api/validate.go
T
fen e54fbc36ef
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
Remove cans feature; add Secure flag to pw_/tok_ cookies
Owner decision 2026-10-16: the cans feature (bundles of pastes, #4) was
half-integrated and has zero prod usage; removing it eliminates four audit
findings by deletion.

Removed:
- internal/api/cans.go and all can routes (/api/pastes/can, /api/cans/*,
  /can/{id}) plus handleCanPage/renderCan
- is_can from /api/mine and /api/public responses
- store layer: CanRow, can store funcs, paste_cans UNION in listings,
  cans sweep; reserved slugs 'can'/'cans' released
- templates/can.html, can CSS, is_can badges in history.js/mine.js
- cans tests (cans_test.go, cans_flow_test.go, cans_expiry_test.go,
  TestErrorCodeCanValidation)
- docs/api.md cans section, README/LICENSE/docker-compose mentions

Store migration: drops paste_cans only when empty (non-empty table is
kept and logged; prod verified clean 2026-10-16) and drops the dormant
pastes.can_id column. Covered by new TestMigrateDropsCansSchema.

Security hardening (audit findings): pw_<id> (unlock) and tok_<id>
(deletion-token) cookies now carry Secure: true in addition to
HttpOnly + SameSite=Lax; all deployments are HTTPS-only. cookie_set_test
now asserts Secure.

go vet ./... and go test ./... pass.
2026-10-16 16:50:40 -05:00

141 lines
4.4 KiB
Go

package api
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
)
// #68 input-validation helpers. Paste payloads are size-capped and list
// endpoints get a single place where limit/offset are parsed and clamped.
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
// admin-tunable content cap plus headroom for JSON field overhead, floored
// at 64KiB so a tiny admin-configured cap can't break small requests.
func (a *apiServer) maxRequestBody() int64 {
s := a.settings.get()
max := s.MaxContentBytes + 4096
// #38: multipart create carries up to one 25 MB file plus overhead
if a.cfg.MaxItemBytes+1<<20 > max {
max = a.cfg.MaxItemBytes + 1<<20
}
if max < 64*1024 {
max = 64 * 1024
}
return max
}
// limitRequestBody wraps the request body with http.MaxBytesReader so
// oversized payloads are cut off server-side instead of being fully decoded
// into memory before the per-field size check runs (#68). A read over the
// cap surfaces as *http.MaxBytesError, which handlers map to 413.
func (a *apiServer) limitRequestBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, a.maxRequestBody())
}
next.ServeHTTP(w, r)
})
}
// writeBodyTooLarge emits the 413 response for a body rejected by
// MaxBytesReader.
func writeBodyTooLarge(w http.ResponseWriter) {
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
}
// isBodyTooLarge reports whether err came from http.MaxBytesReader.
func isBodyTooLarge(err error) bool {
var mbe *http.MaxBytesError
return errors.As(err, &mbe)
}
// checkContent validates paste content: rejects whitespace-only content
// (400) and content over the byte cap (413). Returns (0, "") when valid.
func checkContent(content string, maxBytes int64) (int, string) {
if strings.TrimSpace(content) == "" {
return http.StatusBadRequest, "content is required"
}
if int64(len(content)) > maxBytes { // #40/#68: admin-tunable cap
return http.StatusRequestEntityTooLarge,
fmt.Sprintf("content exceeds max %d bytes", maxBytes)
}
return 0, ""
}
// parseLimit clamps the ?limit query param: missing/non-numeric/zero/negative
// or over-max values fall back to def. Zero intentionally maps to the default
// page size, matching the pre-existing `<= 0` clamp (#68).
func parseLimit(r *http.Request, def, max int) int {
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || n <= 0 || n > max {
return def
}
return n
}
// parseOffset clamps the ?offset query param: missing/non-numeric or negative
// values become 0 (#68: negative offsets previously passed through to SQL).
func parseOffset(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("offset"))
if err != nil || n < 0 {
return 0
}
return n
}
// parseBurnAfterReads validates the burn_after_reads field (#68): negative
// values are rejected; zero/absent mean the default single read.
func parseBurnAfterReads(n int) error {
if n < 0 {
return errors.New("burn_after_reads must be a positive number")
}
return nil
}
// #86: bounds for free-form metadata fields on create.
const (
maxTitleLen = 200
maxLanguageLen = 40
)
// languageRe restricts language to identifiers like go, c#, f#, c++, objc.
var languageRe = regexp.MustCompile(`^[a-zA-Z0-9+#-]{1,40}$`)
// checkTitle validates the paste title (#86): over-max titles are truncated
// to 200 characters so a bloated listing entry can't be stored; whitespace
// is trimmed first.
func checkTitle(title string) (string, error) {
title = strings.TrimSpace(title)
if len(title) > maxTitleLen {
return truncateRunes(title, maxTitleLen), nil
}
return title, nil
}
// checkLanguage validates the language field (#86): optional, max 40 chars,
// and must match ^[a-zA-Z0-9+#-]{1,40}$. Returns "" for absent/blank values.
// Anything else malformed is a 400.
func checkLanguage(lang string) (string, error) {
lang = strings.TrimSpace(lang)
if lang == "" {
return "", nil
}
if len(lang) > maxLanguageLen || !languageRe.MatchString(lang) {
return "", fmt.Errorf("language must match ^[a-zA-Z0-9+#-]{1,40}$ (max %d chars)", maxLanguageLen)
}
return lang, nil
}
// truncateRunes cuts s to at most max runes, keeping the prefix intact.
func truncateRunes(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}