@zudojs/security 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +254 -11
  3. package/dist/body/body.core.d.ts +50 -2
  4. package/dist/body/body.core.js +156 -19
  5. package/dist/body/index.d.ts +1 -1
  6. package/dist/body/index.js +1 -1
  7. package/dist/cookie/cookie.core.d.ts +12 -0
  8. package/dist/cookie/cookie.core.js +98 -8
  9. package/dist/cors/cors.core.d.ts +8 -1
  10. package/dist/cors/cors.core.js +66 -9
  11. package/dist/csrf/csrf.core.d.ts +150 -9
  12. package/dist/csrf/csrf.core.js +199 -41
  13. package/dist/csrf/index.d.ts +2 -1
  14. package/dist/csrf/index.js +1 -1
  15. package/dist/header/header.core.js +18 -5
  16. package/dist/headers/headers.core.js +36 -4
  17. package/dist/index.d.ts +14 -9
  18. package/dist/index.js +10 -8
  19. package/dist/input/index.d.ts +1 -1
  20. package/dist/input/index.js +1 -1
  21. package/dist/input/input.core.d.ts +27 -0
  22. package/dist/input/input.core.js +125 -37
  23. package/dist/rateLimit/index.d.ts +2 -1
  24. package/dist/rateLimit/index.js +1 -1
  25. package/dist/rateLimit/rateLimit.core.d.ts +56 -11
  26. package/dist/rateLimit/rateLimit.core.js +172 -45
  27. package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
  28. package/dist/rateLimit/rateLimit.namespace.js +2 -1
  29. package/dist/types/index.d.ts +1 -1
  30. package/dist/types/security.type.d.ts +36 -19
  31. package/dist/types/security.type.js +26 -11
  32. package/dist/url/index.d.ts +2 -1
  33. package/dist/url/index.js +1 -1
  34. package/dist/url/url.core.d.ts +58 -2
  35. package/dist/url/url.core.js +270 -43
  36. package/package.json +24 -13
  37. package/dist/body/body.core.d.ts.map +0 -1
  38. package/dist/body/body.core.js.map +0 -1
  39. package/dist/body/index.d.ts.map +0 -1
  40. package/dist/body/index.js.map +0 -1
  41. package/dist/cookie/cookie.core.d.ts.map +0 -1
  42. package/dist/cookie/cookie.core.js.map +0 -1
  43. package/dist/cookie/index.d.ts.map +0 -1
  44. package/dist/cookie/index.js.map +0 -1
  45. package/dist/cors/cors.core.d.ts.map +0 -1
  46. package/dist/cors/cors.core.js.map +0 -1
  47. package/dist/cors/cors.namespace.d.ts.map +0 -1
  48. package/dist/cors/cors.namespace.js.map +0 -1
  49. package/dist/cors/index.d.ts.map +0 -1
  50. package/dist/cors/index.js.map +0 -1
  51. package/dist/csrf/csrf.core.d.ts.map +0 -1
  52. package/dist/csrf/csrf.core.js.map +0 -1
  53. package/dist/csrf/index.d.ts.map +0 -1
  54. package/dist/csrf/index.js.map +0 -1
  55. package/dist/header/header.core.d.ts.map +0 -1
  56. package/dist/header/header.core.js.map +0 -1
  57. package/dist/header/index.d.ts.map +0 -1
  58. package/dist/header/index.js.map +0 -1
  59. package/dist/headers/headers.core.d.ts.map +0 -1
  60. package/dist/headers/headers.core.js.map +0 -1
  61. package/dist/headers/index.d.ts.map +0 -1
  62. package/dist/headers/index.js.map +0 -1
  63. package/dist/index.d.ts.map +0 -1
  64. package/dist/index.js.map +0 -1
  65. package/dist/input/index.d.ts.map +0 -1
  66. package/dist/input/index.js.map +0 -1
  67. package/dist/input/input.core.d.ts.map +0 -1
  68. package/dist/input/input.core.js.map +0 -1
  69. package/dist/rateLimit/index.d.ts.map +0 -1
  70. package/dist/rateLimit/index.js.map +0 -1
  71. package/dist/rateLimit/rateLimit.core.d.ts.map +0 -1
  72. package/dist/rateLimit/rateLimit.core.js.map +0 -1
  73. package/dist/rateLimit/rateLimit.namespace.d.ts.map +0 -1
  74. package/dist/rateLimit/rateLimit.namespace.js.map +0 -1
  75. package/dist/types/index.d.ts.map +0 -1
  76. package/dist/types/index.js.map +0 -1
  77. package/dist/types/security.type.d.ts.map +0 -1
  78. package/dist/types/security.type.js.map +0 -1
  79. package/dist/url/index.d.ts.map +0 -1
  80. package/dist/url/index.js.map +0 -1
  81. package/dist/url/url.core.d.ts.map +0 -1
  82. package/dist/url/url.core.js.map +0 -1
