validate

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package validate provides composable input validation rules and HTTP request guards.

Reject, do not repair

A rule answers one question — "is this acceptable?" — and, when the answer is no, returns an error naming the field. Nothing here mutates input. That separation is deliberate and follows OWASP's preference for allow-list validation over post-hoc sanitization, for two reasons:

  • Silent repair hides attacks. If a filter strips something dangerous and lets the request through, nothing is logged, nothing is alerted on, and the attacker gets to iterate against your filter for free until they find the encoding it misses. A rejection is a signal.
  • Silent repair corrupts legitimate data. A user whose display name really does contain an angle bracket deserves an error message, not a quietly rewritten name they never notice.

The companion sanitize package normalizes text that is *expected* to be plain; it is not a substitute for validation and is not an XSS defense.

Rules do not skip empty values

Every rule validates exactly the value it is handed. MinLen fails on an empty string; Email fails on an empty string. Rules do not silently pass when the input is empty, because "my Email rule never actually ran" is a bypass that looks like working code. If a field is genuinely optional, decide that explicitly in your own code before calling Validate.

Error messages

Rule errors never include the offending value. An error that echoes attacker-controlled input back is a reflection primitive: it lands in logs, in error-tracking services, and often in a response body, where it becomes a vector for log injection or reflected XSS depending on where it is rendered. Messages describe the constraint, not the input.

Index

Examples

Constants

This section is empty.

Variables

View Source
var DefaultURLSchemes = []string{"http", "https"}

DefaultURLSchemes is the allow-list URL uses when called with no arguments.

Functions

func MaxBodyBytes

func MaxBodyBytes(n int64, opts ...BodyLimitOption) func(http.Handler) http.Handler

MaxBodyBytes returns middleware that bounds how much of a request body any handler behind it can read.

Threat

A handler that reads a request body without a bound lets one client decide how much of your memory to use. io.ReadAll on an unbounded body, or a JSON decoder fed a stream that never ends, turns a single request into an out-of-memory kill. It costs the attacker one connection.

How it works, and why both halves are needed

Two checks, because either alone is bypassable:

  • Content-Length is compared against n up front, so an oversized upload is refused with 413 before its body is read at all.
  • r.Body is then wrapped with http.MaxBytesReader, which enforces the limit during reading. This is what catches the client that lies: a chunked request declares no length, and a declared Content-Length is a claim, not a fact. The header check is an optimization; the reader is the control.

The two rejection paths, and how to make them agree

An oversized request is refused in one of two places, and they are answered differently on purpose. Adopting this middleware means handling **both**; handling one leaves your API returning two different shapes for the same condition, depending only on whether the client declared its length honestly.

  1. **The declared length is too large.** This middleware answers 413 itself, before the body is read. Replace that response with WithMaxBodyErrorHandler so it carries your error envelope.

  2. **The client lied, or declared nothing.** Nothing is known until the body is read, so the limit is enforced by http.MaxBytesReader inside your handler. The handler's Read returns a *http.MaxBytesError, and **this middleware has already returned by then** — it cannot write a response for you. Match the error with errors.As and emit the same envelope:

    var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { writeError(w, http.StatusRequestEntityTooLarge, "request body too large") return }

The asymmetry is one of mechanism, not of result: one path is configured with an Option and the other is handled downstream with errors.As, but both are meant to produce the same status and the same body. See [ExampleMaxBodyBytes] for the two wired together.

Delegating the second path is the correct design rather than a gap. The middleware is no longer on the stack when the read fails, and a middleware that tried to write then would be racing a handler that may already have sent headers. **Handlers must check the error from reading or decoding the body**; ignoring a decode error and proceeding with a zero value is the bug this middleware cannot prevent for you.

MaxBytesReader also arranges for the connection to be closed rather than leaving an unread oversized body draining, so a rejected upload does not tie up the connection while the client keeps sending.

Ordering

Place this before anything that reads or parses the body — CSRF form-field lookup, JSON decoding, multipart parsing. Middleware ordering is a security property: a body parser that runs before the limit has already done the work the limit exists to prevent. The preset package fixes this order for you.

n is the maximum number of bytes readable from the body. A negative n is treated as zero, which rejects any request that carries a body at all.

