@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
package/dist/csrf/csrf.core.js
CHANGED
|
@@ -2,8 +2,23 @@
|
|
|
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
|
-
import {
|
|
21
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
22
|
/** Default token expiration (1 hour). */
|
|
8
23
|
const DEFAULT_EXPIRATION = 3600;
|
|
9
24
|
/** Default cookie name for CSRF token. */
|
|
@@ -14,34 +29,82 @@ const DEFAULT_HEADER_NAME = "x-csrf-token";
|
|
|
14
29
|
const SAFE_METHODS = ["GET", "HEAD", "OPTIONS", "TRACE"];
|
|
15
30
|
/** Default methods that require CSRF protection. */
|
|
16
31
|
const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
32
|
+
/**
|
|
33
|
+
* Minimum accepted secret length, in characters.
|
|
34
|
+
*
|
|
35
|
+
* The signature is HMAC-SHA256, so a secret shorter than the 32-byte output
|
|
36
|
+
* adds no strength beyond its own length. `"CSRF secret cannot be empty"` was
|
|
37
|
+
* the only check, which accepted a one-character secret in silence.
|
|
38
|
+
*/
|
|
39
|
+
export const MIN_CSRF_SECRET_LENGTH = 32;
|
|
40
|
+
/** Rejects a secret too short to be worth signing with. */
|
|
41
|
+
function assertUsableSecret(secret) {
|
|
42
|
+
if (secret.length === 0) {
|
|
43
|
+
throw new Error("CSRF secret cannot be empty: pass a random string of at least " +
|
|
44
|
+
`${MIN_CSRF_SECRET_LENGTH} characters, e.g. randomBytes(32).toString("hex")`);
|
|
45
|
+
}
|
|
46
|
+
if (secret.length < MIN_CSRF_SECRET_LENGTH) {
|
|
47
|
+
throw new Error(`CSRF secret is too short: got ${secret.length} characters, expected at least ` +
|
|
48
|
+
`${MIN_CSRF_SECRET_LENGTH}. The signature is HMAC-SHA256, so a shorter ` +
|
|
49
|
+
'secret adds no strength. Generate one with randomBytes(32).toString("hex").');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Computes the token signature.
|
|
54
|
+
*
|
|
55
|
+
* HMAC-SHA256 rather than `sha256(payload + secret)`: the plain-hash form is a
|
|
56
|
+
* secret-suffix construction with no security proof, and truncating it to 64
|
|
57
|
+
* bits leaves far too little margin.
|
|
58
|
+
*/
|
|
59
|
+
function sign(secret, expiresAt, random, sessionId) {
|
|
60
|
+
return createHmac("sha256", secret)
|
|
61
|
+
.update(`${expiresAt}:${random}:${sessionId}`)
|
|
62
|
+
.digest("hex");
|
|
63
|
+
}
|
|
64
|
+
/** Constant-time string comparison that does not leak length via early exit. */
|
|
65
|
+
function safeEqual(a, b) {
|
|
66
|
+
const bufA = Buffer.from(a, "utf8");
|
|
67
|
+
const bufB = Buffer.from(b, "utf8");
|
|
68
|
+
if (bufA.length !== bufB.length) {
|
|
69
|
+
// Still burn a comparison so the timing profile does not distinguish a
|
|
70
|
+
// length mismatch from a content mismatch.
|
|
71
|
+
timingSafeEqual(bufA, bufA);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return timingSafeEqual(bufA, bufB);
|
|
75
|
+
}
|
|
17
76
|
/**
|
|
18
77
|
* Generates a cryptographically secure CSRF token.
|
|
19
78
|
*
|
|
20
79
|
* @param secret - The secret key for HMAC generation.
|
|
21
|
-
* @param
|
|
22
|
-
*
|
|
80
|
+
* @param options - Expiration and session binding, or a bare expiration in
|
|
81
|
+
* seconds for backwards compatibility.
|
|
82
|
+
* @returns The CSRF token string, in the form `expiresAt:random:signature`.
|
|
23
83
|
*/
|
|
24
|
-
export function generateCsrfToken(secret,
|
|
25
|
-
|
|
84
|
+
export function generateCsrfToken(secret, options) {
|
|
85
|
+
assertUsableSecret(secret);
|
|
86
|
+
const opts = typeof options === "number" ? { expiration: options } : (options ?? {});
|
|
87
|
+
const ttl = opts.expiration ?? DEFAULT_EXPIRATION;
|
|
88
|
+
if (!Number.isFinite(ttl) || ttl <= 0) {
|
|
89
|
+
throw new RangeError(`CSRF expiration must be positive, got: ${ttl}`);
|
|
90
|
+
}
|
|
26
91
|
const expiresAt = Math.floor(Date.now() / 1000) + ttl;
|
|
27
92
|
const random = randomBytes(16).toString("hex");
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
.update(`${payload}:${secret}`)
|
|
31
|
-
.digest("hex")
|
|
32
|
-
.slice(0, 16);
|
|
33
|
-
return `${payload}:${signature}`;
|
|
93
|
+
const signature = sign(secret, expiresAt, random, opts.sessionId ?? "");
|
|
94
|
+
return `${expiresAt}:${random}:${signature}`;
|
|
34
95
|
}
|
|
35
96
|
/**
|
|
36
97
|
* Validates a CSRF token.
|
|
37
98
|
*
|
|
38
99
|
* @param token - The CSRF token to validate.
|
|
39
100
|
* @param secret - The secret key for verification.
|
|
40
|
-
* @param
|
|
41
|
-
*
|
|
101
|
+
* @param options - Maximum lifetime and session binding, or a bare expiration
|
|
102
|
+
* in seconds for backwards compatibility.
|
|
103
|
+
* @returns True if the token is valid, unexpired, and bound to this session.
|
|
42
104
|
*/
|
|
43
|
-
export function validateCsrfToken(token, secret,
|
|
44
|
-
const
|
|
105
|
+
export function validateCsrfToken(token, secret, options) {
|
|
106
|
+
const opts = typeof options === "number" ? { expiration: options } : (options ?? {});
|
|
107
|
+
const maxTtl = opts.expiration ?? DEFAULT_EXPIRATION;
|
|
45
108
|
const parts = token.split(":");
|
|
46
109
|
if (parts.length !== 3) {
|
|
47
110
|
return false;
|
|
@@ -50,30 +113,50 @@ export function validateCsrfToken(token, secret, expiration) {
|
|
|
50
113
|
if (!expiresAtStr || !random || !providedSignature) {
|
|
51
114
|
return false;
|
|
52
115
|
}
|
|
53
|
-
|
|
54
|
-
|
|
116
|
+
if (!/^\d+$/.test(expiresAtStr)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const expiresAt = Number(expiresAtStr);
|
|
120
|
+
if (!Number.isSafeInteger(expiresAt)) {
|
|
55
121
|
return false;
|
|
56
122
|
}
|
|
57
|
-
// Check expiration
|
|
58
123
|
const now = Math.floor(Date.now() / 1000);
|
|
124
|
+
// Expired.
|
|
59
125
|
if (now > expiresAt) {
|
|
60
126
|
return false;
|
|
61
127
|
}
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.slice(0, 16);
|
|
68
|
-
// Constant-time comparison
|
|
69
|
-
if (providedSignature.length !== expectedSignature.length) {
|
|
128
|
+
// Issued with a longer lifetime than this caller is willing to honour. The
|
|
129
|
+
// expiry is inside the signed payload, so it cannot be forged — but a token
|
|
130
|
+
// minted with a ten-year TTL should not be accepted by a route that expects
|
|
131
|
+
// one hour.
|
|
132
|
+
if (expiresAt - now > maxTtl) {
|
|
70
133
|
return false;
|
|
71
134
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
135
|
+
const expectedSignature = sign(secret, expiresAt, random, opts.sessionId ?? "");
|
|
136
|
+
return safeEqual(providedSignature, expectedSignature);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Verifies a state-changing request under the double-submit / synchroniser
|
|
140
|
+
* token pattern.
|
|
141
|
+
*
|
|
142
|
+
* Both tokens must be present, identical, and individually valid. Comparing
|
|
143
|
+
* the two alone is not enough — an attacker who can set a cookie on the origin
|
|
144
|
+
* could otherwise supply a matching pair of their own.
|
|
145
|
+
*
|
|
146
|
+
* @param cookieToken - Token taken from the CSRF cookie.
|
|
147
|
+
* @param requestToken - Token taken from the request header or form field.
|
|
148
|
+
* @param secret - The secret key for verification.
|
|
149
|
+
* @param options - Maximum lifetime and session binding.
|
|
150
|
+
* @returns True when the request carries a matching, valid token.
|
|
151
|
+
*/
|
|
152
|
+
export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
|
|
153
|
+
if (!cookieToken || !requestToken) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (!safeEqual(cookieToken, requestToken)) {
|
|
157
|
+
return false;
|
|
75
158
|
}
|
|
76
|
-
return
|
|
159
|
+
return validateCsrfToken(cookieToken, secret, options);
|
|
77
160
|
}
|
|
78
161
|
/**
|
|
79
162
|
* Checks if a request method requires CSRF protection.
|
|
@@ -92,13 +175,22 @@ export function requiresCsrfProtection(method, config) {
|
|
|
92
175
|
/**
|
|
93
176
|
* Extracts the CSRF token from request headers.
|
|
94
177
|
*
|
|
178
|
+
* Lookup is case-insensitive: HTTP header names are, and a raw header bag is
|
|
179
|
+
* not guaranteed to arrive lowercased.
|
|
180
|
+
*
|
|
95
181
|
* @param headers - Request headers.
|
|
96
182
|
* @param headerName - The header name to look for.
|
|
97
183
|
* @returns The CSRF token, or undefined.
|
|
98
184
|
*/
|
|
99
185
|
export function extractCsrfTokenFromHeaders(headers, headerName) {
|
|
100
|
-
const name = headerName ?? DEFAULT_HEADER_NAME;
|
|
101
|
-
|
|
186
|
+
const name = (headerName ?? DEFAULT_HEADER_NAME).toLowerCase();
|
|
187
|
+
let value;
|
|
188
|
+
for (const key of Object.keys(headers)) {
|
|
189
|
+
if (key.toLowerCase() === name) {
|
|
190
|
+
value = headers[key];
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
102
194
|
if (typeof value === "string") {
|
|
103
195
|
return value;
|
|
104
196
|
}
|
|
@@ -129,21 +221,87 @@ export function extractCsrfTokenFromCookies(cookieHeader, cookieName) {
|
|
|
129
221
|
return cookie?.value || undefined;
|
|
130
222
|
}
|
|
131
223
|
/**
|
|
132
|
-
* Generates Set-Cookie header for CSRF token.
|
|
224
|
+
* Generates a Set-Cookie header for a CSRF token.
|
|
133
225
|
*
|
|
134
226
|
* @param token - The CSRF token to store.
|
|
135
|
-
* @param config - Optional CSRF configuration.
|
|
227
|
+
* @param config - Optional CSRF and cookie configuration.
|
|
136
228
|
* @returns The Set-Cookie header value.
|
|
137
229
|
*/
|
|
138
230
|
export function generateCsrfCookie(token, config) {
|
|
139
231
|
const name = config?.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
140
232
|
const ttl = config?.expiration ?? DEFAULT_EXPIRATION;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
233
|
+
const httpOnly = config?.httpOnly ?? true;
|
|
234
|
+
const secure = config?.secure ?? true;
|
|
235
|
+
const path = config?.path ?? "/";
|
|
236
|
+
const parts = [`${name}=${token}`, `Path=${path}`];
|
|
237
|
+
if (httpOnly) {
|
|
238
|
+
parts.push("HttpOnly");
|
|
239
|
+
}
|
|
240
|
+
if (secure) {
|
|
241
|
+
parts.push("Secure");
|
|
242
|
+
}
|
|
243
|
+
parts.push("SameSite=Strict", `Max-Age=${ttl}`);
|
|
244
|
+
return parts.join("; ");
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Binds a {@link CsrfConfig} to the CSRF primitives.
|
|
248
|
+
*
|
|
249
|
+
* Every field of `CsrfConfig` was previously inert. `secret` — the one
|
|
250
|
+
* *required* field — was never read by anything: each function took the secret
|
|
251
|
+
* as a positional argument instead. `headerName` was likewise never read, so a
|
|
252
|
+
* caller who configured it still had `x-csrf-token` looked up. Reaching the
|
|
253
|
+
* configured names meant passing them again, by hand, at four separate call
|
|
254
|
+
* sites.
|
|
255
|
+
*
|
|
256
|
+
* This composes the existing functions; it introduces no new token format.
|
|
257
|
+
*
|
|
258
|
+
* @param config - Secret, lifetime, cookie/header names, protected methods.
|
|
259
|
+
* @returns Protection bound to that configuration.
|
|
260
|
+
* @throws {Error} when the secret is missing or shorter than
|
|
261
|
+
* {@link MIN_CSRF_SECRET_LENGTH}.
|
|
262
|
+
*/
|
|
263
|
+
export function createCsrfProtection(config) {
|
|
264
|
+
assertUsableSecret(config.secret);
|
|
265
|
+
const expiration = config.expiration ?? DEFAULT_EXPIRATION;
|
|
266
|
+
const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
267
|
+
const headerName = config.headerName ?? DEFAULT_HEADER_NAME;
|
|
268
|
+
return {
|
|
269
|
+
issue(options) {
|
|
270
|
+
const token = generateCsrfToken(config.secret, {
|
|
271
|
+
expiration,
|
|
272
|
+
...(options?.sessionId !== undefined
|
|
273
|
+
? { sessionId: options.sessionId }
|
|
274
|
+
: {}),
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
token,
|
|
278
|
+
setCookie: generateCsrfCookie(token, {
|
|
279
|
+
cookieName,
|
|
280
|
+
expiration,
|
|
281
|
+
...(config.httpOnly !== undefined
|
|
282
|
+
? { httpOnly: config.httpOnly }
|
|
283
|
+
: {}),
|
|
284
|
+
...(config.secure !== undefined ? { secure: config.secure } : {}),
|
|
285
|
+
...(config.path !== undefined ? { path: config.path } : {}),
|
|
286
|
+
}),
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
verify(request, options) {
|
|
290
|
+
if (!requiresCsrfProtection(request.method, config)) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
const cookieToken = extractCsrfTokenFromCookies(request.cookieHeader ?? "", cookieName);
|
|
294
|
+
const requestToken = extractCsrfTokenFromHeaders(request.headers ?? {}, headerName);
|
|
295
|
+
return verifyDoubleSubmit(cookieToken, requestToken, config.secret, {
|
|
296
|
+
expiration,
|
|
297
|
+
...(options?.sessionId !== undefined
|
|
298
|
+
? { sessionId: options.sessionId }
|
|
299
|
+
: {}),
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
requiresProtection(method) {
|
|
303
|
+
return requiresCsrfProtection(method, config);
|
|
304
|
+
},
|
|
305
|
+
};
|
|
148
306
|
}
|
|
149
307
|
//# sourceMappingURL=csrf.core.js.map
|
package/dist/csrf/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — CSRF Protection Barrel
|
|
3
3
|
*/
|
|
4
|
-
export { generateCsrfToken, validateCsrfToken, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, } from "./csrf.core.js";
|
|
4
|
+
export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf.core.js";
|
|
5
|
+
export type { CsrfTokenOptions, CsrfCookieOptions, CsrfProtection, CsrfProtectionOptions, IssuedCsrfToken, CsrfVerifiableRequest, } from "./csrf.core.js";
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/csrf/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — CSRF Protection Barrel
|
|
3
3
|
*/
|
|
4
|
-
export { generateCsrfToken, validateCsrfToken, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, } from "./csrf.core.js";
|
|
4
|
+
export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf.core.js";
|
|
5
5
|
//# sourceMappingURL=index.js.map
|
|
@@ -22,9 +22,14 @@ const DEFAULT_BLOCKED_HEADERS = [
|
|
|
22
22
|
*/
|
|
23
23
|
const CRLF_PATTERN = /[\r\n]/;
|
|
24
24
|
/**
|
|
25
|
-
* Null byte
|
|
25
|
+
* Null byte patterns.
|
|
26
|
+
*
|
|
27
|
+
* The plain form is for {@link RegExp.test}; the `g` form is for
|
|
28
|
+
* {@link String.replace}, which without the flag would strip only the first
|
|
29
|
+
* occurrence.
|
|
26
30
|
*/
|
|
27
31
|
const NULL_BYTE_PATTERN = /\x00/;
|
|
32
|
+
const NULL_BYTE_PATTERN_GLOBAL = /\x00/g;
|
|
28
33
|
/**
|
|
29
34
|
* Validates a single header name.
|
|
30
35
|
*
|
|
@@ -101,16 +106,23 @@ export function validateHeaders(headers, config) {
|
|
|
101
106
|
errors.push(nameError);
|
|
102
107
|
continue;
|
|
103
108
|
}
|
|
109
|
+
if (config?.blockHopByHop === true && isHopByHopHeader(name)) {
|
|
110
|
+
errors.push(`Header "${name}" is hop-by-hop and must not be forwarded (RFC 9110 s7.6.1)`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
104
113
|
// Validate values (may be array for Set-Cookie, etc.)
|
|
105
114
|
const values = Array.isArray(value) ? value : [value];
|
|
106
115
|
for (const v of values) {
|
|
116
|
+
// Size is accumulated whether or not the value is otherwise valid.
|
|
117
|
+
// Skipping invalid values under-counted exactly the headers most likely
|
|
118
|
+
// to breach `maxTotalSize`, so an oversized header could evade the total
|
|
119
|
+
// bound by also being malformed.
|
|
120
|
+
totalSize +=
|
|
121
|
+
Buffer.byteLength(name, "utf8") + Buffer.byteLength(v, "utf8");
|
|
107
122
|
const valueError = validateHeaderValue(name, v, config);
|
|
108
123
|
if (valueError) {
|
|
109
124
|
errors.push(valueError);
|
|
110
|
-
continue;
|
|
111
125
|
}
|
|
112
|
-
totalSize +=
|
|
113
|
-
Buffer.byteLength(name, "utf8") + Buffer.byteLength(v, "utf8");
|
|
114
126
|
}
|
|
115
127
|
}
|
|
116
128
|
// Check total size
|
|
@@ -131,7 +143,7 @@ export function validateHeaders(headers, config) {
|
|
|
131
143
|
*/
|
|
132
144
|
export function sanitizeHeaderValue(value) {
|
|
133
145
|
// Strip null bytes
|
|
134
|
-
let sanitized = value.replace(
|
|
146
|
+
let sanitized = value.replace(NULL_BYTE_PATTERN_GLOBAL, "");
|
|
135
147
|
// Strip CRLF (strip both \r and \n)
|
|
136
148
|
sanitized = sanitized.replace(/[\r\n]/g, "");
|
|
137
149
|
return sanitized.length > 0 ? sanitized : undefined;
|
|
@@ -148,6 +160,7 @@ export function isHopByHopHeader(name) {
|
|
|
148
160
|
"keep-alive",
|
|
149
161
|
"proxy-authenticate",
|
|
150
162
|
"proxy-authorization",
|
|
163
|
+
"proxy-connection",
|
|
151
164
|
"te",
|
|
152
165
|
"trailer",
|
|
153
166
|
"transfer-encoding",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Generates security-related HTTP response headers with secure defaults.
|
|
5
5
|
*/
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
6
7
|
/** Security header names. */
|
|
7
8
|
export const SECURITY_HEADER_NAMES = {
|
|
8
9
|
CONTENT_SECURITY_POLICY: "Content-Security-Policy",
|
|
@@ -16,15 +17,33 @@ export const SECURITY_HEADER_NAMES = {
|
|
|
16
17
|
OPENER_POLICY: "Cross-Origin-Opener-Policy",
|
|
17
18
|
RESOURCE_POLICY: "Cross-Origin-Resource-Policy",
|
|
18
19
|
};
|
|
20
|
+
/**
|
|
21
|
+
* Default Content-Security-Policy.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately restrictive: no scripts, styles, or frames from anywhere, and
|
|
24
|
+
* no plugin content. An app that serves a UI must replace this — the point of
|
|
25
|
+
* the default is that a service which forgets to set one is still locked down.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_CSP = "default-src 'self'; script-src 'self'; style-src 'self'; " +
|
|
28
|
+
"img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; " +
|
|
29
|
+
"base-uri 'self'; form-action 'self'";
|
|
30
|
+
/** Default HSTS: two years, all subdomains, preload-eligible. */
|
|
31
|
+
const DEFAULT_HSTS = "max-age=63072000; includeSubDomains; preload";
|
|
32
|
+
/** Default Permissions-Policy: deny the high-risk capabilities. */
|
|
33
|
+
const DEFAULT_PERMISSIONS_POLICY = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), " +
|
|
34
|
+
"magnetometer=(), microphone=(), payment=(), usb=()";
|
|
19
35
|
/** Default security headers. */
|
|
20
36
|
const DEFAULT_HEADERS = {
|
|
21
37
|
[SECURITY_HEADER_NAMES.CONTENT_TYPE_OPTIONS]: "nosniff",
|
|
22
38
|
[SECURITY_HEADER_NAMES.FRAME_OPTIONS]: "DENY",
|
|
23
|
-
[SECURITY_HEADER_NAMES.XSS_PROTECTION]: "
|
|
39
|
+
[SECURITY_HEADER_NAMES.XSS_PROTECTION]: "0",
|
|
24
40
|
[SECURITY_HEADER_NAMES.REFERRER_POLICY]: "strict-origin-when-cross-origin",
|
|
25
41
|
[SECURITY_HEADER_NAMES.DNS_PREFETCH_CONTROL]: "off",
|
|
26
42
|
[SECURITY_HEADER_NAMES.OPENER_POLICY]: "same-origin",
|
|
27
43
|
[SECURITY_HEADER_NAMES.RESOURCE_POLICY]: "same-origin",
|
|
44
|
+
[SECURITY_HEADER_NAMES.CONTENT_SECURITY_POLICY]: DEFAULT_CSP,
|
|
45
|
+
[SECURITY_HEADER_NAMES.HSTS]: DEFAULT_HSTS,
|
|
46
|
+
[SECURITY_HEADER_NAMES.PERMISSIONS_POLICY]: DEFAULT_PERMISSIONS_POLICY,
|
|
28
47
|
};
|
|
29
48
|
/**
|
|
30
49
|
* Generates security headers with secure defaults.
|
|
@@ -34,6 +53,15 @@ const DEFAULT_HEADERS = {
|
|
|
34
53
|
*/
|
|
35
54
|
export function generateSecurityHeaders(config) {
|
|
36
55
|
const headers = { ...DEFAULT_HEADERS };
|
|
56
|
+
// Config values end up verbatim in a response header, so a stray CRLF here
|
|
57
|
+
// would split the response exactly as an attacker-supplied one would.
|
|
58
|
+
if (config) {
|
|
59
|
+
for (const [key, value] of Object.entries(config)) {
|
|
60
|
+
if (typeof value === "string" && /[\r\n\x00]/.test(value)) {
|
|
61
|
+
throw new Error(`Security header "${key}" contains CRLF or null bytes (injection risk)`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
37
65
|
if (config?.contentSecurityPolicy) {
|
|
38
66
|
headers[SECURITY_HEADER_NAMES.CONTENT_SECURITY_POLICY] =
|
|
39
67
|
config.contentSecurityPolicy;
|
|
@@ -93,8 +121,10 @@ export function getMissingSecurityHeaders(headers) {
|
|
|
93
121
|
* @returns The base64-encoded nonce.
|
|
94
122
|
*/
|
|
95
123
|
export function generateCspNonce(nonceLength) {
|
|
96
|
-
const { randomBytes } = require("node:crypto");
|
|
97
124
|
const length = nonceLength ?? 16;
|
|
125
|
+
if (!Number.isInteger(length) || length < 16) {
|
|
126
|
+
throw new RangeError(`CSP nonce length must be an integer of at least 16 bytes, got: ${length}`);
|
|
127
|
+
}
|
|
98
128
|
return randomBytes(length).toString("base64");
|
|
99
129
|
}
|
|
100
130
|
/**
|
|
@@ -118,7 +148,9 @@ export function validateCspDirective(directive) {
|
|
|
118
148
|
if (directive.includes("'unsafe-hashes'")) {
|
|
119
149
|
warnings.push("unsafe-hashes weakens CSP");
|
|
120
150
|
}
|
|
121
|
-
//
|
|
122
|
-
|
|
151
|
+
// Every warning, not just the first. A policy carrying all three used to
|
|
152
|
+
// report only `unsafe-inline`, so fixing that one made the function fall
|
|
153
|
+
// silent while `unsafe-eval` and `unsafe-hashes` were still in place.
|
|
154
|
+
return warnings.length > 0 ? warnings.join("; ") : undefined;
|
|
123
155
|
}
|
|
124
156
|
//# sourceMappingURL=headers.core.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @example
|
|
11
11
|
* ```ts
|
|
12
|
-
* import { validateHeaders,
|
|
12
|
+
* import { validateHeaders, validateRequestTarget, generateSecurityHeaders } from '@zudojs/security';
|
|
13
13
|
*
|
|
14
14
|
* // Validate request headers
|
|
15
15
|
* const result = validateHeaders(request.headers);
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* throw new Error(result.errors.join(', '));
|
|
18
18
|
* }
|
|
19
19
|
*
|
|
20
|
-
* // Validate
|
|
21
|
-
*
|
|
20
|
+
* // Validate the request target. Node's `request.url` is origin-form
|
|
21
|
+
* // ("/users?page=1"), which has no scheme — `validateUrl` needs an absolute
|
|
22
|
+
* // URL and reports every such target as malformed.
|
|
23
|
+
* const targetResult = validateRequestTarget(request.url);
|
|
22
24
|
*
|
|
23
25
|
* // Add security headers
|
|
24
26
|
* const headers = generateSecurityHeaders();
|
|
@@ -26,19 +28,22 @@
|
|
|
26
28
|
*
|
|
27
29
|
* @packageDocumentation
|
|
28
30
|
*/
|
|
29
|
-
export type { HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CsrfConfig, RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig,
|
|
31
|
+
export type { HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CsrfConfig, RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig, InputSanitizationConfig, } from "./types/security.type.js";
|
|
30
32
|
export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "./types/security.type.js";
|
|
31
33
|
export { validateHeaderName, validateHeaderValue, validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "./header/index.js";
|
|
32
|
-
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, } from "./url/index.js";
|
|
33
|
-
export {
|
|
34
|
+
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url/index.js";
|
|
35
|
+
export type { RequestTargetConfig } from "./url/index.js";
|
|
36
|
+
export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body/index.js";
|
|
34
37
|
export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie/index.js";
|
|
35
38
|
export type { CorsHeaders } from "./cors/index.js";
|
|
36
39
|
export { isOriginAllowed, generatePreflightHeaders, generateSimpleHeaders, isMethodAllowed, getDisallowedHeaders, } from "./cors/index.js";
|
|
37
40
|
export { cors } from "./cors/cors.namespace.js";
|
|
38
|
-
export { generateCsrfToken, validateCsrfToken, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, } from "./csrf/index.js";
|
|
39
|
-
export {
|
|
41
|
+
export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf/index.js";
|
|
42
|
+
export type { CsrfTokenOptions, CsrfCookieOptions, CsrfProtection, CsrfProtectionOptions, IssuedCsrfToken, CsrfVerifiableRequest, } from "./csrf/index.js";
|
|
43
|
+
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
|
|
44
|
+
export type { RateLimiterOptions, ClientIpOptions } from "./rateLimit/index.js";
|
|
40
45
|
export { rateLimit } from "./rateLimit/rateLimit.namespace.js";
|
|
41
46
|
export { SECURITY_HEADER_NAMES } from "./headers/index.js";
|
|
42
47
|
export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers/index.js";
|
|
43
|
-
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
|
|
48
|
+
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
|
|
44
49
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @example
|
|
11
11
|
* ```ts
|
|
12
|
-
* import { validateHeaders,
|
|
12
|
+
* import { validateHeaders, validateRequestTarget, generateSecurityHeaders } from '@zudojs/security';
|
|
13
13
|
*
|
|
14
14
|
* // Validate request headers
|
|
15
15
|
* const result = validateHeaders(request.headers);
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* throw new Error(result.errors.join(', '));
|
|
18
18
|
* }
|
|
19
19
|
*
|
|
20
|
-
* // Validate
|
|
21
|
-
*
|
|
20
|
+
* // Validate the request target. Node's `request.url` is origin-form
|
|
21
|
+
* // ("/users?page=1"), which has no scheme — `validateUrl` needs an absolute
|
|
22
|
+
* // URL and reports every such target as malformed.
|
|
23
|
+
* const targetResult = validateRequestTarget(request.url);
|
|
22
24
|
*
|
|
23
25
|
* // Add security headers
|
|
24
26
|
* const headers = generateSecurityHeaders();
|
|
@@ -30,21 +32,21 @@ export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from
|
|
|
30
32
|
/* ─── Header Security ────────────────────────────────────────────────────── */
|
|
31
33
|
export { validateHeaderName, validateHeaderValue, validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "./header/index.js";
|
|
32
34
|
/* ─── URL Validation ─────────────────────────────────────────────────────── */
|
|
33
|
-
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, } from "./url/index.js";
|
|
35
|
+
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url/index.js";
|
|
34
36
|
/* ─── Body Validation ────────────────────────────────────────────────────── */
|
|
35
|
-
export { DEFAULT_BODY_LIMITS, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, createBodySizeChecker, } from "./body/index.js";
|
|
37
|
+
export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body/index.js";
|
|
36
38
|
/* ─── Cookie Security ────────────────────────────────────────────────────── */
|
|
37
39
|
export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie/index.js";
|
|
38
40
|
export { isOriginAllowed, generatePreflightHeaders, generateSimpleHeaders, isMethodAllowed, getDisallowedHeaders, } from "./cors/index.js";
|
|
39
41
|
export { cors } from "./cors/cors.namespace.js";
|
|
40
42
|
/* ─── CSRF ───────────────────────────────────────────────────────────────── */
|
|
41
|
-
export { generateCsrfToken, validateCsrfToken, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, } from "./csrf/index.js";
|
|
43
|
+
export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf/index.js";
|
|
42
44
|
/* ─── Rate Limiting ──────────────────────────────────────────────────────── */
|
|
43
|
-
export { defaultKeyGenerator, defaultHandler, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
|
|
45
|
+
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
|
|
44
46
|
export { rateLimit } from "./rateLimit/rateLimit.namespace.js";
|
|
45
47
|
/* ─── Security Headers ───────────────────────────────────────────────────── */
|
|
46
48
|
export { SECURITY_HEADER_NAMES } from "./headers/index.js";
|
|
47
49
|
export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers/index.js";
|
|
48
50
|
/* ─── Input Sanitization ─────────────────────────────────────────────────── */
|
|
49
|
-
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
|
|
51
|
+
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
|
|
50
52
|
//# sourceMappingURL=index.js.map
|
package/dist/input/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — Input Sanitization Barrel
|
|
3
3
|
*/
|
|
4
|
-
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
|
|
4
|
+
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/input/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — Input Sanitization Barrel
|
|
3
3
|
*/
|
|
4
|
-
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
|
|
4
|
+
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
|
|
5
5
|
//# sourceMappingURL=index.js.map
|
|
@@ -7,6 +7,9 @@ import type { InputSanitizationConfig } from "../types/security.type.js";
|
|
|
7
7
|
/**
|
|
8
8
|
* Checks if a string contains SQL injection patterns.
|
|
9
9
|
*
|
|
10
|
+
* A heuristic with a high false-positive rate on ordinary prose — never the
|
|
11
|
+
* only defence against injection. Parameterise your queries.
|
|
12
|
+
*
|
|
10
13
|
* @param input - The string to check.
|
|
11
14
|
* @returns True if SQL injection patterns are detected.
|
|
12
15
|
*/
|
|
@@ -36,6 +39,10 @@ export declare function sanitizeString(input: string, config?: InputSanitization
|
|
|
36
39
|
/**
|
|
37
40
|
* Sanitizes an object by recursively cleaning its values.
|
|
38
41
|
*
|
|
42
|
+
* Cycles and over-deep structures are handled: a repeated reference or a
|
|
43
|
+
* branch past `config.maxDepth` (default 32) becomes `undefined` instead of
|
|
44
|
+
* exhausting the stack.
|
|
45
|
+
*
|
|
39
46
|
* @param obj - The object to sanitize.
|
|
40
47
|
* @param config - Optional sanitization configuration.
|
|
41
48
|
* @returns The sanitized object.
|
|
@@ -49,6 +56,13 @@ export declare function sanitizeObject<T extends Record<string, unknown>>(obj: T
|
|
|
49
56
|
* @returns True if the string is safe.
|
|
50
57
|
*/
|
|
51
58
|
export declare function isSafeString(input: string, allowedPattern?: RegExp): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Returns an equivalent regex with the `g` and `y` flags removed.
|
|
61
|
+
*
|
|
62
|
+
* Both flags make `test` stateful via `lastIndex`; for a one-shot boolean
|
|
63
|
+
* check they only introduce order-dependent results.
|
|
64
|
+
*/
|
|
65
|
+
export declare function withoutStickyFlags(pattern: RegExp): RegExp;
|
|
52
66
|
/**
|
|
53
67
|
* Checks for common attack patterns in a string.
|
|
54
68
|
*
|
|
@@ -59,6 +73,11 @@ export declare function detectThreats(input: string): string[];
|
|
|
59
73
|
/**
|
|
60
74
|
* HTML-escapes a string to prevent XSS.
|
|
61
75
|
*
|
|
76
|
+
* Escapes the five characters that matter in element text and quoted attribute
|
|
77
|
+
* values. It does not make a string safe for an unquoted attribute, inside a
|
|
78
|
+
* `<script>` or `<style>` block, or in a URL position — those contexts need
|
|
79
|
+
* their own encoding.
|
|
80
|
+
*
|
|
62
81
|
* @param input - The string to escape.
|
|
63
82
|
* @returns The escaped string.
|
|
64
83
|
*/
|
|
@@ -66,6 +85,14 @@ export declare function escapeHtml(input: string): string;
|
|
|
66
85
|
/**
|
|
67
86
|
* Strips HTML tags from a string.
|
|
68
87
|
*
|
|
88
|
+
* This removes tag syntax; it is **not** an HTML sanitizer. The result is safe
|
|
89
|
+
* to treat as plain text, but must still be escaped with {@link escapeHtml}
|
|
90
|
+
* before being inserted back into a document — use a dedicated sanitizer if
|
|
91
|
+
* you need to keep markup.
|
|
92
|
+
*
|
|
93
|
+
* An unterminated `<` consumes the remainder of the input, which is the safe
|
|
94
|
+
* direction: a truncated tag never survives into the output.
|
|
95
|
+
*
|
|
69
96
|
* @param input - The string to strip.
|
|
70
97
|
* @returns The string with HTML tags removed.
|
|
71
98
|
*/
|