@zudojs/security
v1.3.4
Published
Security primitives for input validation, header security, CORS, CSRF protection, rate limiting, and security headers.
Maintainers
Readme
@zudojs/security
Security primitives for input validation, header security, CORS, CSRF protection, rate limiting, and security headers.
Documentation: zudojs.oyinlola.site/docs/packages-security · For AI agents: Markdown version, llms.txt
Installation
npm install @zudojs/securityQuick Start
These are functions, not middleware. They compute answers and header values; wiring them into a request pipeline is the caller's job, which keeps the package framework-agnostic.
import {
createRateLimiter,
extractClientIp,
retryAfterSeconds,
generateSimpleHeaders,
generateSecurityHeaders,
} from "@zudojs/security";
const limiter = createRateLimiter({ windowMs: 60_000, max: 100 });
function handle(request, response) {
// Forwarding headers are only trusted when you say how many proxies you run.
const ip = extractClientIp(request.headers, {
trustProxy: 1,
remoteAddress: request.socket.remoteAddress,
});
const limit = limiter.check({ ip });
if (!limit.allowed) {
// `resetAt` is a Date; `Retry-After` is a whole number of seconds.
return respond(429, { retryAfter: retryAfterSeconds(limit) });
}
const headers = {
...generateSecurityHeaders(),
...generateSimpleHeaders(request.headers.origin, {
origin: ["https://example.com"],
credentials: true,
}),
};
return respond(200, body, headers);
}Rate limiting
A genuine sliding window: each check prunes entries older than windowMs and
decides against what remains, so a client cannot spend a full allowance either
side of a fixed boundary. Only allowed requests consume an allowance slot, so a
client being limited cannot grow its own bucket. Expiry is a binary search over
an ordered hit log with amortised compaction, so a check costs O(log max)
rather than O(max): a limiter with max: 1_000_000 is as cheap per request
as one with max: 300.
const limiter = createRateLimiter({
windowMs: 60_000,
max: 100,
maxKeys: 100_000, // LRU-evicted ceiling on tracked keys
});
const ip = "203.0.113.7";
limiter.check({ ip }); // { allowed, remaining, resetAt: Date, total }
limiter.getCount(ip);
limiter.reset(ip);
limiter.destroy(); // clears the cleanup timermiddleware fills a response for you, and the default handler now derives
Retry-After from the decision rather than emitting a fixed 60. It also
honours message:
const limiter = createRateLimiter({
windowMs: 3_600_000,
max: 10,
message: "Hourly quota exhausted.",
});
const response = { statusCode: 200, headers: {} as Record<string, string> };
const result = limiter.middleware({ ip }, response);
// response.headers["Retry-After"] → seconds until the window frees up
// response.headers["X-RateLimit-Limit"], ["X-RateLimit-Remaining"], ["X-RateLimit-Reset"]
// response.body → { error: { code, message: "Hourly quota exhausted." } }A custom handler receives the same decision as a third argument, so it can do
the arithmetic too. Two-parameter handlers written against the previous
signature still work.
extractClientIp does not trust X-Forwarded-For by default. Any client can
send that header, so taking its leftmost entry hands the caller control of their
own rate-limit bucket. Set trustProxy to the number of proxies you actually
operate; entries are then read in from the right, and everything to the left of
your own hops is ignored.
The address it returns has any port and IPv6 brackets removed (some proxies
append client-ip:port, which used to give every TCP connection its own
bucket). The default key generator then keys IPv4 as-is, IPv4-mapped IPv6 as
IPv4, and other IPv6 by its /64, so one host cannot rotate through its own
prefix. Use createIpKeyGenerator({ ipv6PrefixLength }) for another prefix,
and ipRateLimitKey(ip) to compute the key yourself; getCount(ip) and
reset(ip) accept the raw address.
The default key generator throws a ConfigurationError when ip is
missing or is not an address (including the "unknown" placeholder
extractClientIp returns when it has nothing to go on). Those requests used
to share one "unknown" bucket, so one client could starve everyone else.
Always pass remoteAddress, or supply your own keyGenerator.
CORS
import {
generatePreflightHeaders,
generateSimpleHeaders,
} from "@zudojs/security";
const config = { origin: ["https://app.example.com"], credentials: true };
// `Access-Control-Request-Headers` is a comma-separated list; split it
// yourself — the package takes a `string[]`, not the raw header.
const requested = (request.headers["access-control-request-headers"] ?? "")
.split(",")
.map((h) => h.trim())
.filter((h) => h.length > 0);
// Preflight — optionally validating what the browser asked for.
generatePreflightHeaders(request.headers.origin, config, {
method: request.headers["access-control-request-method"],
headers: requested,
});Vary: Origin is emitted whenever the allowed origin is reflected (array, regex
or predicate), including on rejection — without it a shared cache can serve one
origin's Access-Control-Allow-Origin to another. A wildcard origin combined
with credentials throws, since browsers reject that pairing outright.
CSRF
Two patterns, both on the same HMAC-SHA256 token. Bind the token to a session wherever you have one — an unbound token is valid for every user.
The shortest correct version binds your configuration once. secret must be at
least 32 characters (MIN_CSRF_SECRET_LENGTH) — the signature is HMAC-SHA256,
so a shorter one adds no strength. Read it from the environment and refuse to
start without it. Never write process.env.CSRF_SECRET ?? "some-string": a
hard-coded fallback is a secret anyone can read in your source, and a short
one throws at the first call. Generate one with
node -e 'console.log(require("node:crypto").randomBytes(32).toString("hex"))'.
import { createCsrfProtection, MIN_CSRF_SECRET_LENGTH } from "@zudojs/security";
const secret = process.env.CSRF_SECRET;
if (!secret || secret.length < MIN_CSRF_SECRET_LENGTH) {
throw new Error(`CSRF_SECRET must be set to at least ${MIN_CSRF_SECRET_LENGTH} characters`);
}
const csrf = createCsrfProtection({
secret,
cookieName: "app_csrf",
headerName: "x-app-csrf",
expiration: 3600,
});
// Issue
const { token, setCookie } = csrf.issue({ sessionId });
setHeader("Set-Cookie", setCookie);
// Verify — safe to call on every request; safe methods return true.
// `methods` (default POST, PUT, PATCH, DELETE) is matched case-insensitively.
if (!csrf.verify(
{ method: request.method, headers: request.headers, cookieHeader: request.headers.cookie },
{ sessionId },
)) {
return respond(403);
}The same thing with the primitives, if you would rather hold the pieces:
import {
generateCsrfToken,
generateCsrfCookie,
verifyDoubleSubmit,
extractCsrfTokenFromCookies,
extractCsrfTokenFromHeaders,
requiresCsrfProtection,
} from "@zudojs/security";
// Issue
const token = generateCsrfToken(secret, { sessionId, expiration: 3600 });
setHeader("Set-Cookie", generateCsrfCookie(token));
// Verify
if (requiresCsrfProtection(request.method)) {
const ok = verifyDoubleSubmit(
extractCsrfTokenFromCookies(request.headers.cookie ?? ""),
extractCsrfTokenFromHeaders(request.headers),
secret,
{ sessionId },
);
if (!ok) return respond(403);
}Which methods skip the check. Only GET, HEAD, OPTIONS and TRACE, matched
exactly after upper-casing (no trimming). Every other method is verified —
including an empty, padded or unknown one ("", " ", "POST ", "FOO"),
and CONNECT. A configured methods list can exempt a standard method it
leaves out (methods: ["DELETE"] exempts POST), never an unknown or malformed
one. Before 1.3.1 any method not in the list skipped the check, so
csrf.verify({ method: "FOO" }) returned true with no token at all.
What returns false and what throws. csrf.verify, verifyDoubleSubmit
and validateCsrfToken return false for anything wrong with the request: a
missing, malformed, non-string, forged, expired or mismatched token, a token
bound to another session, or a request with no method (treated as needing
protection). They throw ConfigurationError only for a configuration mistake:
a secret shorter than 32 characters — the same minimum generateCsrfToken
enforces, so a verifier given process.env.CSRF_SECRET ?? "" cannot accept
tokens signed with an empty key — or an empty or invalid methods list. A
throw from a CSRF check therefore means "fix your configuration", never "this
request is bad". createCsrfProtection and generateCsrfCookie also throw
ValidationError for a cookie path that breaks the Path rules below.
The cookie is Secure and HttpOnly by default, which suits the synchroniser
token pattern where the server renders the token into the page. For the
double-submit pattern — where client script reads the cookie back — pass
httpOnly: false, and accept that XSS on the origin can then read the token.
URLs and SSRF
import {
isSafeUrl,
isPrivateHostname,
validateRequestTarget,
} from "@zudojs/security";
isSafeUrl("http://169.254.169.254/"); // false — cloud metadata
isSafeUrl("http://[::ffff:127.0.0.1]/"); // false — v4-mapped loopback
isSafeUrl("gopher://internal/"); // false — protocol not allowlistedAddresses are range-checked numerically (127/8, 10/8, 172.16/12, 192.168/16,
169.254/16, 100.64/10, 0/8, ::1, fc00::/7, fe80::/10, fec0::/10,
ff00::/8), and only http: and https: are permitted unless you widen
allowedProtocols. IPv6 forms that embed an IPv4 address — ::a.b.c.d,
::ffff:a.b.c.d, ::ffff:0:a.b.c.d, NAT64 64:ff9b::/96 and 6to4
2002::/16 — are judged as that IPv4 address, whatever spelling the URL
parser gives them; the local-use NAT64 prefix 64:ff9b:1::/48 is refused.
The building blocks are exported for other guards: expandIpv6(address)
returns the eight 16-bit groups (or undefined), embeddedIpv4(groups)
returns the embedded IPv4 octets for the forms above (or undefined), and
isNonPublicIpv6Range(groups) is true for fc00::/7, fe80::/10,
fec0::/10, ff00::/8 and 64:ff9b:1::/48.
This cannot stop DNS rebinding. A public hostname may resolve to a private
address, and may resolve differently between the check and the connection. For
outbound requests that must be safe, resolve the hostname yourself, run
isPrivateHostname against the resolved address, and connect to that address.
validateRequestTarget rejects CR and LF — literal or percent-encoded — and
detects traversal by decoding to a fixed point rather than pattern-matching
encoded forms. It also enforces maxLength and rejects invalid percent
encoding; a single trailing % used to abort decoding altogether, so
/a/%2e%2e/etc/passwd% and /a%0d%0aX-Evil:1% were both reported valid.
It takes a RequestTargetConfig rather than the full UrlValidationConfig: a
request target is origin-form and carries no scheme, so allowedProtocols
could never apply to it.
Cookies
serializeCookie validates before it writes: the value is percent-encoded, and
an unsafe name, attribute, Max-Age or Expires throws. SameSite=None and
Partitioned require Secure.
Domain must be a hostname — labels of letters, digits and hyphens separated
by dots, with an optional leading dot (example.com, .example.com) — and
anything else (spaces, colons, backslashes, empty labels) throws
ValidationError. Path must be printable ASCII (0x20–0x7E) with no ; or
,: a control character or any non-ASCII character ("/ä") throws —
percent-encode it instead ("/%C3%A4"). This is RFC 6265's path-value
(any US-ASCII character except controls and ;) plus the , this package has
always refused; space is inside that grammar, so "/a b" is accepted.
Before 1.3.1 non-ASCII was accepted.
Previously only a real CR, LF, NUL, ; or , was refused, so the literal text
a\r\nX-Evil: 1 was written into Domain= unchanged.
parseCookieHeader validates on the way in too. A name that is not an RFC 6265
token, or a value carrying a control character, is reported in errors and
kept out of cookies — it used to check only length, so a malformed cookie
reached the caller with errors: []. Values are otherwise accepted leniently
(spaces, commas and quoted-string wrappers are common in the wild); use
validateCookieValue where you want the strict cookie-octet rule.
stripSensitiveCookies(header) removes a cookie when a sensitive word appears
anywhere in its name as a whole word (split on ., -, _ and camelCase,
after dropping a __Host-/__Secure- prefix). The defaults
(DEFAULT_SENSITIVE_COOKIE_NAMES) cover session, sid, token, auth,
jwt, csrf and the framework defaults connect.sid, PHPSESSID and
JSESSIONID; theme or sidebar survive.
createSecureCookie("sid", value, { maxAge: 3600 });
// sid=…; Max-Age=3600; Secure; HttpOnly; SameSite=LaxInput sanitization
import { sanitizeObject, detectThreats, escapeHtml } from "@zudojs/security";
sanitizeObject(payload, { maxDepth: 32 }); // cycle-safe, arrays stay arrays
detectThreats(input); // ordered: SQL_INJECTION, XSS, NULL_BYTE, CONTROL_CHARACTERS
escapeHtml(text);detectThreats, containsSqlInjection and containsXss are heuristics with a
high false-positive rate on ordinary prose — useful for logging and alerting,
never a substitute for parameterised queries or contextual output encoding.
containsXss decodes HTML character references (javascript:,
javascript:) and ignores whitespace inside a scheme before matching.
Body limits must be finite numbers above zero: validateBodySize,
validateContentLength and createBodySizeChecker throw ConfigurationError
for NaN (what Number(process.env.BODY_LIMIT) gives when the variable is
unset), and validateBodyLimitConfig reports it — a NaN limit used to allow
any body.
escapeHtml covers element text and quoted attribute values; unquoted
attributes, <script> bodies and URL positions need their own encoding.
Features
- Sliding-window rate limiting with proxy-aware client IP extraction
- CORS with
Vary: Originand wildcard/credentials validation - CSRF tokens: HMAC-SHA256, session-bound, constant-time double-submit
- URL validation, traversal detection and SSRF range checks
- Cookie parsing and injection-safe serialization
- Header validation, CRLF stripping and opt-in hop-by-hop rejection
- Body size limits,
Content-Lengthvalidation and request-smuggling framing checks - Security headers with a restrictive default CSP and HSTS
Use Cases
- Securing HTTP endpoints
- Preventing injection attacks
- Rate limiting public APIs
- Compliance with security headers standards