Example

ExampleMaxBodyBytes wires both rejection paths to one error envelope.

An oversized request is refused in two different places — by the middleware when the declared Content-Length is too large, and by http.MaxBytesReader inside the handler when the client lied or declared nothing. Adopting the middleware means handling both, or the same condition produces two different response shapes depending only on how the client framed its request.

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/JonasBorgesLM/moat/validate"
)

func main() {
	// The envelope the rest of the application already documents.
	writeError := func(w http.ResponseWriter, status int, msg string) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
	}

	const tooLarge = "request body too large"

	// Path 1: the declared length is over the limit. The middleware answers,
	// so the envelope is installed as an Option.
	overLimit := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		writeError(w, http.StatusRequestEntityTooLarge, tooLarge)
	})

	// Path 2: the declaration was absent or a lie. Nothing is known until the
	// body is read, the middleware has already returned, and the failure
	// arrives as a *http.MaxBytesError from Read. Same envelope, by hand.
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		var maxErr *http.MaxBytesError
		if errors.As(err, &maxErr) {
			writeError(w, http.StatusRequestEntityTooLarge, tooLarge)
			return
		}
		if err != nil {
			writeError(w, http.StatusBadRequest, "cannot read body")
			return
		}
		fmt.Fprintf(w, "read %d bytes", len(body))
	})

	limited := validate.MaxBodyBytes(8, validate.WithMaxBodyErrorHandler(overLimit))(handler)

	// An honest oversized request: caught by the header check.
	declared := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("A", 100)))
	honest := httptest.NewRecorder()
	limited.ServeHTTP(honest, declared)

	// The same body, declaring no length: caught by the reader instead.
	chunked := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("A", 100)))
	chunked.ContentLength = -1
	lying := httptest.NewRecorder()
	limited.ServeHTTP(lying, chunked)

	fmt.Printf("declared: %d %s", honest.Code, honest.Body)
	fmt.Printf("chunked:  %d %s", lying.Code, lying.Body)

}
Output:
declared: 413 {"error":"request body too large"}
chunked:  413 {"error":"request body too large"}

func RequireContentType

func RequireContentType(mediaTypes []string, opts ...ContentTypeOption) func(http.Handler) http.Handler

RequireContentType returns middleware that rejects a body-carrying request whose Content-Type is not one of the given media types, with 415.

Threat

Content type is the contract for how a body will be parsed, and confusion about it is exploitable. A cross-site request forged with an HTML form can only send application/x-www-form-urlencoded, multipart/form-data or text/plain; an endpoint that accepts application/json and nothing else is out of reach of that technique. Conversely, an endpoint that will parse whatever it is handed can be steered into a parser the developer never considered — XML with external entities being the classic example.

Requiring an explicit type is a cheap way to keep an endpoint's parsing surface to the one format it was written for.

Matching

Types are compared as parsed media types with parameters ignored, so "application/json" matches a request sending "application/json; charset=utf-8". Comparison is case-insensitive, as the grammar requires. A malformed Content-Type is rejected rather than guessed at, because a value the standard library refuses to parse is exactly the sort of value that two parsers will disagree about.

Scope

The check applies to methods that carry a body — POST, PUT and PATCH — and only to those, so a GET is never rejected for having no Content-Type. A POST with an empty body is still required to declare its type: whether an endpoint accepts an empty body is a decision for the handler, and letting a declaration-free request through on the grounds that it happened to be empty this time is the kind of special case that later turns out to be reachable with a body attached.

Calling this with no media types rejects every body-carrying request, which is the fail-closed reading of an empty allow-list.

The rejection response is replaceable with WithContentTypeErrorHandler.

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/JonasBorgesLM/moat/validate"
)

func main() {
	handler := validate.RequireContentType([]string{"application/json"})(
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.WriteHeader(http.StatusOK)
		}))

	// A forged HTML form post cannot set this header to application/json, so a
	// JSON-only endpoint is out of reach of that technique.
	r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("a=1"))
	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, r)

	fmt.Println(rec.Code)
}
Output:
415

func Validate

func Validate(field, value string, rules ...Rule) error

Validate applies rules to value in order and returns the first error, or nil if every rule passes.