@@ -9,6 +9,26 @@ const MAX_COOKIE_HEADER_SIZE = 4096;
9
9
  const MAX_COOKIE_COUNT = 50;
10
10
  /** Maximum individual cookie size. */
11
11
  const MAX_COOKIE_SIZE = 1024;
12
+ /**
13
+ * RFC 6265 cookie-name grammar: a `token` as defined by RFC 9110.
14
+ *
15
+ * Separators (`()<>@,;:\"/[]?={}`), whitespace, and control characters are all
16
+ * excluded — several of them would otherwise let a name close the name/value
17
+ * pair early and inject an attribute.
18
+ */
19
+ const COOKIE_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
20
+ /**
21
+ * RFC 6265 cookie-octet: US-ASCII excluding controls, whitespace, double
22
+ * quote, comma, semicolon, and backslash.
23
+ */
24
+ const COOKIE_VALUE_PATTERN = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/;
25
+ /** Characters that must never reach an attribute value. */
26
+ const ATTRIBUTE_UNSAFE = /[;,\r\n\x00]/;
27
+ /**
28
+ * Control characters that are never acceptable inside a parsed cookie value,
29
+ * however lenient the rest of the read path is.
30
+ */
31
+ const COOKIE_VALUE_CONTROL_CHARS = /[\x00-\x1F\x7F]/;
12
32
  /**
13
33
  * Parses a raw cookie header string into individual cookies.
14
34
  *
@@ -42,8 +62,23 @@ export function parseCookieHeader(cookieHeader, config) {
42
62
  }
43
63
  const name = part.slice(0, eqIndex).trim();
44
64
  const value = part.slice(eqIndex + 1).trim();
45
- if (name.length === 0) {
46
- errors.push(`Cookie ${i}: empty cookie name`);
65
+ // The read path used to check length and nothing else, so a cookie whose
66
+ // name the package's own `validateCookieName` rejects — one carrying a
67
+ // separator, whitespace or a control character — still landed in
68
+ // `result.cookies` with `errors: []`. Names are unambiguous: RFC 6265
69
+ // defines them as a token, and anything else is malformed framing.
70
+ const nameError = validateCookieName(name);
71
+ if (nameError) {
72
+ errors.push(`Cookie ${i}: ${nameError}`);
73
+ continue;
74
+ }
75
+ // Values are deliberately checked less strictly than `validateCookieValue`
76
+ // does on the write path: real-world servers emit values containing
77
+ // spaces, commas and quoted-string wrappers, and rejecting those here
78
+ // would drop legitimate traffic. Control characters have no such excuse —
79
+ // a CR or LF in a value reflected back into a header splits the response.
80
+ if (COOKIE_VALUE_CONTROL_CHARS.test(value)) {
81
+ errors.push(`Cookie "${name}": value contains control characters`);
47
82
  continue;
48
83
  }
49
84
  if (Buffer.byteLength(value, "utf8") > maxSize) {
@@ -57,26 +92,64 @@ export function parseCookieHeader(cookieHeader, config) {
57
92
  /**
58
93
  * Serializes a parsed cookie into a Set-Cookie header value.
59
94
  *
95
+ * The name, value, and every attribute are validated before being written: a
96
+ * value carrying a CRLF would split the response, and one carrying a `;` would
97
+ * append attributes the caller never asked for. Values are percent-encoded by
98
+ * default so ordinary text (spaces, commas, non-ASCII) round-trips safely
99
+ * through {@link parseCookieHeader}.
100
+ *
60
101
  * @param cookie - The cookie to serialize.
61
102
  * @param config - Optional security configuration for defaults.
62
103
  * @returns The serialized Set-Cookie header value.
104
+ * @throws {Error} when the name, value, or an attribute is unsafe.
63
105
  */
