Docs / Packages / @zudojs/security
v1.3.1

@zudojs/security

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

SECURITY INPUT VALIDATION CORS CSRF RATE LIMITING HEADERS SANITIZATION

OVERVIEW

A web server receives data it did not write: URLs, headers, cookies, bodies, form fields. Some of that data is sent by people trying to break the server. @zudojs/security is a set of small functions that inspect that data and build the response headers browsers use to protect your users.

Everything here is a plain function, not middleware. You call it, and it hands back a boolean, a string, a list of error messages, or a set of headers. Deciding what to do with that answer — reject the request, log it, carry on — is your job. That is what lets the package work with any HTTP layer.

Here is what each protection is for, in one sentence each:

ProtectionWhat it is for
Security headersResponse headers that tell the browser to switch on its own defences, such as refusing to run scripts from other sites.
CORSDecides which other websites are allowed to read responses from your API inside a browser.
CSRFStops a page on another site from making a logged-in user's browser send a state-changing request to your site.
Rate limitingCaps how many requests one client may make in a time window, so nobody can hammer your API.
Input sanitizationRemoves characters that have no business being in user input, and flags input that looks like an attack.
URL validationRejects request paths that try to escape their directory or inject a header, and refuses outbound URLs that point back into your own network.
Secure cookiesWrites Set-Cookie values a browser will protect, and refuses to write one an attacker could tamper with.

When you need it

  • You are building an HTTP server or adapter and have to decide what to accept.
  • You want CORS, CSRF or rate limiting without pulling in a framework's middleware stack.
  • Your service fetches URLs supplied by users and must not be tricked into calling internal addresses.

When you don't

  • You want ready-made middleware. These are functions; you wire them up yourself.
  • You need rate limits shared across several servers. The limiter here counts in one process's memory.
  • You need to accept HTML from users. Use a real HTML sanitizer; nothing here does that job.
  • You need password hashing, encryption or signing in general. That is @zudojs/crypto.
Danger: no function on this page makes a value "safe" everywhere. A string that is safe inside HTML text is not safe inside a SQL query, a shell command, or a URL. Safety depends on where the value is going, so every destination needs its own treatment.

INSTALLATION

$ npm install @zudojs/security

The package depends on @zudojs/errors and @zudojs/constants, so your package manager installs those for you. Nothing else is needed: the code uses only Node's built-in node:crypto. Node 24 or newer.

These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.

QUICK START

This is a complete Node server that does two things: it caps each client at five requests a minute, and it sends the default security headers on every response.

import { createServer } from "node:http"; import { createRateLimiter, extractClientIp, retryAfterSeconds, generateSecurityHeaders, } from "@zudojs/security"; // max = requests allowed, windowMs = the window they are counted in. const limiter = createRateLimiter({ max: 5, windowMs: 60_000 }); const server = createServer((req, res) => { // No proxy in front of us, so trust the socket address only. const ip = extractClientIp(req.headers, { remoteAddress: req.socket.remoteAddress, }); const limit = limiter.check({ ip }); const headers = generateSecurityHeaders(); if (!limit.allowed) { // Derived from the decision — resetAt is a Date, not a header value. const retryAfter = String(retryAfterSeconds(limit)); res.writeHead(429, { ...headers, "Retry-After": retryAfter }); res.end("Too many requests"); return; } res.writeHead(200, { ...headers, "Content-Type": "text/plain" }); res.end("You have " + limit.remaining + " requests left."); }); server.listen(3000);

Run it, then call it six times in a row:

$ curl -i http://localhost:3000/

What you should see: the first five responses are 200 OK, with bodies counting down from 4 requests left to 0 requests left. Every one of them carries X-Frame-Options: DENY, X-Content-Type-Options: nosniff, a Content-Security-Policy and the other defaults. The sixth is 429 Too Many Requests. A minute later the first request ages out and you are allowed again.

SECURITY HEADERS

Security headers are response headers that ask the browser to enforce rules on your behalf: do not put this page in a frame, do not guess file types, do not run scripts from other origins. The browser does the work; you only have to send the headers.

generateSecurityHeaders() returns a plain object of header names and values. Called with no argument you get the defaults; anything you pass overrides just that one header.

import { generateSecurityHeaders } from "@zudojs/security"; const headers = generateSecurityHeaders(); console.log(headers["X-Frame-Options"]); // "DENY" console.log(headers["X-Content-Type-Options"]); // "nosniff" console.log(headers["X-XSS-Protection"]); // "0" // A UI that loads scripts from a CDN needs its own policy. const uiHeaders = generateSecurityHeaders({ contentSecurityPolicy: "default-src 'self'; script-src 'self' https://cdn.example.com", }); console.log(uiHeaders["Content-Security-Policy"]); // "default-src 'self'; script-src 'self' https://cdn.example.com"

These are the defaults you get when you pass nothing:

HeaderDefaultWhat it does
Content-Security-Policydefault-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'Restricts where scripts, styles and images may come from. Deliberately strict.
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadTells the browser to use HTTPS for this domain for the next two years.
X-Content-Type-OptionsnosniffStops the browser guessing a file's type and running an upload as a script.
X-Frame-OptionsDENYStops other sites embedding your pages in a frame (clickjacking).
X-XSS-Protection0Switches off the old browser XSS filter, which caused bugs of its own. CSP replaced it.
Referrer-Policystrict-origin-when-cross-originStops full URLs, which may hold tokens, leaking to other sites.
Permissions-Policyaccelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()Denies access to device features the page does not need.
X-DNS-Prefetch-ControloffStops the browser resolving links ahead of time.
Cross-Origin-Opener-Policysame-originCuts the link between your page and windows it opens.
Cross-Origin-Resource-Policysame-originStops other origins loading your responses as resources.
Watch out: the default CSP forbids inline scripts and every external origin. That is right for a JSON API and wrong for most web UIs — if your pages go blank, replace contentSecurityPolicy rather than deleting the header. And do not send the HSTS default from a site still served over plain HTTP; two years is a long time to be locked out.

Three helpers go with it. getMissingSecurityHeaders(headers) tells you which of the ten are absent from a response you already built. generateCspNonce() returns a fresh random base64 string for a 'nonce-...' CSP entry. validateCspDirective(directive) returns a warning when a policy contains 'unsafe-inline', 'unsafe-eval' or 'unsafe-hashes', and undefined otherwise. It is advice, not a gate: it does not parse the policy.

import { getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "@zudojs/security"; console.log(getMissingSecurityHeaders({ "x-frame-options": "DENY" }).length); // 9 const nonce = generateCspNonce(); console.log(nonce.length); // 24 — 16 random bytes written in base64 console.log(validateCspDirective("default-src 'self'")); // undefined console.log(validateCspDirective("script-src 'unsafe-inline'")); // "unsafe-inline weakens CSP"
Common mistake: building a header value out of user input. generateSecurityHeaders throws if any config value contains a line break or a null byte, because such a value would let the caller append headers of their own. generateCspNonce throws a RangeError below 16 bytes.

CORS

By default a browser will not let JavaScript on https://app.example.com read a response from https://api.example.com. CORS — Cross-Origin Resource Sharing — is how your API says "this particular site may read my responses", using Access-Control-* headers.

An origin is the scheme, host and port of a page, such as https://app.example.com. The browser puts the calling page's origin in the Origin request header, and you answer with headers saying whether it is allowed.

There are two kinds of cross-origin request. A simple one is sent straight away. Anything else — a DELETE, a custom header — makes the browser send an OPTIONS preflight first, to ask permission. There is a function for each.

import { generateSimpleHeaders, generatePreflightHeaders, } from "@zudojs/security"; const config = { origin: ["https://app.example.com"], methods: ["GET", "POST"], allowedHeaders: ["Content-Type", "Authorization"], credentials: true, }; // A normal request from an allowed origin. console.log(generateSimpleHeaders("https://app.example.com", config)); // { // Vary: "Origin", // "Access-Control-Allow-Origin": "https://app.example.com", // "Access-Control-Allow-Credentials": "true" // } // A request from anywhere else: no Allow-Origin, so the browser blocks it. console.log(generateSimpleHeaders("https://evil.example", config)); // { Vary: "Origin", "Access-Control-Allow-Credentials": "true" } // A preflight, checking what the browser actually asked for. console.log( generatePreflightHeaders("https://app.example.com", config, { method: "POST", headers: ["content-type"], }), ); // Adds Access-Control-Allow-Methods, -Allow-Headers and -Max-Age: "86400".

The third argument to generatePreflightHeaders is optional. When you pass it, a preflight asking for a method or header you did not allow gets no CORS headers back at all, instead of a policy the browser then has to reject.

In plain words: origin accepts a single string, an array of strings, a RegExp, or a function (origin) => boolean. Whenever the answer depends on who asked — anything but a single fixed string — the result carries Vary: Origin, which stops a shared cache handing one site's permission slip to another.
Danger: credentials: true combined with origin: "*" throws an Error. Browsers refuse that pairing outright, so a policy built that way would silently never work. List your origins instead.

Three smaller functions let you make the same checks yourself: isOriginAllowed returns the origin value to send or undefined, isMethodAllowed returns a boolean, and getDisallowedHeaders returns the header names that are not permitted. All five are also reachable through the cors namespace object.

import { cors } from "@zudojs/security"; const policy = { origin: "https://app.example.com", allowedHeaders: ["Content-Type"] }; console.log(cors.isOriginAllowed("https://app.example.com", policy)); // "https://app.example.com" console.log(cors.isOriginAllowed("https://evil.example", policy)); // undefined console.log(cors.getDisallowedHeaders(["X-Secret"], policy)); // [ "X-Secret" ]
Common mistake: assuming CORS protects your API. It does not. It only limits what browser JavaScript on other sites can read. Any script, server or command line can still call your endpoints, so keep your authentication and authorization checks.

CSRF

Browsers attach your site's cookies to requests your site did not start. So a form on evil.example can post to yourbank.example/transfer and arrive fully logged in. That is Cross-Site Request Forgery.

The fix is to require something the other site cannot know: a token. You issue it, the browser sends it back in a header or form field, and you check it before doing anything that changes state.

A token here looks like expiresAt:random:signature. The signature is an HMAC-SHA256 over the other two parts plus a session id, keyed with your secret, so nobody without the secret can make one.

Issuing a token when a page is rendered:

import { generateCsrfToken, generateCsrfCookie } from "@zudojs/security"; // At least 32 characters, e.g. randomBytes(32).toString("hex"). No fallback: // a missing secret must stop the app, not quietly use a known string. const secret = process.env.CSRF_SECRET; if (!secret) throw new Error("CSRF_SECRET is not set"); // sessionId binds the token to one logged-in user. const token = generateCsrfToken(secret, { sessionId: "user-42", expiration: 3600 }); console.log(token.split(":").length); // 3 const setCookie = generateCsrfCookie(token); console.log(setCookie); // "_csrf=<the token>; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=3600"

Checking it on the way back in:

import { requiresCsrfProtection, extractCsrfTokenFromCookies, extractCsrfTokenFromHeaders, verifyDoubleSubmit, } from "@zudojs/security"; type Headers = Record<string, string | string[] | undefined>; function checkCsrf(method: string, headers: Headers, sessionId: string, secret: string): boolean { // GET, HEAD, OPTIONS and TRACE do not change state, so they are skipped. if (!requiresCsrfProtection(method)) return true; const cookie = typeof headers.cookie === "string" ? headers.cookie : ""; const fromCookie = extractCsrfTokenFromCookies(cookie); const fromRequest = extractCsrfTokenFromHeaders(headers); return verifyDoubleSubmit(fromCookie, fromRequest, secret, { sessionId }); } const secret = process.env.CSRF_SECRET; if (!secret) throw new Error("CSRF_SECRET is not set"); console.log(checkCsrf("GET", {}, "user-42", secret)); // true — nothing to check console.log(checkCsrf("POST", {}, "user-42", secret)); // false — no token present console.log(checkCsrf("POST", { cookie: "_csrf=abc", "x-csrf-token": "abc" }, "user-42", secret)); // false — forged token try { checkCsrf("POST", {}, "user-42", "s3cret"); } catch (error) { console.log((error as Error).name); // ConfigurationError — secret under 32 characters }
Two different failures: a missing, forged, expired or wrong-session token makes verifyDoubleSubmit, validateCsrfToken and csrf.verify return false — answer that with a 403. So does a token that is not a string at all, a request with no method, and a malformed headers or cookie bag. A bad secret (missing, empty, or under 32 characters) is a configuration error: every call throws ConfigurationError (ERR_CONFIGURATION_INVALID), even on a request that carries no token at all, and generateCsrfToken throws too. Validate the secret once at startup so this surfaces at boot and not as a 500 on the first POST.

verifyDoubleSubmit passes only when both tokens are present, byte-for-byte equal (compared in constant time), correctly signed, unexpired, and bound to the session id you pass. Comparing the two alone would not be enough. validateCsrfToken and verifyDoubleSubmit throw on a secret shorter than 32 characters (MIN_CSRF_SECRET_LENGTH), and the protected methods list is matched case-insensitively.

Changed in 1.3.0: the checks throw only for misconfiguration. Up to 1.2.x a non-string token, a request with no method, or a malformed header or cookie bag made them throw a TypeError, which a server turned into a 500 rather than a 403. They now return false. A secret under 32 characters and a bad methods list still throw ConfigurationError.

createCsrfProtection binds all of that to one configuration, so the cookie name, header name, lifetime and secret are read from the same place at every call site:

import type { IncomingHttpHeaders } from "node:http"; 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", // default "_csrf" headerName: "x-app-csrf", // default "x-csrf-token" expiration: 3600, // seconds // methods: omit for the defaults — POST, PUT, PATCH, DELETE. }); const sessionId = "user-42"; // the logged-in user's session id // Issue: render `token` into the page and send `setCookie` as Set-Cookie. const { token, setCookie } = csrf.issue({ sessionId }); console.log(setCookie.startsWith("app_csrf=")); // true // Verify: safe to call on every request. Safe methods return true; a missing, // forged or malformed token returns false and never throws. function verify(method: string, headers: IncomingHttpHeaders): boolean { return csrf.verify({ method, headers, cookieHeader: headers.cookie }, { sessionId }); } console.log(verify("POST", { cookie: `app_csrf=${token}`, "x-app-csrf": token })); // true console.log(verify("POST", { cookie: `app_csrf=${token}` })); // false — no header console.log(verify("POST", { cookie: "app_csrf=junk", "x-app-csrf": "junk" })); // false — malformed console.log(verify("GET", {})); // true — safe method console.log(verify("", {})); // false — an empty method is verified, not skipped

verify and requiresCsrfProtection fail closed. Only GET, HEAD, OPTIONS and TRACE skip the check, matched exactly after upper-casing and without trimming, so "get" is skipped but " GET" is not. Every other value is verified: an empty or blank method, "POST ", "CONNECT" and an unknown method such as "FOO" all need a valid token. When you configure methods, a standard HTTP method the list leaves out is also exempt: with methods: ["DELETE"], POST and PUT skip the check, while "" and "FOO" are still verified.

Changed in 1.3.1 (security): up to 1.3.0 any method outside the protected list skipped the check, so "", " ", "POST ", "FOO" and "CONNECT" returned true with no token. They now return false without a valid token. If a client of yours sends a non-standard method to a CSRF-protected route, it now needs a token.
Changed in 1.2.0: createCsrfProtection and requiresCsrfProtection now throw a ConfigurationError when methods is present but empty, is not an array, or contains a blank entry. Until 1.1.0, methods: [] turned CSRF off for every request in silence — and that is exactly what process.env.CSRF_METHODS?.split(",").filter(Boolean) ?? [] produces when the variable is unset. Omit methods to get the defaults; pass a list only when you mean to change them.
Watch out: without sessionId, a token minted for one user validates for every other user — an attacker can get a token with their own account and replay it against a victim. Pass a session id whenever you have one, and pass the same one at generation and at validation.
In plain words: the cookie is HttpOnly by default, so page scripts cannot read it. That fits the synchroniser token pattern, where the server renders the token into the page or form. If you want the browser's own script to read the cookie and echo it back — the double-submit pattern — pass generateCsrfCookie(token, { httpOnly: false }), and accept that any XSS on your origin can then steal the token.
Common mistake: validating with a shorter expiration than the token was issued with. validateCsrfToken treats expiration as the maximum lifetime it is willing to honour, so a token minted for a day fails a check that expects an hour. Use the same number on both sides.

RATE LIMITING

Rate limiting caps how many requests one client may make in a period of time. It is what stops someone guessing passwords a thousand times a second, or running your bill up by calling an expensive endpoint in a loop.

createRateLimiter returns an object that remembers, in this process's memory, when each client was last allowed through. The window slides: every check throws away timestamps older than windowMs and decides against what is left.

import { createRateLimiter } from "@zudojs/security"; const limiter = createRateLimiter({ max: 3, windowMs: 60_000 }); for (let i = 0; i < 4; i++) { const result = limiter.check({ ip: "203.0.113.7" }); console.log(result.allowed, result.remaining); } // true 2 // true 1 // true 0 // false 0 console.log(limiter.getCount("203.0.113.7")); // 3 — denied requests are not counted limiter.destroy(); // clears the internal cleanup timer

The object you get back has these members:

MemberWhat it doesNotes
check(request)Decides one request and records it.Returns { allowed, remaining, resetAt, total }; resetAt is a Date.
middleware(request, response?)Same as check, but on a denial it also fills in the response object.Sets status 429, Retry-After and a JSON error body, unless you configured your own handler.
reset(key)Forgets one client's history.The key is whatever your keyGenerator returns — by default the IP (IPv6 by /64); passing the raw IP works too.
clear()Forgets every client.Useful between tests.
getCount(key)How many allowed requests are still inside the window.Never exceeds max.
destroy()Stops the cleanup timer and empties the store.Call it on shutdown, and in tests.
sizeHow many keys are currently tracked.A getter, not a method.

The config takes max and windowMs (both required; a non-positive value throws a RangeError), plus optional keyGenerator, handler, skip, message and maxKeys. Once the store passes maxKeys (100,000 by default) the least recently seen keys are dropped, so a client rotating its identity cannot grow the map without limit. The default key generator strips ports, keys IPv4-mapped IPv6 as IPv4, buckets other IPv6 by /64 (DEFAULT_IPV6_PREFIX_LENGTH; build your own with createIpKeyGenerator / ipRateLimitKey), and throws a ConfigurationError when request.ip is missing or not an address (including "unknown").

Finding the client's IP

A limiter is only as good as the key it counts against. extractClientIp deliberately ignores forwarding headers unless you opt in, because anyone can put anything in X-Forwarded-For.

import { extractClientIp } from "@zudojs/security"; const headers = { "x-forwarded-for": "1.1.1.1, 2.2.2.2, 3.3.3.3" }; // No proxies trusted: the header is ignored entirely. console.log(extractClientIp(headers)); // "unknown" console.log(extractClientIp(headers, { remoteAddress: "9.9.9.9" })); // "9.9.9.9" // One proxy of your own: read one entry in from the right. console.log(extractClientIp(headers, { trustProxy: 1 })); // "3.3.3.3" console.log(extractClientIp(headers, { trustProxy: 2 })); // "2.2.2.2" // A chain shorter than trustProxy never passed through your proxies, so // the header is skipped entirely: x-real-ip first, then remoteAddress. const short = { "x-forwarded-for": "1.2.3.4" }; console.log(extractClientIp(short, { trustProxy: 2, remoteAddress: "9.9.9.9" })); // "9.9.9.9" — this was "1.2.3.4" before 1.2.0
Changed in 1.2.0: a forwarded chain shorter than trustProxy is now ignored. Such a chain did not pass through the proxies whose entries make it trustworthy — a request entering at an inner hop, or one a client shortened deliberately — and the old index clamp landed on the entry the client wrote. With trustProxy: 2, a request arriving with X-Forwarded-For: 1.2.3.4 was rate-limited as 1.2.3.4, so rotating that value handed the caller a fresh bucket each time. Chains at or above the configured length behave exactly as before.
In plain words: each proxy appends the address it saw, so the entries on the right were written by your infrastructure and the ones on the left came from the caller. Set trustProxy to the number of proxies you actually run — not more. A value that is too high starts reading attacker-supplied text again.
Watch out: the counters live in one process. Two instances behind a load balancer each allow max requests, so the real limit is doubled. A shared limit needs a store outside the process, which this package does not provide.
Common mistake: creating a limiter inside your request handler. That makes a fresh, empty limiter — and a fresh timer — on every request, so nothing is ever limited. Create it once at startup.

INPUT SANITIZATION

Input sanitization here means two separate things, and it matters that you keep them apart. Cleaning removes characters that should never be in text at all, such as null bytes. Detecting looks for shapes that resemble an attack and tells you what it saw.

Danger — read this before using anything below. None of these functions makes a value safe for every destination. sanitizeString strips control characters; it does not make the result safe to put in HTML, a SQL query, a shell command, a file path, a URL or a log line. containsSqlInjection and containsXss are guesses based on regular expressions: they miss real attacks and they flag innocent text. The only real defences are parameterised queries for SQL and correct encoding at each output point.

Cleaning values

sanitizeString removes null bytes and control characters, and can optionally normalise Unicode, truncate, and run a function of yours.

import { sanitizeString, sanitizeObject } from "@zudojs/security"; // \u0000 is a null byte, \u0007 a control character. Both are removed. console.log(sanitizeString("he\u0000l\u0007lo")); // "hello" console.log(sanitizeString("abcdef", { maxStringLength: 3 })); // "abc" // sanitizeObject walks a whole payload, cleaning every string it finds. const payload = JSON.parse('{"__proto__":{"admin":true},"name":"a\\u0000b","tags":["x","y"]}'); const clean = sanitizeObject(payload); console.log(Object.keys(clean)); // [ "name", "tags" ] console.log(clean.name); // "ab" console.log(clean.tags); // [ "x", "y" ] — still an array

sanitizeObject drops keys named __proto__, constructor and prototype, which are the keys used to poison JavaScript's prototype chain. Arrays stay arrays, a Date or class instance is passed through untouched, a value that refers back to itself becomes undefined instead of crashing, and recursion stops at maxDepth (32 by default).

Changed in 1.2.0: sanitizeObject throws a ConfigurationError when maxDepth is present but is not an integer of 1 or more. The guard runs before the object is entered, so maxDepth: 0 used to discard the argument itself and return undefined under a non-optional T — every field read off the result then threw at a call site TypeScript had called safe. Number(process.env.MAX_DEPTH) with the variable unset is the same shape of mistake as a NaN body limit.

Refusing prototype-polluting data

Every JavaScript object has a hidden link to a shared prototype that supplies its inherited properties. JSON.parse keeps a key named __proto__, constructor or prototype as an ordinary property, so it reaches your handler intact. The moment your code copies that object with Object.assign, a for…in merge or a bracket assignment, the key can rewrite the prototype instead, and a field like isAdmin suddenly appears on objects that never had it. That attack is called prototype pollution.

findUnsafeKey(value) looks through decoded, untrusted data and returns the first of those three keys it finds, at any depth, or undefined when there is none. It walks plain objects and arrays only, iteratively (so a deeply nested payload cannot overflow the stack) and cycle-safe (an object that refers to itself is visited once).

import { findUnsafeKey } from "@zudojs/security"; const body = JSON.parse('{"user":{"name":"Ada","settings":{"__proto__":{"isAdmin":true}}}}'); const unsafe = findUnsafeKey(body); if (unsafe !== undefined) { // Refuse the request (400) rather than trying to repair it. console.log(`Refused: body contains "${unsafe}"`); // Refused: body contains "__proto__" } console.log(findUnsafeKey({ items: [{ id: 1 }, { id: 2 }] })); // undefined

Which one to use: findUnsafeKey checks a whole value, such as a request body, a queue job or an RPC payload, and is the one to call on anything you decoded. containsPrototypePollution(input) checks a single string, such as a key name or a path segment you are about to use as a property name. sanitizeObject silently drops the keys and hands back a cleaned copy; findUnsafeKey lets you refuse the input instead, which is usually the better answer, because a client that sends __proto__ is not sending you anything you want to process.

Already done for you: @zudojs/rpc refuses an RPC frame whose payload or metadata carries one of these keys (RPC_INVALID_REQUEST), and every @zudojs/api binding refuses them in its input (400 over HTTP, a validation error over RPC, queues and the CLI). Both use this function.

Escaping for HTML

escapeHtml replaces &, <, >, ", ' and the backtick with their HTML entities. That is enough for element text and for a quoted attribute value.

import { escapeHtml, stripHtml } from "@zudojs/security"; console.log(escapeHtml("<b>hi</b>")); // "&lt;b&gt;hi&lt;/b&gt;" console.log(stripHtml("<b>hi</b>")); // "hi"
Watch out: escapeHtml is not enough inside an unquoted attribute, inside a <script> or <style> block, or where the value becomes a URL — a javascript: href survives escaping untouched. Those places need their own encoding. And stripHtml only removes tag syntax; the plain text it returns must still be escaped before you put it back in a page. Neither is an HTML sanitizer, so do not use them to accept rich text from users.

Detecting suspicious input

detectThreats returns a list of labels for what it recognised. Treat the result as a signal for logging and alerting, never as an authorisation decision.

import { detectThreats, isSafeString } from "@zudojs/security"; console.log(detectThreats("<script>alert(1)</script>")); // [ "XSS" ] console.log(detectThreats("a\u0000b")); // [ "NULL_BYTE", "CONTROL_CHARACTERS" ] // A false positive: ordinary prose containing a SQL keyword. console.log(detectThreats("Please update my address")); // [ "SQL_INJECTION" ] // isSafeString: no null bytes, no control characters, and — if you pass one — // the whole string matches your own pattern. console.log(isSafeString("order-123", /^[a-z0-9-]+$/)); // true console.log(isSafeString("order 123", /^[a-z0-9-]+$/)); // false
In plain words: an allowlist beats a blocklist. Saying "an order id is lowercase letters, digits and dashes" rules out every attack at once; hunting for the word DROP rules out one spelling of one attack and blocks a customer named O'Brien. Reach for isSafeString with your own pattern first.
Common mistake: reusing a regular expression that carries the g or y flag for repeated boolean tests. Such a pattern remembers where it stopped, so it reports false every other call. isSafeString and the CORS origin check strip those flags for you, and withoutStickyFlags(pattern) is exported so you can do the same to your own patterns.

URL VALIDATION

There are two very different URL problems, and this package handles both. One is incoming: a request path that tries to climb out of its directory or smuggle a newline into your response headers. The other is outgoing: a URL a user gave you, which you are about to fetch, that secretly points back into your own network.

Incoming request targets

A request target is the path and query of a request, such as /users?page=1. validateRequestTarget rejects carriage return and line feed (written literally or percent-encoded), null bytes, and directory traversal.

import { validateRequestTarget, normalizePath, containsTraversal } from "@zudojs/security"; console.log(validateRequestTarget("/users?page=1").valid); // true const bad = validateRequestTarget("/files/%2e%2e/%2e%2e/etc/passwd"); console.log(bad.valid); // false console.log(bad.errors); // [ "Request target contains path traversal attempts" ] console.log(validateRequestTarget("/a%0d%0aX-Injected:%201").valid); // false // Since 1.2.0: an RFC 3986 path parameter no longer hides a traversal. console.log(validateRequestTarget("/files/..;/etc/passwd").valid); // false console.log(containsTraversal("/a/..;/b")); // true console.log(containsTraversal("/a/....//b")); // false — not a traversal // Ask for a tidied path back with normalizePaths. const tidied = validateRequestTarget("/a//b/./c?x=1", { normalizePaths: true }); console.log(tidied.normalized); // "/a/b/c?x=1" console.log(normalizePath("/a/b/../c")); // "/a/c"
In plain words: .. means "go up one directory", so /files/../../etc/passwd reaches a file you never meant to serve. Attackers hide it by encoding: %2e%2e, .%2e, %252e%252e. Rather than listing every spelling, containsTraversal decodes the path over and over until it stops changing (that is fullyDecodeUri) and then looks for a plain .. segment. Input still changing after eight rounds is treated as hostile.
Changed in 1.2.0: containsTraversal and validateRequestTarget now strip RFC 3986 path parameters before segmenting, so /a/..;/b is reported as traversal like every other spelling of it. Tomcat, Jetty and several reverse-proxy pairings resolve that to /a/../b, and until 1.1.0 it passed. ....// is still not a traversal, and nothing that was already caught has changed.

validateUrl does the same job for a complete absolute URL, and also checks the length (2048 characters by default), the protocol, and percent-encoding.

import { validateUrl } from "@zudojs/security"; console.log(validateUrl("https://example.com/docs").valid); // true console.log(validateUrl("ftp://example.com/x").errors); // [ 'Protocol "ftp:" is not allowed (allowed: http:, https:)' ] console.log(validateUrl("not a url").errors); // [ "URL is malformed" ]
Watch out: protocols in allowedProtocols are written with the trailing colon — ["http:", "https:"], not ["http"]. That is the form Node's URL parser produces, and a list without colons matches nothing.

Outgoing URLs and SSRF

SSRF — Server-Side Request Forgery — is when a user hands you a URL and your server fetches it for them. Your server usually sits inside a private network, so http://169.254.169.254/ reaches the cloud metadata service and the credentials it hands out.

isSafeUrl answers "does this URL point somewhere public?" It allows only http: and https:, refuses URLs carrying a username or password, and range-checks the address numerically. IPv6 forms embedding an IPv4 address (::a.b.c.d, ::ffff:0:a.b.c.d, 64:ff9b::/96, 2002::/16) are judged as that IPv4 address; expandIpv6(address), embeddedIpv4(groups) and isNonPublicIpv6Range(groups) are exported.

import { isSafeUrl, isPrivateHostname } from "@zudojs/security"; console.log(isSafeUrl("https://example.com/logo.png")); // true console.log(isSafeUrl("http://169.254.169.254/")); // false — cloud metadata console.log(isSafeUrl("http://127.0.0.2/")); // false — loopback console.log(isSafeUrl("http://2130706433/")); // false — 127.0.0.1 written as one number console.log(isSafeUrl("http://[::ffff:127.0.0.1]/")); // false — loopback via IPv6 console.log(isSafeUrl("file:///etc/passwd")); // false — protocol not allowed console.log(isSafeUrl("http://user:pass@example.com/")); // false — credentials in the URL // The hostname check on its own, for an address you resolved yourself. console.log(isPrivateHostname("10.1.2.3")); // true console.log(isPrivateHostname("1.1.1.1")); // false
Danger: isSafeUrl cannot stop DNS rebinding. A hostname like totally-public.example may resolve to 127.0.0.1, and may resolve differently a second later — between your check and your connection. If an outbound fetch really must be safe, resolve the hostname yourself, run isPrivateHostname against the address you got back, and connect to that address rather than to the name.

SECURE COOKIES

A cookie is a small named value the browser stores for your site and sends back on every request. Because session ids usually live in one, how you write the Set-Cookie header decides how well that session is protected.

Three attributes do most of the work:

  • Secure — only send it over HTTPS, so it cannot be read off the wire.
  • HttpOnly — hide it from page scripts, so a cross-site scripting bug cannot steal it.
  • SameSite — do not send it on requests started by other sites, which blunts CSRF.

createSecureCookie turns those on by default, so you have to opt out rather than remember to opt in.

import { createSecureCookie, parseCookieHeader } from "@zudojs/security"; console.log(createSecureCookie("sid", "abc123", { maxAge: 3600 })); // "sid=abc123; Max-Age=3600; Secure; HttpOnly; SameSite=Lax" // A value with a space is percent-encoded so it survives the round trip. console.log(createSecureCookie("greeting", "hello there")); // "greeting=hello%20there; Secure; HttpOnly; SameSite=Lax" // Reading the Cookie header a browser sent back. const parsed = parseCookieHeader("sid=abc123; theme=dark"); console.log(parsed.cookies); // [ { name: "sid", value: "abc123" }, { name: "theme", value: "dark" } ] console.log(parsed.errors); // []

serializeCookie takes the same attributes as one object instead of separate arguments, and both functions throw a ValidationError rather than write something dangerous. Domain must be a hostname: labels of letters, digits and hyphens separated by dots, with an optional leading dot, at most 253 characters. Path must be printable ASCII (0x20–0x7E) with no ; or ,: percent-encode anything else, so write /caf%C3%A9, not /café. A space is allowed, as RFC 6265 allows it. generateCsrfCookie and createCsrfProtection apply the same Path check and throw ValidationError too.

import { serializeCookie, createSecureCookie } from "@zudojs/security"; import { ValidationError } from "@zudojs/errors"; // Domain must be a hostname. This one has literal backslashes, a space and a colon. try { serializeCookie({ name: "sid", value: "v", domain: "a\\r\\nX-Evil: 1" }); } catch (error) { console.log(error instanceof ValidationError); // true console.log((error as Error).message); // 'Cookie Domain contains invalid characters (injection risk): it must be a hostname …' } console.log(createSecureCookie("sid", "v", { domain: ".example.com", path: "/app" })); // "sid=v; Path=/app; Domain=.example.com; Secure; HttpOnly; SameSite=Lax" // A ";" in Path would start a new attribute. try { createSecureCookie("sid", "v", { path: "/app;Domain=evil.example" }); } catch (error) { console.log((error as Error).message); // 'Cookie Path contains invalid characters (injection risk): only printable ASCII is allowed (percent-encode anything else), and ";" and "," are not, …' }
Changed in 1.3.0 (behaviour change): up to 1.2.x only a real CR, LF, NUL, ; or , was refused, so a Domain of a\r\nX-Evil: 1 written with literal backslashes, or one with spaces or a colon, went into the header unchanged. If you pass a Domain with a port or anything other than a hostname, it now throws.
Changed in 1.3.1 (behaviour change): a cookie Path with non-ASCII characters, such as "/ä", used to be written as it was. It now throws ValidationError; percent-encode it. generateCsrfCookie and createCsrfProtection used to write their path into Set-Cookie unchecked and now run the same check.
Watch out: parseCookieHeader splits and trims; it does not percent-decode. If you wrote a value with createSecureCookie and it contained anything outside the plain cookie character set, call decodeURIComponent on the value yourself after parsing.

Two more helpers. validateCookieName and validateCookieValue return an error message or undefined, if you want to check before serializing. stripSensitiveCookies removes session-like cookies from a header before you forward it somewhere else or write it to a log.

import { validateCookieName, stripSensitiveCookies } from "@zudojs/security"; console.log(validateCookieName("sid")); // undefined — fine console.log(validateCookieName("a b")); // "Cookie name contains invalid characters: a b" console.log(stripSensitiveCookies("session_id=abc; theme=dark")); // "theme=dark"
In plain words: stripSensitiveCookies drops any cookie whose name contains session, sessionid, sess, sid, token, auth, jwt, csrf, xsrf (and PHPSESSID/JSESSIONID) as a whole word, after ignoring a __Host-/__Secure- prefix, e.g. connect.sid, access_token, __Host-session. Pass your own list as the second argument if your cookies are named differently — a cookie it does not recognise is kept.

HEADERS AND BODY LIMITS

These two groups matter if you are writing an HTTP adapter or a proxy. They check the parts of a request that arrive before your route handler ever runs.

Headers

Header values must never contain a carriage return or line feed: those characters end a header, so a value carrying one can append headers or a whole second response. That is called response splitting.

import { validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "@zudojs/security"; console.log(validateHeaders({ "content-type": "application/json" })); // { valid: true, errors: [] } console.log(validateHeaders({ "x-forwarded-for": "1.1.1.1" }).errors); // [ 'Header "x-forwarded-for" is blocked by security policy' ] // Returns undefined when nothing is left after stripping. console.log(sanitizeHeaderValue("one line only")); // "one line only" // Hop-by-hop headers belong to a single connection and must not be forwarded. console.log(isHopByHopHeader("Connection")); // true console.log(isHopByHopHeader("Content-Type")); // false

validateHeaders also caps the number of headers (50), each value's size (8 KB) and the total (64 KB). All four limits are configurable through HeaderSecurityConfig.

Watch out: by default validateHeaders blocks x-forwarded-for, x-forwarded-host and x-forwarded-proto outright — the assumption is that no proxy sits in front of you, so those headers can only have come from the client. If you do run a proxy, pass your own blockedHeaders list, and keep trusting them only through extractClientIp with a trustProxy count.

Body limits and framing

A body limit stops one request eating your memory. Framing is the separate question of where the body ends: a request that answers that question twice, differently, can be split into two by a proxy that reads one answer while your server reads the other. That is request smuggling, and it is why the contradictory combinations are rejected outright.

import { validateBodyFraming, validateContentLength, getBodyLimitForContentType, DEFAULT_BODY_LIMITS, } from "@zudojs/security"; // undefined means "no problem found". console.log(validateBodyFraming({ "content-length": "120" })); // undefined console.log( validateBodyFraming({ "content-length": "120", "transfer-encoding": "chunked" }), ); // "Request specifies both Content-Length and Transfer-Encoding (request smuggling risk)" console.log(validateContentLength("100abc")); // "Content-Length is not a valid number: 100abc" console.log(validateContentLength("2000", 1000)); // "Content-Length 2000 exceeds maximum 1000 bytes" // Pick a size limit from the Content-Type. console.log(getBodyLimitForContentType("application/json; charset=utf-8")); // 1048576 console.log(getBodyLimitForContentType("multipart/form-data; boundary=x")); // 104857600 console.log(DEFAULT_BODY_LIMITS.auth); // 262144
In plain words: the four presets are json (1 MB), auth (256 KB), upload (100 MB) and webhook (2 MB). A login form looks exactly like any other form on the wire, so the Content-Type cannot tell you it is a login. On those routes pass the purpose yourself: getBodyLimitForContentType(type, undefined, "auth").

Once you know the limit, validateBodySize(actualSize, maxSize, contentType?) checks one size, and createBodySizeChecker(maxSize, contentType?) returns a reusable (size) => { allowed, error? } function for a streaming read. A limit that is NaN, infinite, zero or negative throws a ConfigurationError instead of silently allowing everything.

API REFERENCE

Everything below is exported from the package root, @zudojs/security.

Security headers

NameWhat it doesNotes
generateSecurityHeaders(config?)Builds the ten security response headers.Returns Record<string, string>. Throws if a config value holds a newline or null byte.
getMissingSecurityHeaders(headers)Lists which of the ten are absent.Case-insensitive. Returns string[].
generateCspNonce(nonceLength?)Random base64 nonce for a CSP entry.Default 16 bytes; fewer throws a RangeError.
validateCspDirective(directive)Warns about 'unsafe-inline' and friends.Returns a message or undefined. Advice only, not a parser.
SECURITY_HEADER_NAMESThe ten header names as constants.Frozen object literal.

CORS

NameWhat it doesNotes
isOriginAllowed(origin, config)Decides one origin.Returns the value to send, or undefined.
generateSimpleHeaders(origin, config)Headers for a non-preflight request.Returns CorsHeaders.
generatePreflightHeaders(origin, config, request?)Headers for an OPTIONS preflight.With request it also validates the requested method and headers.
isMethodAllowed(method, config)Is this method in the policy?Case-insensitive.
getDisallowedHeaders(headers, config)Which requested headers are not permitted.Returns string[]; empty means all allowed.
corsNamespace holding the five functions above.For callers who prefer cors.isOriginAllowed(...).

CSRF

NameWhat it doesNotes
generateCsrfToken(secret, options?)Mints a signed token.options is { expiration?, sessionId? } or a plain number of seconds. Throws ConfigurationError on an empty secret or one under 32 characters.
validateCsrfToken(token, secret, options?)Checks signature, expiry and session binding.Returns boolean (false for a malformed or forged token). expiration is the maximum lifetime you accept. Throws on a short secret.
verifyDoubleSubmit(cookieToken, requestToken, secret, options?)Checks that both copies match and are valid.Constant-time comparison. The usual entry point. false for a missing or non-string token; throws only on a short secret.
createCsrfProtection(config)Binds a secret, lifetime, cookie/header names and methods to the primitives.Returns { issue, verify, requiresProtection }. Throws ConfigurationError on a short secret or an unusable methods list. verify returns false, never throws, for a bad token or a malformed request.
requiresCsrfProtection(method, config?)Does this method change state?false for GET, HEAD, OPTIONS, TRACE (and, with config.methods, a standard method the list leaves out); true for anything else, including an empty or unknown method. Throws ConfigurationError when config.methods is present but empty, not an array, or holds a blank entry.
extractCsrfTokenFromHeaders(headers, headerName?)Reads the token from headers.Default x-csrf-token; lookup is case-insensitive.
extractCsrfTokenFromCookies(cookieHeader, cookieName?)Reads the token from a raw Cookie header.Default cookie name _csrf.
generateCsrfCookie(token, config?)Builds the Set-Cookie value.HttpOnly, Secure, SameSite=Strict by default.

Rate limiting

NameWhat it doesNotes
createRateLimiter(config)Builds an in-memory sliding-window limiter.Returns { check, middleware, reset, clear, getCount, destroy, size }. Create once, at startup.
extractClientIp(headers, options?)Works out the client address.Ignores forwarding headers unless trustProxy is set, and ignores a chain shorter than trustProxy. Falls back to x-real-ip, then remoteAddress, then "unknown". Ports and IPv6 brackets are stripped from the result.
defaultKeyGenerator(request)Uses request.ip as the key (port stripped, IPv6 by /64); throws ConfigurationError when it is missing or not an IP.Used when you pass no keyGenerator.
defaultHandler(request, response, result?, message?)Fills in a 429 response.Sets status, the X-RateLimit-* headers and a JSON error body. Retry-After is derived from the decision, not a fixed 60.
retryAfterSeconds(result?, now?)Whole seconds until the window frees up.At least 1, never fractional, so a client never reads Retry-After: 0.
rateLimitNamespace holding the rate-limit functions above.

Input sanitization

NameWhat it doesNotes
sanitizeString(input, config?)Strips null bytes and control characters.Optionally normalises Unicode, truncates, runs your customSanitizer.
sanitizeObject(obj, config?)Cleans every string in a payload.Drops prototype-pollution keys; cycle-safe; stops at maxDepth (32). Throws ConfigurationError if maxDepth is not an integer of 1 or more.
escapeHtml(input)Escapes six HTML characters.Safe for element text and quoted attributes only.
stripHtml(input)Removes tag syntax.Not a sanitizer. Escape the result before rendering.
isSafeString(input, allowedPattern?)No null bytes, no control characters, matches your pattern.The allowlist approach. Strips g/y from your pattern first.
detectThreats(input)Labels what it recognised.Any of "SQL_INJECTION", "XSS", "NULL_BYTE", "CONTROL_CHARACTERS". Heuristic.
containsSqlInjection(input)Regex guess at SQL injection.High false-positive rate. Never a substitute for parameterised queries.
containsXss(input)Regex guess at XSS.Misses real attacks. Encode on output instead.
containsPrototypePollution(input)Is this string one of the three dangerous keys?Exact match on one string. For a whole decoded value use findUnsafeKey.
findUnsafeKey(value)First __proto__, constructor or prototype key anywhere in decoded data, or undefined.Plain objects and arrays only; iterative and cycle-safe. See Refusing prototype-polluting data.
withoutStickyFlags(pattern)Copies a regex without g and y.Use before repeated .test() calls.
PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNSThe lists behind those checks.None carries the g flag, by design.

URLs

NameWhat it doesNotes
validateUrl(url, config?)Checks a full absolute URL.Returns { valid, normalized?, errors }. Protocols include the colon.
validateRequestTarget(target, config?)Checks an incoming path and query.Rejects CR, LF, null bytes and traversal, including ..;.
normalizePath(pathname)Resolves . and ..... can never climb above the root.
containsTraversal(path)Is there a .. segment?Decodes first, so encoded forms are caught; RFC 3986 path parameters are stripped, so ..; counts too.
fullyDecodeUri(value)Percent-decodes to a fixed point.Returns { decoded, truncated }; truncated means it gave up after eight rounds.
isSafeUrl(url, allowedProtocols?)Is this URL safe to fetch?Blocks private ranges, non-HTTP protocols and embedded credentials. Cannot stop DNS rebinding.
isPrivateHostname(hostname)Is this host internal?Run it against an address you resolved yourself.

Cookies, headers and bodies

NameWhat it doesNotes
createSecureCookie(name, value, options?, config?)Builds a Set-Cookie value.Secure, HttpOnly, SameSite=Lax by default. Throws on unsafe input.
serializeCookie(cookie, config?)Same, taking a ParsedCookie.Percent-encodes the value; validates every attribute. Domain must be a hostname; Path must be printable ASCII with no ; or ,.
parseCookieHeader(header, config?)Splits a Cookie header.Returns { cookies, errors }. Does not percent-decode.
validateCookieName(name) / validateCookieValue(value)Check one part.Error message, or undefined when fine.
stripSensitiveCookies(header, names?)Removes session-like cookies.Defaults to DEFAULT_SENSITIVE_COOKIE_NAMES, matched as whole words anywhere in the name (isSensitiveCookieName).
validateHeaders(headers, config?)Checks names, values, counts and sizes.Returns { valid, errors }. Blocks x-forwarded-* by default.
validateHeaderName(name) / validateHeaderValue(name, value, config?)Check one header.Error message, or undefined.
sanitizeHeaderValue(value)Strips null bytes and line breaks.Returns undefined if nothing is left.
isHopByHopHeader(name)Should this header not be forwarded?Connection, Upgrade, TE, and the rest.
validateBodyFraming(headers, maxSize?)Rejects ambiguous body framing.Message, or undefined. Guards against request smuggling.
validateContentLength(value, maxSize?)Digits only, safe integer, within a maximum.Message, or undefined.
validateBodySize(actual, maxSize?, contentType?)Is this body too big?Message, or undefined. Default limit 1 MB.
createBodySizeChecker(maxSize, contentType?)Reusable size check.Returns (size) => { allowed, error? }.
getBodyLimitForContentType(type, presets?, purpose?)Picks a preset limit.Pass purpose to override the type-based guess.
parseMediaType(contentType)Strips parameters and lowercases.undefined when absent or malformed.
validateBodyLimitConfig(config)Sanity-checks a limit.Must be positive and at most 1 GB.
DEFAULT_BODY_LIMITSThe four presets.json, auth, upload, webhook.

Types

These are TypeScript types only; they disappear at runtime. Each is the config or result shape of the functions above: HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CorsHeaders, CsrfConfig, CsrfTokenOptions, CsrfCookieOptions, RateLimitConfig, RateLimiterOptions, ClientIpOptions, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig, RequestValidationConfig and InputSanitizationConfig.

In plain words: RequestValidationConfig is exported as a type but no function in this package reads it. It is a shape for your own request-validation code, not a switch you can turn on.
Errors: there is no SecurityError class here. Most functions report problems in their return value — a boolean, a message string, or an errors array. The few that throw raise one of three things. ConfigurationError from @zudojs/errors is what a misconfiguration gets: a CSRF secret under 32 characters, an unusable CSRF methods list, a sanitizeObject maxDepth below 1, a non-finite body limit, a security-header config value carrying CRLF, a CORS call pairing a wildcard origin with credentials, and the default rate-limit key generator given no usable ip. ValidationError, also from @zudojs/errors, comes out of cookie serialization. RangeError covers the numeric bounds: createRateLimiter with a non-positive max or windowMs, generateCspNonce below 16 bytes, a non-positive CSRF expiration, and createIpKeyGenerator outside 1–128.

COMMON MISTAKES

  • Treating sanitizeString as an all-purpose cleaner. It strips control characters and nothing more, so the "clean" value still injects into SQL or HTML. Fix: parameterise queries, and encode at the point of output — escapeHtml for HTML text, and the right encoder for every other destination.
  • Blocking input because detectThreats flagged it. The word "update" in a support message trips SQL_INJECTION, so real users get rejected while a crafted payload slips past. Fix: use it for logging and alerting; validate with isSafeString and your own allowlist pattern.
  • Calling createRateLimiter inside the request handler. Every request gets an empty limiter and its own timer, so nothing is limited and timers pile up. Fix: create it once at module level and call destroy() on shutdown.
  • Setting trustProxy higher than the number of proxies you run. The count reaches past your own hops into text the client wrote, so anyone can pick their own rate-limit bucket. Fix: count your real proxies, and pass remoteAddress so there is a trustworthy fallback.
  • Issuing CSRF tokens without a sessionId. An attacker signs up, gets a valid token, and replays it against your users; every token is valid for everyone. Fix: pass the same session id to generateCsrfToken and to verifyDoubleSubmit.
  • Building the CSRF methods list out of an environment variable. process.env.CSRF_METHODS?.split(",").filter(Boolean) ?? [] is an empty list whenever the variable is unset, and an empty list used to mean "protect nothing". Since 1.2.0 it throws a ConfigurationError at startup instead. Fix: omit methods unless you are genuinely changing them from POST, PUT, PATCH, DELETE.
  • Expecting CORS to keep attackers out. It only limits what browser scripts on other origins can read; curl ignores it entirely. Fix: keep authentication and authorization on every endpoint, and treat CORS as a browser convenience.

COMPLETE EXPORT INDEX

Every name @zudojs/security exports from its package root at v1.2.0 — 102 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.

Show all 102 exports
Functions (65)
containsPrototypePollution containsSqlInjection containsTraversal containsXss createBodySizeChecker createCsrfProtection createIpKeyGenerator createRateLimiter createSecureCookie defaultHandler defaultKeyGenerator detectThreats embeddedIpv4 escapeHtml expandIpv6 extractClientIp extractCsrfTokenFromCookies extractCsrfTokenFromHeaders fullyDecodeUri generateCspNonce generateCsrfCookie generateCsrfToken generatePreflightHeaders generateSecurityHeaders generateSimpleHeaders getBodyLimitForContentType getDisallowedHeaders getMissingSecurityHeaders ipRateLimitKey isHopByHopHeader isMethodAllowed isNonPublicIpv6Range isOriginAllowed isPrivateHostname isSafeString isSafeUrl isSensitiveCookieName normalizePath parseClientIp parseCookieHeader parseMediaType requiresCsrfProtection resolveBodyLimit retryAfterSeconds sanitizeHeaderValue sanitizeObject sanitizeString serializeCookie stripHtml stripSensitiveCookies validateBodyFraming validateBodyLimitConfig validateBodySize validateContentLength validateCookieName validateCookieValue validateCspDirective validateCsrfToken validateHeaderName validateHeaders validateHeaderValue validateRequestTarget validateUrl verifyDoubleSubmit withoutStickyFlags
Interfaces (26)
BodyLimitConfig BodyLimitPresets ClientIpOptions CookieSecurityConfig CorsConfig CorsHeaders CsrfConfig CsrfCookieOptions CsrfProtection CsrfProtectionOptions CsrfTokenOptions CsrfVerifiableRequest HeaderSecurityConfig HeaderValidationResult InputSanitizationConfig IpKeyOptions IssuedCsrfToken ParsedCookie RateLimitConfig RateLimiterOptions RateLimitRequest RateLimitResponse RateLimitResult SecurityHeadersConfig UrlValidationConfig UrlValidationResult
Type aliases (1)
RequestTargetConfig
Constants (10)
cors DEFAULT_BODY_LIMITS DEFAULT_IPV6_PREFIX_LENGTH DEFAULT_SENSITIVE_COOKIE_NAMES MIN_CSRF_SECRET_LENGTH PROTOTYPE_POLLUTION_KEYS rateLimit SECURITY_HEADER_NAMES SQL_INJECTION_PATTERNS XSS_PATTERNS