@zudojs/security 0.0.1 → 1.0.0
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.
- package/README.md +254 -11
- package/dist/body/body.core.d.ts +50 -2
- package/dist/body/body.core.js +150 -19
- package/dist/body/index.d.ts +1 -1
- package/dist/body/index.js +1 -1
- package/dist/cookie/cookie.core.d.ts +12 -0
- package/dist/cookie/cookie.core.js +98 -8
- package/dist/cors/cors.core.d.ts +8 -1
- package/dist/cors/cors.core.js +66 -9
- package/dist/csrf/csrf.core.d.ts +150 -9
- package/dist/csrf/csrf.core.js +199 -41
- package/dist/csrf/index.d.ts +2 -1
- package/dist/csrf/index.js +1 -1
- package/dist/header/header.core.js +18 -5
- package/dist/headers/headers.core.js +36 -4
- package/dist/index.d.ts +14 -9
- package/dist/index.js +10 -8
- package/dist/input/index.d.ts +1 -1
- package/dist/input/index.js +1 -1
- package/dist/input/input.core.d.ts +27 -0
- package/dist/input/input.core.js +125 -37
- package/dist/rateLimit/index.d.ts +2 -1
- package/dist/rateLimit/index.js +1 -1
- package/dist/rateLimit/rateLimit.core.d.ts +56 -11
- package/dist/rateLimit/rateLimit.core.js +160 -47
- package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
- package/dist/rateLimit/rateLimit.namespace.js +2 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/security.type.d.ts +36 -19
- package/dist/types/security.type.js +26 -11
- package/dist/url/index.d.ts +2 -1
- package/dist/url/index.js +1 -1
- package/dist/url/url.core.d.ts +58 -2
- package/dist/url/url.core.js +270 -43
- package/package.json +14 -7
- package/dist/body/body.core.d.ts.map +0 -1
- package/dist/body/body.core.js.map +0 -1
- package/dist/body/index.d.ts.map +0 -1
- package/dist/body/index.js.map +0 -1
- package/dist/cookie/cookie.core.d.ts.map +0 -1
- package/dist/cookie/cookie.core.js.map +0 -1
- package/dist/cookie/index.d.ts.map +0 -1
- package/dist/cookie/index.js.map +0 -1
- package/dist/cors/cors.core.d.ts.map +0 -1
- package/dist/cors/cors.core.js.map +0 -1
- package/dist/cors/cors.namespace.d.ts.map +0 -1
- package/dist/cors/cors.namespace.js.map +0 -1
- package/dist/cors/index.d.ts.map +0 -1
- package/dist/cors/index.js.map +0 -1
- package/dist/csrf/csrf.core.d.ts.map +0 -1
- package/dist/csrf/csrf.core.js.map +0 -1
- package/dist/csrf/index.d.ts.map +0 -1
- package/dist/csrf/index.js.map +0 -1
- package/dist/header/header.core.d.ts.map +0 -1
- package/dist/header/header.core.js.map +0 -1
- package/dist/header/index.d.ts.map +0 -1
- package/dist/header/index.js.map +0 -1
- package/dist/headers/headers.core.d.ts.map +0 -1
- package/dist/headers/headers.core.js.map +0 -1
- package/dist/headers/index.d.ts.map +0 -1
- package/dist/headers/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/input/index.d.ts.map +0 -1
- package/dist/input/index.js.map +0 -1
- package/dist/input/input.core.d.ts.map +0 -1
- package/dist/input/input.core.js.map +0 -1
- package/dist/rateLimit/index.d.ts.map +0 -1
- package/dist/rateLimit/index.js.map +0 -1
- package/dist/rateLimit/rateLimit.core.d.ts.map +0 -1
- package/dist/rateLimit/rateLimit.core.js.map +0 -1
- package/dist/rateLimit/rateLimit.namespace.d.ts.map +0 -1
- package/dist/rateLimit/rateLimit.namespace.js.map +0 -1
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/types/security.type.d.ts.map +0 -1
- package/dist/types/security.type.js.map +0 -1
- package/dist/url/index.d.ts.map +0 -1
- package/dist/url/index.js.map +0 -1
- package/dist/url/url.core.d.ts.map +0 -1
- 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
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
120
|
-
if (
|
|
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.
|
|
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
|
}
|
package/dist/cors/cors.core.d.ts
CHANGED
|
@@ -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
|
|
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
|
*
|
package/dist/cors/cors.core.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
package/dist/csrf/csrf.core.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
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,
|
|
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
|
|
21
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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?:
|
|
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
|