64
106
  export function serializeCookie(cookie, config) {
65
- const parts = [`${cookie.name}=${cookie.value}`];
107
+ const nameError = validateCookieName(cookie.name);
108
+ if (nameError) {
109
+ throw new Error(`Cannot serialize cookie: ${nameError}`);
110
+ }
111
+ // Percent-encode unless the value is already a bare cookie-octet string, so
112
+ // a caller that passes an encoded value does not get it double-encoded.
113
+ const encodedValue = COOKIE_VALUE_PATTERN.test(cookie.value)
114
+ ? cookie.value
115
+ : encodeURIComponent(cookie.value);
116
+ const valueError = validateCookieValue(encodedValue);
117
+ if (valueError) {
118
+ throw new Error(`Cannot serialize cookie "${cookie.name}": ${valueError}`);
119
+ }
120
+ const parts = [`${cookie.name}=${encodedValue}`];
66
121
  if (cookie.path) {
122
+ assertSafeAttribute("Path", cookie.path);
67
123
  parts.push(`Path=${cookie.path}`);
68
124
  }
69
125
  if (cookie.domain) {
126
+ assertSafeAttribute("Domain", cookie.domain);
70
127
  parts.push(`Domain=${cookie.domain}`);
71
128
  }
72
129
  if (cookie.maxAge !== undefined) {
130
+ if (!Number.isInteger(cookie.maxAge)) {
131
+ throw new Error(`Cannot serialize cookie "${cookie.name}": Max-Age must be an integer, got ${cookie.maxAge}`);
132
+ }
73
133
  parts.push(`Max-Age=${cookie.maxAge}`);
74
134
  }
75
135
  if (cookie.expires) {
136
+ if (Number.isNaN(cookie.expires.getTime())) {
137
+ throw new Error(`Cannot serialize cookie "${cookie.name}": Expires is an invalid Date`);
138
+ }
76
139
  parts.push(`Expires=${cookie.expires.toUTCString()}`);
77
140
  }
78
141
  // Apply security defaults from config
79
142
  const secure = cookie.secure ?? config?.secure ?? true;
143
+ const sameSite = cookie.sameSite ?? config?.sameSite ?? "lax";
144
+ // SameSite=None is only honoured on a Secure cookie; without it browsers
145
+ // reject the cookie outright, which fails as a silent loss of state.
146
+ if (sameSite === "none" && !secure) {
147
+ throw new Error(`Cannot serialize cookie "${cookie.name}": SameSite=None requires the Secure attribute`);
148
+ }
149
+ // Partitioned (CHIPS) likewise requires Secure.
150
+ if (cookie.partitioned && !secure) {
151
+ throw new Error(`Cannot serialize cookie "${cookie.name}": Partitioned requires the Secure attribute`);
152
+ }
80
153
  if (secure) {
81
154
  parts.push("Secure");
82
155
  }
@@ -84,13 +157,18 @@ export function serializeCookie(cookie, config) {
84
157
  if (httpOnly) {
85
158
  parts.push("HttpOnly");
86
159
  }
87
- const sameSite = cookie.sameSite ?? config?.sameSite ?? "lax";
88
160
  parts.push(`SameSite=${sameSite.charAt(0).toUpperCase() + sameSite.slice(1)}`);
89
161
  if (cookie.partitioned) {
90
162
  parts.push("Partitioned");
91
163
  }
92
164
  return parts.join("; ");
93
165
  }
166
+ /** Throws when an attribute value could terminate the attribute or the header. */
167
+ function assertSafeAttribute(attribute, value) {
168
+ if (ATTRIBUTE_UNSAFE.test(value)) {
169
+ throw new Error(`Cookie ${attribute} contains invalid characters (injection risk): ${JSON.stringify(value)}`);
170
+ }
171
+ }
94
172
  /**
95
173
  * Creates a Set-Cookie header value with secure defaults.
96
174
  *
@@ -99,6 +177,7 @@ export function serializeCookie(cookie, config) {
99
177
  * @param options - Optional cookie attributes.
100
178
  * @param config - Optional security configuration.
101
179
  * @returns The serialized Set-Cookie header value.
180
+ * @throws {Error} when the name, value, or an attribute is unsafe.
102
181
  */
103
182
  export function createSecureCookie(name, value, options, config) {
104
183
  return serializeCookie({ name, value, ...options }, config);
@@ -116,8 +195,8 @@ export function validateCookieName(name) {
116
195
  if (name.length > 256) {
117
196
  return `Cookie name exceeds maximum length of 256: ${name.length}`;
118
197
  }
119
- // RFC 6265: cookie name cannot contain certain characters
120
- if (/[\s,;=]/.test(name)) {
198
+ // RFC 6265: cookie-name is a token — no separators, whitespace, or controls.
199
+ if (!COOKIE_NAME_PATTERN.test(name)) {
121
200
  return `Cookie name contains invalid characters: ${name}`;
122
201
  }
123
202
  return undefined;
@@ -133,15 +212,23 @@ export function validateCookieValue(value) {
133
212
  if (value.includes(";")) {
134
213
  return "Cookie value cannot contain semicolons";
135
214
  }
136
- // Check for control characters
215
+ // Check for control characters, CR and LF included
137
216
  if (/[\x00-\x1F\x7F]/.test(value)) {
138
217
  return "Cookie value contains control characters";
139
218
  }
219
+ // Remaining cookie-octet exclusions: whitespace, quote, comma, backslash.
220
+ if (/[\s",\\]/.test(value)) {
221
+ return "Cookie value contains characters that must be percent-encoded";
222
+ }
140
223
  return undefined;
141
224
  }
142
225
  /**
143
226
  * Strips security-sensitive cookies from a cookie header.
144
227
  *
228
+ * Matching is on the whole name and on `name`-prefixed variants (`session`
229
+ * also strips `session_id` and `session-token`), because the sensitive cookie
230
+ * in a real deployment is rarely named exactly `session`.
231
+ *
145
232
  * @param cookieHeader - The raw Cookie header.
146
233
  * @param sensitiveNames - Names of cookies to strip (case-insensitive).
147
234
  * @returns The cleaned cookie header.
@@ -156,7 +243,10 @@ export function stripSensitiveCookies(cookieHeader, sensitiveNames = ["session",
156
243
  if (eqIndex === -1)
157
244
  return false;
158
245
  const name = pair.slice(0, eqIndex).trim().toLowerCase();
159
- return !lowerSensitive.includes(name);
246
+ return !lowerSensitive.some((sensitive) => name === sensitive ||
247
+ name.startsWith(`${sensitive}_`) ||
248
+ name.startsWith(`${sensitive}-`) ||
249
+ name.startsWith(`${sensitive}.`));
160
250
  })
161
251
  .join("; ");
162
252
  }
@@ -14,6 +14,7 @@ export interface CorsHeaders {
14
14
  "Access-Control-Expose-Headers"?: string;
15
15
  "Access-Control-Allow-Credentials"?: string;
16
16
  "Access-Control-Max-Age"?: string;
17
+ Vary?: string;
17
18
  }
18
19
  /**
19
20
  * Checks if a request origin is allowed.
@@ -28,9 +29,15 @@ export declare function isOriginAllowed(origin: string | undefined, config: Cors
28
29
  *
29
30
  * @param requestOrigin - The request Origin header.
30
31
  * @param config - CORS configuration.
32
+ * @param request - Optional preflight request details. When supplied, the
33
+ * requested method and headers are validated and an unacceptable preflight
34
+ * returns no CORS headers at all.
31
35
  * @returns CORS headers to set on the response.
32
36
  */
33
- export declare function generatePreflightHeaders(requestOrigin: string | undefined, config: CorsConfig): CorsHeaders;
37
+ export declare function generatePreflightHeaders(requestOrigin: string | undefined, config: CorsConfig, request?: {
38
+ readonly method?: string;
39
+ readonly headers?: readonly string[];
40
+ }): CorsHeaders;
34
41
  /**
35
42
  * Generates CORS headers for a simple request.
36
43
  *
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Validates and generates CORS headers for cross-origin requests.
5
5
  */
6
+ import { withoutStickyFlags } from "../input/input.core.js";
6
7
  /** Default CORS configuration (restrictive). */
7
8
  const DEFAULT_CORS_CONFIG = {
8
9
  origin: undefined,
@@ -12,6 +13,23 @@ const DEFAULT_CORS_CONFIG = {
12
13
  credentials: false,
13
14
  maxAge: 86400,
14
15
  };
16
+ /**
17
+ * Rejects a configuration that pairs a wildcard origin with credentials.
18
+ *
19
+ * Browsers refuse the combination outright, so shipping it is not a leak so
20
+ * much as a policy that silently never works. Failing here turns a confusing
21
+ * runtime symptom into a startup error.
22
+ */
23
+ function assertConfigCoherent(config) {
24
+ if (!config.credentials)
25
+ return;
26
+ const origin = config.origin;
27
+ const hasWildcard = origin === "*" || (Array.isArray(origin) && origin.includes("*"));
28
+ if (hasWildcard) {
29
+ throw new Error('CORS: credentials cannot be combined with a wildcard origin ("*"). ' +
30
+ "Enumerate the allowed origins, or supply a function or RegExp.");
31
+ }
32
+ }
15
33
  /**
16
34
  * Checks if a request origin is allowed.
17
35
  *
@@ -20,6 +38,7 @@ const DEFAULT_CORS_CONFIG = {
20
38
  * @returns The allowed origin value, or undefined if not allowed.
21
39
  */
22
40
  export function isOriginAllowed(origin, config) {
41
+ assertConfigCoherent(config);
23
42
  if (!origin) {
24
43
  return undefined;
25
44
  }
@@ -32,9 +51,10 @@ export function isOriginAllowed(origin, config) {
32
51
  if (typeof allowedOrigin === "function") {
33
52
  return allowedOrigin(origin) ? origin : undefined;
34
53
  }
35
- // Regex check
54
+ // Regex check. A `g` or `y` flag would make `test` stateful via `lastIndex`,
55
+ // so the same origin would alternate between allowed and denied.
36
56
  if (allowedOrigin instanceof RegExp) {
37
- return allowedOrigin.test(origin) ? origin : undefined;
57
+ return withoutStickyFlags(allowedOrigin).test(origin) ? origin : undefined;
38
58
  }
39
59
  // String check
40
60
  if (typeof allowedOrigin === "string") {
@@ -56,23 +76,57 @@ export function isOriginAllowed(origin, config) {
56
76
  }
57
77
  return undefined;
58
78
  }
79
+ /**
80
+ * True when the allow-origin value depends on the request's Origin header.
81
+ *
82
+ * A reflected value must be accompanied by `Vary: Origin`, or a shared cache
83
+ * will hand one origin's response — and its `Access-Control-Allow-Origin` — to
84
+ * a different origin.
85
+ */
86
+ function isReflected(config) {
87
+ const origin = config.origin;
88
+ if (origin === undefined)
89
+ return false;
90
+ if (origin === "*")
91
+ return false;
92
+ // A single literal string always produces the same header; everything else
93
+ // (array, RegExp, predicate) varies with the request.
94
+ return typeof origin !== "string";
95
+ }
59
96
  /**
60
97
  * Generates CORS headers for a preflight request.
61
98
  *
62
99
  * @param requestOrigin - The request Origin header.
63
100
  * @param config - CORS configuration.
101
+ * @param request - Optional preflight request details. When supplied, the
102
+ * requested method and headers are validated and an unacceptable preflight
103
+ * returns no CORS headers at all.
64
104
  * @returns CORS headers to set on the response.
65
105
  */
66
- export function generatePreflightHeaders(requestOrigin, config) {
106
+ export function generatePreflightHeaders(requestOrigin, config, request) {
67
107
  const headers = {};
68
- const allowedOrigin = isOriginAllowed(requestOrigin, config);
69
- if (allowedOrigin) {
70
- headers["Access-Control-Allow-Origin"] = allowedOrigin;
108
+ // Vary is set even when the origin is rejected: the decision itself depends
109
+ // on the Origin header, so the negative response is equally uncacheable
110
+ // across origins.
111
+ if (isReflected(config)) {
112
+ headers.Vary = "Origin";
71
113
  }
72
- else {
114
+ const allowedOrigin = isOriginAllowed(requestOrigin, config);
115
+ if (!allowedOrigin) {
73
116
  // No matching origin — don't set CORS headers
74
117
  return headers;
75
118
  }
119
+ // Reject the preflight outright when it asks for something not permitted,
120
+ // rather than answering with a policy the browser will then enforce against.
121
+ if (request?.method !== undefined &&
122
+ !isMethodAllowed(request.method, config)) {
123
+ return headers;
124
+ }
125
+ if (request?.headers !== undefined &&
126
+ getDisallowedHeaders([...request.headers], config).length > 0) {
127
+ return headers;
128
+ }
129
+ headers["Access-Control-Allow-Origin"] = allowedOrigin;
76
130
  const methods = config.methods ?? DEFAULT_CORS_CONFIG.methods;
77
131
  headers["Access-Control-Allow-Methods"] = methods.join(", ");
78
132
  const allowedHeaders = config.allowedHeaders ?? DEFAULT_CORS_CONFIG.allowedHeaders;
@@ -93,6 +147,9 @@ export function generatePreflightHeaders(requestOrigin, config) {
93
147
  */
94
148
  export function generateSimpleHeaders(requestOrigin, config) {
95
149
  const headers = {};
150
+ if (isReflected(config)) {
151
+ headers.Vary = "Origin";
152
+ }
96
153
  const allowedOrigin = isOriginAllowed(requestOrigin, config);
97
154
  if (allowedOrigin) {
98
155
  headers["Access-Control-Allow-Origin"] = allowedOrigin;
@@ -115,7 +172,7 @@ export function generateSimpleHeaders(requestOrigin, config) {
115
172
  */
116
173
  export function isMethodAllowed(method, config) {
117
174
  const allowedMethods = config.methods ?? DEFAULT_CORS_CONFIG.methods;
118
- return allowedMethods.includes(method.toUpperCase());
175
+ return allowedMethods.some((allowed) => allowed.toUpperCase() === method.toUpperCase());
119
176
  }
120
177
  /**
121
178
  * Validates that all requested headers are allowed.
@@ -126,6 +183,6 @@ export function isMethodAllowed(method, config) {
126
183
  */
127
184
  export function getDisallowedHeaders(headers, config) {
128
185
  const allowedHeaders = new Set((config.allowedHeaders ?? DEFAULT_CORS_CONFIG.allowedHeaders).map((h) => h.toLowerCase()));
129
- return headers.filter((h) => !allowedHeaders.has(h.toLowerCase()));
186
+ return headers.filter((h) => !allowedHeaders.has(h.trim().toLowerCase()));
130
187
  }
131
188
  //# sourceMappingURL=cors.core.js.map
@@ -2,25 +2,78 @@
2
2
  * @zudojs/security — CSRF Protection
3
3
  *
4
4
  * Generates and validates CSRF tokens for state-changing requests.
5
+ *
6
+ * Two patterns are supported, both built on the same signed token:
7
+ *
8
+ * - **Synchroniser token** (recommended). The server stores the token in an
9
+ * `HttpOnly` cookie via {@link generateCsrfCookie} and renders the same
10
+ * token into the page or form. The browser returns it in a header or field,
11
+ * and {@link verifyDoubleSubmit} compares the two. Script on the page never
12
+ * needs to read the cookie, so `HttpOnly` stays on.
13
+ * - **Double-submit cookie.** Client-side script reads the cookie and echoes
14
+ * it into a header. This requires `httpOnly: false` on the cookie — pass it
15
+ * explicitly, and understand that any XSS on the origin can then read the
16
+ * token.
17
+ *
18
+ * Bind the token to a session wherever you have one: without `sessionId`, a
19
+ * token minted for one user validates for every other user.
5
20
  */
6
21
  import type { CsrfConfig } from "../types/security.type.js";
22
+ /**
23
+ * Minimum accepted secret length, in characters.
24
+ *
25
+ * The signature is HMAC-SHA256, so a secret shorter than the 32-byte output
26
+ * adds no strength beyond its own length. `"CSRF secret cannot be empty"` was
27
+ * the only check, which accepted a one-character secret in silence.
28
+ */
29
+ export declare const MIN_CSRF_SECRET_LENGTH = 32;
30
+ /** Options accepted by token generation and validation. */
31
+ export interface CsrfTokenOptions {
32
+ /** Token lifetime in seconds (default: 3600). */
33
+ readonly expiration?: number;
34
+ /**
35
+ * Session identifier to bind the token to.
36
+ *
37
+ * Strongly recommended: an unbound token is valid for every user, so an
38
+ * attacker can mint one with their own session and replay it against a
39
+ * victim's.
40
+ */
41
+ readonly sessionId?: string;
42
+ }
7
43
  /**
8
44
  * Generates a cryptographically secure CSRF token.
9
45
  *
10
46
  * @param secret - The secret key for HMAC generation.
11
- * @param expiration - Optional expiration in seconds (default: 3600).
12
- * @returns The CSRF token string.
47
+ * @param options - Expiration and session binding, or a bare expiration in
48
+ * seconds for backwards compatibility.
49
+ * @returns The CSRF token string, in the form `expiresAt:random:signature`.
13
50
  */
14
- export declare function generateCsrfToken(secret: string, expiration?: number): string;
51
+ export declare function generateCsrfToken(secret: string, options?: number | CsrfTokenOptions): string;
15
52
  /**
16
53
  * Validates a CSRF token.
17
54
  *
18
55
  * @param token - The CSRF token to validate.
19
56
  * @param secret - The secret key for verification.
20
- * @param expiration - Optional expiration in seconds (default: 3600).
21
- * @returns True if the token is valid and not expired.
57
+ * @param options - Maximum lifetime and session binding, or a bare expiration
58
+ * in seconds for backwards compatibility.
59
+ * @returns True if the token is valid, unexpired, and bound to this session.
60
+ */
61
+ export declare function validateCsrfToken(token: string, secret: string, options?: number | CsrfTokenOptions): boolean;
62
+ /**
63
+ * Verifies a state-changing request under the double-submit / synchroniser
64
+ * token pattern.
65
+ *
66
+ * Both tokens must be present, identical, and individually valid. Comparing
67
+ * the two alone is not enough — an attacker who can set a cookie on the origin
68
+ * could otherwise supply a matching pair of their own.
69
+ *
70
+ * @param cookieToken - Token taken from the CSRF cookie.
71
+ * @param requestToken - Token taken from the request header or form field.
72
+ * @param secret - The secret key for verification.
73
+ * @param options - Maximum lifetime and session binding.
74
+ * @returns True when the request carries a matching, valid token.
22
75
  */
23
- export declare function validateCsrfToken(token: string, secret: string, expiration?: number): boolean;
76
+ export declare function verifyDoubleSubmit(cookieToken: string | undefined, requestToken: string | undefined, secret: string, options?: number | CsrfTokenOptions): boolean;
24
77
  /**
25
78
  * Checks if a request method requires CSRF protection.
26
79
  *
@@ -32,6 +85,9 @@ export declare function requiresCsrfProtection(method: string, config?: CsrfConf
32
85
  /**
33
86
  * Extracts the CSRF token from request headers.
34
87
  *
88
+ * Lookup is case-insensitive: HTTP header names are, and a raw header bag is
89
+ * not guaranteed to arrive lowercased.
90
+ *
35
91
  * @param headers - Request headers.
36
92
  * @param headerName - The header name to look for.
37
93
  * @returns The CSRF token, or undefined.
@@ -46,11 +102,96 @@ export declare function extractCsrfTokenFromHeaders(headers: Record<string, stri
46
102
  */
47
103
  export declare function extractCsrfTokenFromCookies(cookieHeader: string, cookieName?: string): string | undefined;
48
104
  /**
49
- * Generates Set-Cookie header for CSRF token.
105
+ * Options for the CSRF cookie.
106
+ *
107
+ * `secret` is deliberately omitted: this function never reads one, and
108
+ * inheriting the whole of {@link CsrfConfig} made a *required* secret part of
109
+ * the declared shape of a call that has no use for it.
110
+ */
111
+ export interface CsrfCookieOptions extends Omit<CsrfConfig, "secret"> {
112
+ /**
113
+ * Whether to set `HttpOnly` (default: true).
114
+ *
115
+ * Keep it on for the synchroniser-token pattern, where the server renders
116
+ * the token into the page. Set it to `false` only for the double-submit
117
+ * pattern, where client script must read the cookie back.
118
+ */
119
+ readonly httpOnly?: boolean;
120
+ /**
121
+ * Whether to set `Secure` (default: true).
122
+ *
123
+ * Only turn this off for local development over plain HTTP. Without it the
124
+ * token is readable by anyone on the network path.
125
+ */
126
+ readonly secure?: boolean;
127
+ /** Cookie path (default: "/"). */
128
+ readonly path?: string;
129
+ }
130
+ /**
131
+ * Generates a Set-Cookie header for a CSRF token.
50
132
  *
51
133
  * @param token - The CSRF token to store.
52
- * @param config - Optional CSRF configuration.
134
+ * @param config - Optional CSRF and cookie configuration.
53
135
  * @returns The Set-Cookie header value.
54
136
  */
55
- export declare function generateCsrfCookie(token: string, config?: CsrfConfig): string;
137
+ export declare function generateCsrfCookie(token: string, config?: Partial<CsrfCookieOptions>): string;
138
+ /** Cookie-shaping options for {@link createCsrfProtection}. */
139
+ export interface CsrfProtectionOptions extends CsrfConfig {
140
+ /** Whether to set `HttpOnly` on the cookie (default: true). */
141
+ readonly httpOnly?: boolean;
142
+ /** Whether to set `Secure` on the cookie (default: true). */
143
+ readonly secure?: boolean;
144
+ /** Cookie path (default: "/"). */
145
+ readonly path?: string;
146
+ }
147
+ /** A CSRF token together with the `Set-Cookie` header that carries it. */
148
+ export interface IssuedCsrfToken {
149
+ /** The token to render into the page or return to the client. */
150
+ readonly token: string;
151
+ /** The `Set-Cookie` header value. */
152
+ readonly setCookie: string;
153
+ }
154
+ /** The request fields {@link CsrfProtection.verify} needs. */
155
+ export interface CsrfVerifiableRequest {
156
+ readonly method: string;
157
+ readonly headers?: Record<string, string | string[] | undefined>;
158
+ /** The raw `Cookie` header value. */
159
+ readonly cookieHeader?: string;
160
+ }
161
+ /** CSRF protection bound to one configuration. */
162
+ export interface CsrfProtection {
163
+ /** Mint a token and the cookie that carries it. */
164
+ issue(options?: {
165
+ readonly sessionId?: string;
166
+ }): IssuedCsrfToken;
167
+ /**
168
+ * Verify a request under the double-submit pattern.
169
+ *
170
+ * Returns `true` for a method that does not require protection, so it can be
171
+ * called unconditionally.
172
+ */
173
+ verify(request: CsrfVerifiableRequest, options?: {
174
+ readonly sessionId?: string;
175
+ }): boolean;
176
+ /** Whether this method requires protection under the configured methods. */
177
+ requiresProtection(method: string): boolean;
178
+ }
179
+ /**
180
+ * Binds a {@link CsrfConfig} to the CSRF primitives.
181
+ *
182
+ * Every field of `CsrfConfig` was previously inert. `secret` — the one
183
+ * *required* field — was never read by anything: each function took the secret
184
+ * as a positional argument instead. `headerName` was likewise never read, so a
185
+ * caller who configured it still had `x-csrf-token` looked up. Reaching the
186
+ * configured names meant passing them again, by hand, at four separate call
187
+ * sites.
188
+ *
189
+ * This composes the existing functions; it introduces no new token format.
190
+ *
191
+ * @param config - Secret, lifetime, cookie/header names, protected methods.
192
+ * @returns Protection bound to that configuration.
193
+ * @throws {Error} when the secret is missing or shorter than
194
+ * {@link MIN_CSRF_SECRET_LENGTH}.
195
+ */
196
+ export declare function createCsrfProtection(config: CsrfProtectionOptions): CsrfProtection;
56
197
  //# sourceMappingURL=csrf.core.d.ts.map