Evaluation short-circuits, so order the rules from cheapest and most fundamental to most specific: Required first, then a length bound, then a format check. That also produces the most useful message, since "is required" beats "is not a valid email address" for an empty field.

Bounding the length before running a pattern is worth doing on principle even though Go's regexp package is not vulnerable to catastrophic backtracking: it keeps the work proportional to a value you control rather than to one the client chose.

A nil rule is skipped.

Example
package main

import (
	"fmt"

	"github.com/JonasBorgesLM/moat/validate"
)

func main() {
	err := validate.Validate("email", "not-an-email",
		validate.Required(),
		validate.MaxLen(254),
		validate.Email(),
	)
	fmt.Println(err)
}
Output:
email: must be a valid email address

Types

type BodyLimitOption added in v0.2.0

type BodyLimitOption func(*bodyLimitConfig)

BodyLimitOption configures MaxBodyBytes.

It is a distinct type from ContentTypeOption so that an option cannot be passed to the constructor it does not belong to. A single shared option type would compile and then silently do nothing, which is the failure mode this package exists to avoid.

func WithMaxBodyErrorHandler added in v0.2.0

func WithMaxBodyErrorHandler(h http.Handler) BodyLimitOption

WithMaxBodyErrorHandler replaces the response written when a request declares a Content-Length larger than the limit. The default writes 413.

This covers only the first of the two rejection paths described above. The second one surfaces as a *http.MaxBytesError inside your handler and cannot be routed here — setting this option without also handling that error leaves the inconsistency it was meant to fix.

A nil handler is ignored.

type ContentTypeOption added in v0.2.0

type ContentTypeOption func(*contentTypeConfig)

ContentTypeOption configures RequireContentType. See BodyLimitOption for why the two constructors do not share one option type.

func WithContentTypeErrorHandler added in v0.2.0

func WithContentTypeErrorHandler(h http.Handler) ContentTypeOption

WithContentTypeErrorHandler replaces the response written when a request's Content-Type is missing, malformed, or outside the allow-list. The default writes 415.

All three causes share one response, and a replacement should keep it that way. Telling a client which of them applied describes the allow-list back to whoever is probing it, one request at a time.

A nil handler is ignored.

type Error

type Error struct {
	// Field is the name that was passed to [Validate].
	Field string

	// Message describes the constraint that was violated. It is safe to show to
	// the user: it is built from your own rule configuration, never from the
	// submitted value.
	Message string
}

Error reports that a field failed validation.

It deliberately carries no copy of the rejected value. See the package documentation on why echoing attacker-controlled input into an error is a hazard rather than a convenience.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface.

type Rule

type Rule func(field, value string) error

Rule reports whether value is acceptable for the named field.

A Rule returns nil when the value is acceptable and a non-nil error, normally an *Error, when it is not. field is used only to build the error message; a Rule must not vary its decision based on it.

Rules must be pure and must not panic on any input, including invalid UTF-8: they run on data an attacker chose.

func Email

func Email() Rule

Email rejects a value that is not a single, plain email address.

It is built on net/mail.ParseAddress — the standard library's RFC 5322 parser — rather than a regular expression, because the grammar for an address is genuinely not regular and every regex people reach for is wrong in both directions. On top of parsing, this rule requires:

  • Exactly one address. "a@example.com, b@example.com" is rejected.
  • No display name. "Ada <ada@example.com>" is rejected; the value must be the bare address, so that what you store is what you send to.
  • Byte-for-byte agreement with the input, so a value that only becomes valid after the parser normalizes it — quoted local parts, folded whitespace, comments — does not slip through.
  • No domain literal. "user@[203.0.113.9]" is rejected: it is valid RFC 5322, it is essentially never what a signup form meant, and it is a standard way to steer mail at an address the application did not intend.
  • A dot in the domain, so "user@localhost" is rejected.
  • Length limits from RFC 5321: 64 bytes for the local part, 254 for the whole address.

This is stricter than the RFC. That is the intended trade: the cost of rejecting an exotic-but-legal address is an annoyed user, while the cost of accepting one is that it flows into a mail header, a database, and a log line.

Note that ParseAddress already rejects embedded newlines, which is what stops this value from being used for SMTP header injection.

