Security notes
vld is usually the first code that touches untrusted input. These are the places where a schema alone is not enough or where the defaults matter.
Filesystem paths and files
vld::path() and vld::file() check the input against the server's filesystem. Without a base directory:
- any absolute path or
..is accepted; exists(),file()anddir()reveal to the client whether arbitrary paths exist;vld::file()opens and reads whatever file the client names ({"path": "/etc/passwd"}).
Always confine user-supplied paths:
let avatar = vld::file()
.within("/srv/uploads")
.max_size(5 * 1024 * 1024)
.media_types(&["image/png", "image/jpeg"]);
within(base) resolves ., .. and the symlinks of existing ancestors before checking containment, so "../etc/x", "/srv/uploads/../etc/x" and a symlink pointing outside are rejected (path_outside_base), even when the target does not exist. It returns base.join(input), so later file operations use the checked path. File reads stop after max_size + 1 bytes.
within is a lexical check plus symlink resolution at validation time. If an attacker can create symlinks inside base between validation and use, open the file with OS-level protections (openat2 with RESOLVE_BENEATH, a chroot or a container) as well.URLs
string().url() accepts any http / https URL, including http://localhost and http://10.0.0.1. If your server fetches the URL (webhooks, link previews, image proxies), use url_strict() (feature string-advanced): it rejects localhost, private, loopback, link-local and unspecified addresses, including IPv4-mapped IPv6 forms such as http://[::ffff:127.0.0.1].
url_strict() checks the URL text. A public hostname can still resolve to a private address (DNS rebinding), so an SSRF-sensitive fetcher should also check the resolved IP before connecting.
Request bodies
All web adapters cap JSON and form bodies at 2 MiB by default and answer 413 above it, without buffering more. Raise the limit only for the routes that need it; see the body limits table. Form bodies are limited to 1000 fields.
Set limits on collections inside the body too: vld::array(..).max_len(n), vld::record(..).max_keys(n), vld::string().max(n) and vld::json_value().max_depth(n). A 2 MiB body can still hold a million tiny array items.
Headers and cookies
The adapters already defend against two classic tricks:
- Header names containing
_are ignored, sox_user: admincannot fill a field meant forX-Userbehind a proxy that only strips the dash form. - When a cookie name appears more than once (across all
Cookieheaders), the first value wins, which defeats cookie tossing from a sibling subdomain.
Details are in the web overview.
Error output
Issues carry the received value (truncated to 100 characters, cut on character boundaries). The default web error body contains only path, message and code, not the value. If you return VldError or ValidationIssue as JSON yourself, remember that received echoes client input; strip it for secrets such as passwords and tokens.
Regex
vld::string().pattern() uses regex-lite, which has no backtracking and runs in linear time, so a pattern cannot be used for ReDoS. Patterns are compiled once, when the schema is built; the built-in formats use no regex at all.
Fuzzing
The fuzz/ directory has cargo-fuzz targets for the ISO date parsers, string formats, URL / form / cookie decoding and error truncation. See Contributing.