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