Validating the shape of an address says nothing about whether it exists or belongs to whoever submitted it. Only a confirmation email establishes that.

func Matches

func Matches(re *regexp.Regexp, message string) Rule

Matches rejects a value that does not match re, reporting message.

Anchor your pattern. `regexp.MustCompile("[a-z]+")` matches any value that contains a lowercase run anywhere, which is almost never the constraint intended; `\A[a-z]+\z` is. Unanchored patterns are the single most common way an allow-list turns out to allow everything.

Prefer describing what is allowed over describing what is forbidden. A block-list pattern has to anticipate every encoding of every dangerous construct; an allow-list only has to describe the shape you actually want.

Go's regexp package uses RE2, which runs in time linear in the input and has no backtracking, so a pattern here cannot be turned into a CPU denial of service the way it can in most other languages. Bound the length anyway with MaxLen, to keep the work proportional to a limit you chose.

message is shown to the user, so phrase it as the requirement — "must contain only letters and digits" — rather than leaking the pattern, which tells an attacker exactly what shape to aim for.

A nil re rejects every value: a rule that silently accepted everything because it was misconfigured would be a hole, not a convenience.

Example
package main

import (
	"fmt"
	"regexp"

	"github.com/JonasBorgesLM/moat/validate"
)

func main() {
	// Anchor the pattern. Without \A and \z this would accept any value that
	// merely contains a slug somewhere inside it.
	slug := regexp.MustCompile(`\A[a-z0-9]+(-[a-z0-9]+)*\z`)

	fmt.Println(validate.Validate("slug", "hello-world", validate.Matches(slug, "must be a slug")))
	fmt.Println(validate.Validate("slug", "hello world!", validate.Matches(slug, "must be a slug")))
}
Output:
<nil>
slug: must be a slug

func MaxLen

func MaxLen(n int) Rule

MaxLen rejects a value longer than n runes.

Length is counted in runes, not bytes. Counting bytes would make the limit depend on the alphabet, so a name in Japanese would be cut to a third of the length of the same limit in English — a correctness bug that reliably gets "fixed" by raising the limit until it stops complaining.

An upper bound belongs on every free-text field that reaches storage, a log, or another service. It is the cheapest defense against the family of bugs where an unbounded value overflows a column, blows a downstream limit, or simply costs more to process than the request was worth.

func MinLen

func MinLen(n int) Rule

MinLen rejects a value shorter than n runes.

Length is counted in runes, not bytes, so a limit expressed as "at least 8 characters" means what a user would expect for non-ASCII input. Invalid UTF-8 bytes each count as one rune, so malformed input cannot be used to appear longer than it is.

func NoHTMLTags

func NoHTMLTags() Rule

NoHTMLTags rejects a value containing an angle bracket.

Read the name literally and the scope narrowly. This is an input-shape constraint for fields that have no business containing markup — a person's name, a city, a product SKU — and nothing more. It is **not** an XSS defense and must not be treated as one:

  • It does not make a value safe to render as HTML. A value with no angle brackets is still dangerous inside an unquoted attribute, inside a javascript: URL, or inside a <script> block.
  • Passing this rule tells you nothing about other contexts: SQL, shell, LDAP, and CSV formula injection are all unaffected.

The real defense against XSS is contextual output encoding at the point of rendering, which html/template performs for you. Use it, and do not defeat it by wrapping values in template.HTML.

The check is on the characters < and >, so it also rejects a value with a stray comparison operator. For a field where that is legitimate, this is the wrong rule; use Matches with an allow-list pattern instead.

Example
package main

import (
	"fmt"

	"github.com/JonasBorgesLM/moat/validate"
)

func main() {
	// An input-shape constraint, not an XSS defense: reject markup in a field
	// that should never contain any, and encode on output regardless.
	err := validate.Validate("name", "<script>alert(1)</script>", validate.NoHTMLTags())
	fmt.Println(err)
}
Output:
name: must not contain < or >

func OneOf

func OneOf(allowed ...string) Rule

OneOf rejects a value that is not exactly equal to one of allowed.

This is the allow-list rule, and it is the right tool whenever the set of acceptable values is known: a sort direction, a status, a role, a currency. It is far stronger than any pattern, because there is nothing left to bypass — an unlisted value is rejected whatever it contains.

