Titles are trimmed and truncated to 200 characters; language must be
blank or match ^[a-zA-Z0-9+#-]{1,40}$ (max 40 chars) or the create is
rejected with a clear 400. Regression tests cover truncation, malformed
and oversized language values, valid identifiers, and blank metadata.
137 lines
4.2 KiB
Go
137 lines
4.2 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// #68 input-validation helpers. Paste/can 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
|
|
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])
|
|
}
|