Files
warmbly/internal/errx/bind.go
T
Matthew MeszarosandDevin AI 4d3c782ca1 feat: reject unsupported raw API query text before mailbox and campaign searches reach the database
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-06 12:22:33 +00:00

302 lines
8.7 KiB
Go

package errx
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// maxBindProblems caps how many validation failures one refusal lists.
const maxBindProblems = 5
// Validation failures name fields as the caller wrote them (the json key), not as Go does.
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterTagNameFunc(jsonFieldName)
}
binding.Validator = rejectingTextValidator{delegate: binding.Validator}
}
type rejectingTextValidator struct {
delegate binding.StructValidator
}
func (v rejectingTextValidator) ValidateStruct(value any) error {
if err := v.delegate.ValidateStruct(value); err != nil {
return err
}
return ValidateText(value)
}
func (v rejectingTextValidator) Engine() any {
return v.delegate.Engine()
}
type unsupportedTextError struct {
field string
reason string
}
func (e *unsupportedTextError) Error() string {
return fmt.Sprintf("field %q %s", e.field, e.reason)
}
// ValidateText rejects text PostgreSQL cannot store in a request value.
func ValidateText(value any) error {
return unsupportedText(reflect.ValueOf(value), "")
}
func unsupportedText(value reflect.Value, path string) error {
if !value.IsValid() {
return nil
}
for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
if value.IsNil() {
return nil
}
value = value.Elem()
}
switch value.Kind() {
case reflect.String:
var reason string
if strings.ContainsRune(value.String(), '\x00') {
reason = "contains a NUL character"
} else if !utf8.ValidString(value.String()) {
reason = "contains invalid UTF-8"
}
if reason != "" {
if path == "" {
path = "value"
}
return &unsupportedTextError{field: path, reason: reason}
}
case reflect.Struct:
typeOf := value.Type()
for i := 0; i < value.NumField(); i++ {
field := typeOf.Field(i)
if field.PkgPath != "" {
continue
}
name := boundFieldName(field)
if name == "" {
continue
}
if err := unsupportedText(value.Field(i), joinFieldPath(path, name)); err != nil {
return err
}
}
case reflect.Slice, reflect.Array:
for i := 0; i < value.Len(); i++ {
if err := unsupportedText(value.Index(i), fmt.Sprintf("%s[%d]", path, i)); err != nil {
return err
}
}
case reflect.Map:
iter := value.MapRange()
for iter.Next() {
if err := unsupportedText(iter.Key(), joinFieldPath(path, "key")); err != nil {
return err
}
key := fmt.Sprint(iter.Key().Interface())
if err := unsupportedText(iter.Value(), joinFieldPath(path, key)); err != nil {
return err
}
}
}
return nil
}
func boundFieldName(field reflect.StructField) string {
for _, tag := range []string{"json", "form"} {
name, _, _ := strings.Cut(field.Tag.Get(tag), ",")
if name == "-" {
return ""
}
if name != "" {
return name
}
}
return field.Name
}
func joinFieldPath(parent, field string) string {
if parent == "" {
return field
}
return parent + "." + field
}
func jsonFieldName(f reflect.StructField) string {
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == "-" {
return ""
}
return name
}
// InvalidBody is the 400 for a request body that failed to bind, naming what
// was wrong with it: empty, not JSON, the wrong shape, or missing a field.
func InvalidBody(err error) *Error {
var (
syntaxErr *json.SyntaxError
typeErr *json.UnmarshalTypeError
sizeErr *http.MaxBytesError
timeErr *time.ParseError
textErr *unsupportedTextError
)
switch {
case err == nil:
return ErrInvalid
case errors.Is(err, io.EOF):
return New(BadRequest, "The request body is empty. Send a JSON body with Content-Type: application/json.")
case errors.Is(err, io.ErrUnexpectedEOF):
return New(BadRequest, "The request body is not valid JSON: it ends before the value is complete.")
case errors.As(err, &sizeErr):
return New(BadRequest, fmt.Sprintf("The request body is larger than the %d byte limit.", sizeErr.Limit))
case errors.As(err, &syntaxErr):
return New(BadRequest, fmt.Sprintf("The request body is not valid JSON: %s (at byte %d).", strings.TrimPrefix(syntaxErr.Error(), "json: "), syntaxErr.Offset))
case errors.As(err, &typeErr):
return typeMismatch(typeErr)
case errors.As(err, &timeErr):
return New(BadRequest, fmt.Sprintf("The request body has a time that is not RFC 3339 (such as 2026-10-01T09:00:00Z): %q.", strings.Trim(timeErr.Value, `"`)))
case errors.As(err, &textErr):
return New(BadRequest, fmt.Sprintf("Field %q %s, which is not supported.", textErr.field, textErr.reason))
case strings.HasPrefix(err.Error(), "invalid UUID"):
return New(BadRequest, "The request body has a value that should be a UUID and is not one.")
}
if fes := fieldErrors(err); len(fes) > 0 {
return fieldProblems(fes)
}
return New(BadRequest, "The request body could not be read in the shape this endpoint expects. Check it against the API reference.")
}
// InvalidQuery is the 400 for query parameters that failed to bind.
func InvalidQuery(err error) *Error {
var textErr *unsupportedTextError
if errors.As(err, &textErr) {
return New(BadRequest, fmt.Sprintf("Query parameter %q %s, which is not supported.", textErr.field, textErr.reason))
}
return New(BadRequest, "The query parameters are invalid. Check them against the API reference.")
}
func typeMismatch(e *json.UnmarshalTypeError) *Error {
want := jsonKind(e.Type)
got, _, _ := strings.Cut(e.Value, " ")
if e.Field == "" {
return New(BadRequest, fmt.Sprintf("The request body must be a JSON %s, not a JSON %s.", want, got))
}
return New(BadRequest, fmt.Sprintf("Field %q must be a JSON %s, not a JSON %s.", e.Field, want, got))
}
// jsonKind names a Go type by the JSON value that decodes into it.
func jsonKind(t reflect.Type) string {
if t == nil {
return "value"
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Implements(textUnmarshaler) || reflect.PointerTo(t).Implements(textUnmarshaler) {
return "string"
}
switch t.Kind() {
case reflect.Slice, reflect.Array:
return "array"
case reflect.Struct, reflect.Map:
return "object"
case reflect.String:
return "string"
case reflect.Bool:
return "boolean"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
}
return "value"
}
var textUnmarshaler = reflect.TypeFor[interface{ UnmarshalText([]byte) error }]()
// fieldErrors flattens gin's per-element errors for a slice body into one list.
func fieldErrors(err error) []validator.FieldError {
var out []validator.FieldError
var sliceErrs binding.SliceValidationError
var structErrs validator.ValidationErrors
switch {
case errors.As(err, &sliceErrs):
for _, e := range sliceErrs {
out = append(out, fieldErrors(e)...)
}
case errors.As(err, &structErrs):
out = append(out, structErrs...)
}
return out
}
func fieldProblems(errs []validator.FieldError) *Error {
parts := make([]string, 0, maxBindProblems)
for i, fe := range errs {
if i == maxBindProblems {
parts = append(parts, fmt.Sprintf("and %d more", len(errs)-maxBindProblems))
break
}
parts = append(parts, fieldProblem(fe))
}
return New(BadRequest, "The request body is missing or has invalid fields: "+strings.Join(parts, "; ")+".")
}
func fieldProblem(fe validator.FieldError) string {
name := fieldPath(fe)
unit := ""
switch fe.Kind() {
case reflect.String:
unit = " characters"
case reflect.Slice, reflect.Array, reflect.Map:
unit = " items"
}
switch fe.Tag() {
case "required":
return fmt.Sprintf("%q is required", name)
case "min", "gte":
return fmt.Sprintf("%q must be at least %s%s", name, fe.Param(), unit)
case "max", "lte":
return fmt.Sprintf("%q must be at most %s%s", name, fe.Param(), unit)
case "gt":
return fmt.Sprintf("%q must be more than %s%s", name, fe.Param(), unit)
case "lt":
return fmt.Sprintf("%q must be less than %s%s", name, fe.Param(), unit)
case "len":
return fmt.Sprintf("%q must be exactly %s%s", name, fe.Param(), unit)
case "oneof":
return fmt.Sprintf("%q must be one of: %s", name, strings.ReplaceAll(fe.Param(), " ", ", "))
case "email":
return fmt.Sprintf("%q must be an email address", name)
case "uuid", "uuid4":
return fmt.Sprintf("%q must be a UUID", name)
case "url", "http_url":
return fmt.Sprintf("%q must be a URL", name)
}
return fmt.Sprintf("%q failed the %q rule", name, fe.Tag())
}
// fieldPath is the dotted json path of a failing field, without the Go type the namespace starts with.
func fieldPath(fe validator.FieldError) string {
ns := fe.Namespace()
if _, rest, ok := strings.Cut(ns, "."); ok && rest != "" {
return rest
}
return fe.Field()
}