npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zudojs/security

v1.3.4

Published

Security primitives for input validation, header security, CORS, CSRF protection, rate limiting, and security headers.

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/security

Quick 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 timer

middleware 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 allowlisted

Addresses 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=Lax

Input 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 (jav&#x61;script:, javascript&colon;) 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: Origin and 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-Length validation 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