Comparison is exact and case-sensitive. Fold the case yourself first if you mean to accept both, so the folding is visible rather than implied.

The allowed values appear in the error message, so do not use this rule for secrets. Calling it with no allowed values rejects everything, which is the fail-closed reading of an empty allow-list.

Example
package main

import (
	"fmt"

	"github.com/JonasBorgesLM/moat/validate"
)

func main() {
	// An allow-list is the strongest rule available: there is nothing left to
	// bypass, whatever the value contains.
	err := validate.Validate("sort", "asc; DROP TABLE users", validate.OneOf("asc", "desc"))
	fmt.Println(err)
}
Output:
sort: must be one of: asc, desc

func Required

func Required() Rule

Required rejects an empty value, and a value consisting only of whitespace.

Whitespace-only counts as empty because a required field satisfied by a single space is satisfied in name only, and because the whitespace people use to do it is often not the space character — a non-breaking space or an ideographic space passes a naive value != "" check while looking blank.

func URL added in v0.2.0

func URL(schemes ...string) Rule

URL rejects a value that is not an absolute URL with a scheme in the allow-list. With no arguments the allow-list is DefaultURLSchemes.

Threat

A URL supplied by a user and later rendered into an href, a redirect, or an image source is a script-execution primitive if its scheme is not constrained. The two that matter:

  • javascript: — clicking a link whose href is "javascript:fetch(...)" runs that code in your origin, with the victim's session. html/template will not save you here: it is contextual escaping, and inside an href a javascript: URL is a *valid* URL, not a syntax error to escape.
  • data: — "data:text/html,<script>..." renders attacker-authored HTML. Modern browsers block top-level navigation to it, but it still reaches iframes, and "should be blocked by the browser" is not an input policy.

Constraining the scheme at the point of input is what makes the value safe for every later use, rather than relying on each rendering site to remember.

Control characters, and who actually rejects them

A browser removes tab, newline and carriage return from a URL before parsing its scheme, so "java\tscript:alert(1)" navigates as javascript:. An allow-list that compares the *parsed* scheme is the standard place this gets through, because the parser and the browser disagree about what the scheme even is.

On current Go that bypass is closed by net/url itself: it refuses to parse a URL containing an ASCII control character in the scheme, host or path, so the disagreement never arises and this rule rejects the value on the parse error. Verified exhaustively — see TestNetURLRejectsControlCharactersInScheme, which is a canary: if a future Go becomes lenient there, it fails and tells you the pre-check below has become load-bearing.

The pre-check is kept anyway, for two reasons that are not hypothetical:

  • It does not depend on net/url staying as strict as it is today. A security rule whose guarantee is a side effect of another package's current strictness is one upgrade away from not holding.
  • It rejects cases net/url does accept. Control characters in a *fragment*, and spaces in a path, query or trailing position, parse cleanly — they do not confuse the scheme, but they are not something a well-formed user-supplied URL contains, and accepting them means storing a value that renders differently in different consumers.

Scheme-relative URLs are rejected

"//evil.example.com/path" parses with an empty scheme and is treated by a browser as "same scheme as the current page", so it navigates off-site. It is the classic open-redirect payload. A scheme is therefore required, which rejects relative URLs generally — this rule is for absolute URLs, and a field that should hold a path wants Matches with an anchored pattern instead.

What this rule does not do

It does not check that the host is one you trust. An allowed scheme still permits any host, so this is not sufficient on its own for a redirect target: for that, compare the parsed host against a registered allow-list, which is application knowledge this package does not have. It also does not perform DNS resolution, so it is not an SSRF defense — a hostname that resolves to a link-local address passes.

Comparison is case-insensitive, as the URL grammar requires: "JaVaScRiPt:" is the same scheme to a browser.

An empty allow-list rejects every value, which is the fail-closed reading of one, and matches how OneOf and RequireContentType treat the same mistake.

Example
package main

import (
	"fmt"

	"github.com/JonasBorgesLM/moat/validate"
)

func main() {
	err := validate.Validate("website", "javascript:alert(1)",
		validate.Required(),
		validate.MaxLen(2048),
		validate.URL(),
	)
	fmt.Println(err)
}
Output:
website: must be a valid URL

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL