@zudojs/security 0.1.0 → 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/LICENSE +21 -0
- 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 +20 -13
- 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/types/index.d.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Re-exports all security configuration and result types.
|
|
5
5
|
*/
|
|
6
|
-
export type { HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CsrfConfig, RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig,
|
|
6
|
+
export type { HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CsrfConfig, RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig, InputSanitizationConfig, } from "./security.type.js";
|
|
7
7
|
export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "./security.type.js";
|
|
8
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -13,6 +13,15 @@ export interface HeaderSecurityConfig {
|
|
|
13
13
|
readonly maxTotalSize?: number;
|
|
14
14
|
/** Headers that should be rejected entirely. */
|
|
15
15
|
readonly blockedHeaders?: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Reject hop-by-hop headers (default: false).
|
|
18
|
+
*
|
|
19
|
+
* `isHopByHopHeader` was exported and consulted by nothing. Set this when
|
|
20
|
+
* validating a request that has crossed a proxy: `Transfer-Encoding`,
|
|
21
|
+
* `Connection` and friends are per-hop and a forwarded one is a
|
|
22
|
+
* request-smuggling primitive.
|
|
23
|
+
*/
|
|
24
|
+
readonly blockHopByHop?: boolean;
|
|
16
25
|
}
|
|
17
26
|
/** Result of header validation. */
|
|
18
27
|
export interface HeaderValidationResult {
|
|
@@ -116,8 +125,14 @@ export interface RateLimitConfig {
|
|
|
116
125
|
readonly windowMs: number;
|
|
117
126
|
/** Key generator function (defaults to IP). */
|
|
118
127
|
readonly keyGenerator?: (request: RateLimitRequest) => string;
|
|
119
|
-
/**
|
|
120
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Custom handler when rate limit is exceeded.
|
|
130
|
+
*
|
|
131
|
+
* Receives the decision that produced the rejection, so a handler can set an
|
|
132
|
+
* accurate `Retry-After` instead of guessing. A two-parameter handler is
|
|
133
|
+
* still assignable — the third argument is simply ignored.
|
|
134
|
+
*/
|
|
135
|
+
readonly handler?: (request: RateLimitRequest, response: RateLimitResponse, result: RateLimitResult) => void;
|
|
121
136
|
/** Skip certain requests. */
|
|
122
137
|
readonly skip?: (request: RateLimitRequest) => boolean;
|
|
123
138
|
/** Message returned when rate limited. */
|
|
@@ -166,21 +181,6 @@ export interface SecurityHeadersConfig {
|
|
|
166
181
|
/** Cross-Origin-Resource-Policy value. */
|
|
167
182
|
readonly resourcePolicy?: string;
|
|
168
183
|
}
|
|
169
|
-
/** Configuration for request validation. */
|
|
170
|
-
export interface RequestValidationConfig {
|
|
171
|
-
/** Allowed HTTP methods. */
|
|
172
|
-
readonly allowedMethods?: readonly string[];
|
|
173
|
-
/** Maximum request line size in bytes. */
|
|
174
|
-
readonly maxRequestLineSize?: number;
|
|
175
|
-
/** Allowed hosts (empty = allow all). */
|
|
176
|
-
readonly allowedHosts?: readonly string[];
|
|
177
|
-
/** Whether to validate the Host header. */
|
|
178
|
-
readonly validateHost?: boolean;
|
|
179
|
-
/** Whether to validate Content-Length. */
|
|
180
|
-
readonly validateContentLength?: boolean;
|
|
181
|
-
/** Maximum Content-Length value. */
|
|
182
|
-
readonly maxContentLength?: number;
|
|
183
|
-
}
|
|
184
184
|
/** Configuration for input sanitization. */
|
|
185
185
|
export interface InputSanitizationConfig {
|
|
186
186
|
/** Whether to strip null bytes. */
|
|
@@ -191,13 +191,30 @@ export interface InputSanitizationConfig {
|
|
|
191
191
|
readonly maxStringLength?: number;
|
|
192
192
|
/** Whether to prevent prototype pollution. */
|
|
193
193
|
readonly preventPrototypePollution?: boolean;
|
|
194
|
+
/** Maximum nesting depth to recurse into (default: 32). */
|
|
195
|
+
readonly maxDepth?: number;
|
|
194
196
|
/** Custom sanitizer function. */
|
|
195
197
|
readonly customSanitizer?: (value: string) => string;
|
|
196
198
|
}
|
|
197
199
|
/** Keys that indicate prototype pollution attempts. */
|
|
198
200
|
export declare const PROTOTYPE_POLLUTION_KEYS: readonly ["__proto__", "constructor", "prototype"];
|
|
199
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* Common SQL injection patterns.
|
|
203
|
+
*
|
|
204
|
+
* Heuristics, not a parser: they carry a high false-positive rate on ordinary
|
|
205
|
+
* prose (the word "update", a `#` in a hashtag) and must never be the only
|
|
206
|
+
* defence. Parameterise queries; use these for logging and alerting.
|
|
207
|
+
*
|
|
208
|
+
* Like {@link XSS_PATTERNS}, none carries the `g` flag — see the note there.
|
|
209
|
+
*/
|
|
200
210
|
export declare const SQL_INJECTION_PATTERNS: readonly [RegExp, RegExp, RegExp, RegExp];
|
|
201
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Common XSS patterns.
|
|
213
|
+
*
|
|
214
|
+
* These are matched with {@link RegExp.test}, so none of them carries the `g`
|
|
215
|
+
* flag: a global regex advances `lastIndex` on every `test` call and resumes
|
|
216
|
+
* from there on the next one, which makes a shared pattern report `false` for
|
|
217
|
+
* a payload it matched a moment earlier.
|
|
218
|
+
*/
|
|
202
219
|
export declare const XSS_PATTERNS: readonly [RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp];
|
|
203
220
|
//# sourceMappingURL=security.type.d.ts.map
|
|
@@ -9,23 +9,38 @@ export const PROTOTYPE_POLLUTION_KEYS = [
|
|
|
9
9
|
"constructor",
|
|
10
10
|
"prototype",
|
|
11
11
|
];
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Common SQL injection patterns.
|
|
14
|
+
*
|
|
15
|
+
* Heuristics, not a parser: they carry a high false-positive rate on ordinary
|
|
16
|
+
* prose (the word "update", a `#` in a hashtag) and must never be the only
|
|
17
|
+
* defence. Parameterise queries; use these for logging and alerting.
|
|
18
|
+
*
|
|
19
|
+
* Like {@link XSS_PATTERNS}, none carries the `g` flag — see the note there.
|
|
20
|
+
*/
|
|
13
21
|
export const SQL_INJECTION_PATTERNS = [
|
|
14
22
|
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE|UNION|FETCH|DECLARE|TRUNCATE|COMMENT|ALTER)\b)/i,
|
|
15
23
|
/(--|#|\/\*|\*\/)/,
|
|
16
24
|
/('\s*(OR|AND)\s*')/i,
|
|
17
25
|
/(;\s*(DROP|DELETE|INSERT|UPDATE))/i,
|
|
18
26
|
];
|
|
19
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Common XSS patterns.
|
|
29
|
+
*
|
|
30
|
+
* These are matched with {@link RegExp.test}, so none of them carries the `g`
|
|
31
|
+
* flag: a global regex advances `lastIndex` on every `test` call and resumes
|
|
32
|
+
* from there on the next one, which makes a shared pattern report `false` for
|
|
33
|
+
* a payload it matched a moment earlier.
|
|
34
|
+
*/
|
|
20
35
|
export const XSS_PATTERNS = [
|
|
21
|
-
/<script\b[^>]*>[\s\S]*?<\/script[^>]*>/
|
|
22
|
-
/<script\b[^>]*>/
|
|
23
|
-
/<\/script[^>]*>/
|
|
24
|
-
/javascript:/
|
|
25
|
-
/on\w+\s*=/
|
|
26
|
-
/data:text\/html/
|
|
27
|
-
/<iframe\b[^>]*>/
|
|
28
|
-
/<object\b[^>]*>/
|
|
29
|
-
/<embed\b[^>]*>/
|
|
36
|
+
/<script\b[^>]*>[\s\S]*?<\/script[^>]*>/i,
|
|
37
|
+
/<script\b[^>]*>/i,
|
|
38
|
+
/<\/script[^>]*>/i,
|
|
39
|
+
/javascript:/i,
|
|
40
|
+
/on\w+\s*=/i,
|
|
41
|
+
/data:text\/html/i,
|
|
42
|
+
/<iframe\b[^>]*>/i,
|
|
43
|
+
/<object\b[^>]*>/i,
|
|
44
|
+
/<embed\b[^>]*>/i,
|
|
30
45
|
];
|
|
31
46
|
//# sourceMappingURL=security.type.js.map
|
package/dist/url/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — URL Validation Barrel
|
|
3
3
|
*/
|
|
4
|
-
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, } from "./url.core.js";
|
|
4
|
+
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url.core.js";
|
|
5
|
+
export type { RequestTargetConfig } from "./url.core.js";
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/url/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — URL Validation Barrel
|
|
3
3
|
*/
|
|
4
|
-
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, } from "./url.core.js";
|
|
4
|
+
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url.core.js";
|
|
5
5
|
//# sourceMappingURL=index.js.map
|
package/dist/url/url.core.d.ts
CHANGED
|
@@ -5,6 +5,32 @@
|
|
|
5
5
|
* and ensures request targets are safe.
|
|
6
6
|
*/
|
|
7
7
|
import type { UrlValidationConfig, UrlValidationResult } from "../types/security.type.js";
|
|
8
|
+
/**
|
|
9
|
+
* Fully percent-decodes a string, up to {@link MAX_DECODE_ROUNDS} times.
|
|
10
|
+
*
|
|
11
|
+
* Traversal sequences cannot be recognised reliably by pattern-matching an
|
|
12
|
+
* encoded string: `..`, `%2e%2e`, `.%2e`, `%2e.` and their double-encoded
|
|
13
|
+
* forms are all the same path, and the variant space grows with every round of
|
|
14
|
+
* encoding. Decoding to a fixed point first collapses them into one shape.
|
|
15
|
+
*
|
|
16
|
+
* @param value - The string to decode.
|
|
17
|
+
* @returns The decoded string and whether decoding terminated cleanly.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fullyDecodeUri(value: string): {
|
|
20
|
+
decoded: string;
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
malformed: boolean;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Checks whether a path contains a traversal segment.
|
|
26
|
+
*
|
|
27
|
+
* Operates on the fully decoded form, and treats a backslash as a separator
|
|
28
|
+
* because Windows and some proxies do.
|
|
29
|
+
*
|
|
30
|
+
* @param path - The path to inspect.
|
|
31
|
+
* @returns True when a `..` segment is present.
|
|
32
|
+
*/
|
|
33
|
+
export declare function containsTraversal(path: string): boolean;
|
|
8
34
|
/**
|
|
9
35
|
* Validates a URL against security configuration.
|
|
10
36
|
*
|
|
@@ -16,10 +42,21 @@ export declare function validateUrl(url: string, config?: UrlValidationConfig):
|
|
|
16
42
|
/**
|
|
17
43
|
* Normalizes a URL path by resolving . and .. segments.
|
|
18
44
|
*
|
|
45
|
+
* `..` never escapes the root: at depth zero it is discarded rather than
|
|
46
|
+
* popping into the parent.
|
|
47
|
+
*
|
|
19
48
|
* @param pathname - The path to normalize.
|
|
20
49
|
* @returns The normalized path.
|
|
21
50
|
*/
|
|
22
51
|
export declare function normalizePath(pathname: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Options accepted by {@link validateRequestTarget}.
|
|
54
|
+
*
|
|
55
|
+
* A request target is origin-form (`/users?page=1`) and carries no scheme, so
|
|
56
|
+
* `allowedProtocols` can never apply to it. Declaring the full
|
|
57
|
+
* {@link UrlValidationConfig} advertised a knob this function cannot honour.
|
|
58
|
+
*/
|
|
59
|
+
export type RequestTargetConfig = Omit<UrlValidationConfig, "allowedProtocols">;
|
|
23
60
|
/**
|
|
24
61
|
* Validates a request target (URI path + query).
|
|
25
62
|
*
|
|
@@ -27,12 +64,31 @@ export declare function normalizePath(pathname: string): string;
|
|
|
27
64
|
* @param config - Optional validation configuration.
|
|
28
65
|
* @returns Validation result.
|
|
29
66
|
*/
|
|
30
|
-
export declare function validateRequestTarget(target: string, config?:
|
|
67
|
+
export declare function validateRequestTarget(target: string, config?: RequestTargetConfig): UrlValidationResult;
|
|
68
|
+
/**
|
|
69
|
+
* Checks whether a hostname points at a private, loopback, link-local or
|
|
70
|
+
* otherwise internal destination.
|
|
71
|
+
*
|
|
72
|
+
* @param hostname - The hostname to check (as produced by `URL.hostname`).
|
|
73
|
+
* @returns True when the hostname is not safe to reach outward.
|
|
74
|
+
*/
|
|
75
|
+
export declare function isPrivateHostname(hostname: string): boolean;
|
|
31
76
|
/**
|
|
32
77
|
* Checks if a URL is safe to follow (not pointing to internal resources).
|
|
33
78
|
*
|
|
79
|
+
* Only `http:` and `https:` are allowed by default — every other scheme,
|
|
80
|
+
* including `file:`, `gopher:`, `data:` and `blob:`, is rejected rather than
|
|
81
|
+
* denied one at a time.
|
|
82
|
+
*
|
|
83
|
+
* **This cannot stop DNS rebinding.** A public hostname may resolve to a
|
|
84
|
+
* private address, and may resolve differently between this check and the
|
|
85
|
+
* connection. For outbound requests that must be safe, resolve the hostname
|
|
86
|
+
* yourself, run {@link isPrivateHostname} against the resolved address, and
|
|
87
|
+
* connect to that address directly.
|
|
88
|
+
*
|
|
34
89
|
* @param url - The URL to check.
|
|
90
|
+
* @param allowedProtocols - Protocols to permit (default: http and https).
|
|
35
91
|
* @returns True if the URL appears safe.
|
|
36
92
|
*/
|
|
37
|
-
export declare function isSafeUrl(url: string): boolean;
|
|
93
|
+
export declare function isSafeUrl(url: string, allowedProtocols?: readonly string[]): boolean;
|
|
38
94
|
//# sourceMappingURL=url.core.d.ts.map
|
package/dist/url/url.core.js
CHANGED
|
@@ -8,18 +8,13 @@
|
|
|
8
8
|
const DEFAULT_MAX_URL_LENGTH = 2048;
|
|
9
9
|
/** Default allowed protocols. */
|
|
10
10
|
const DEFAULT_ALLOWED_PROTOCOLS = ["http:", "https:"];
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
*/
|
|
14
|
-
const TRAVERSAL_PATTERNS = [
|
|
15
|
-
/\.\./, // Simple ..
|
|
16
|
-
/%2e%2e/i, // URL-encoded ..
|
|
17
|
-
/%252e%252e/i, // Double-encoded ..
|
|
18
|
-
/\.\.%2f/i, // Mixed encoding
|
|
19
|
-
/\.\.%5c/i, // Mixed encoding with backslash
|
|
20
|
-
];
|
|
11
|
+
/** Maximum percent-decoding rounds before input is considered hostile. */
|
|
12
|
+
const MAX_DECODE_ROUNDS = 8;
|
|
21
13
|
/**
|
|
22
14
|
* Null byte patterns.
|
|
15
|
+
*
|
|
16
|
+
* Kept alongside full decoding because a caller may pass a target that is not
|
|
17
|
+
* decodable (invalid escapes) and still contains a literal or encoded NUL.
|
|
23
18
|
*/
|
|
24
19
|
const NULL_BYTE_PATTERNS = [
|
|
25
20
|
/\x00/, // Actual null byte
|
|
@@ -27,9 +22,91 @@ const NULL_BYTE_PATTERNS = [
|
|
|
27
22
|
/%2500/i, // Double-encoded null byte
|
|
28
23
|
];
|
|
29
24
|
/**
|
|
30
|
-
* Invalid percent-encoding
|
|
25
|
+
* Invalid percent-encoding.
|
|
26
|
+
*
|
|
27
|
+
* A `%` must be followed by exactly two hex digits. The lookahead also catches
|
|
28
|
+
* a truncated escape at the very end of the string, which a "`%` plus one
|
|
29
|
+
* character" pattern silently accepts.
|
|
30
|
+
*/
|
|
31
|
+
const INVALID_PERCENT_ENCODING = /%(?![0-9a-fA-F]{2})/;
|
|
32
|
+
/**
|
|
33
|
+
* Control characters that must never appear in a request target.
|
|
34
|
+
*
|
|
35
|
+
* Tab is tolerated; CR and LF are not. Leaving those two out of the class —
|
|
36
|
+
* as an "everything except tab" range easily does — is what allows a target to
|
|
37
|
+
* be reflected into a header and split the response.
|
|
38
|
+
*/
|
|
39
|
+
const TARGET_CONTROL_CHARS = /[\x00-\x08\x0A-\x1F\x7F]/;
|
|
40
|
+
/**
|
|
41
|
+
* Fully percent-decodes a string, up to {@link MAX_DECODE_ROUNDS} times.
|
|
42
|
+
*
|
|
43
|
+
* Traversal sequences cannot be recognised reliably by pattern-matching an
|
|
44
|
+
* encoded string: `..`, `%2e%2e`, `.%2e`, `%2e.` and their double-encoded
|
|
45
|
+
* forms are all the same path, and the variant space grows with every round of
|
|
46
|
+
* encoding. Decoding to a fixed point first collapses them into one shape.
|
|
47
|
+
*
|
|
48
|
+
* @param value - The string to decode.
|
|
49
|
+
* @returns The decoded string and whether decoding terminated cleanly.
|
|
50
|
+
*/
|
|
51
|
+
export function fullyDecodeUri(value) {
|
|
52
|
+
let current = value;
|
|
53
|
+
let malformed = INVALID_PERCENT_ENCODING.test(value);
|
|
54
|
+
for (let round = 0; round < MAX_DECODE_ROUNDS; round++) {
|
|
55
|
+
const { decoded: next, malformed: roundMalformed } = decodeOnce(current);
|
|
56
|
+
if (roundMalformed)
|
|
57
|
+
malformed = true;
|
|
58
|
+
if (next === current) {
|
|
59
|
+
return { decoded: current, truncated: false, malformed };
|
|
60
|
+
}
|
|
61
|
+
current = next;
|
|
62
|
+
}
|
|
63
|
+
// Still changing after the cap: treat as hostile rather than looping.
|
|
64
|
+
return { decoded: current, truncated: true, malformed };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* One decoding round that survives a malformed escape.
|
|
68
|
+
*
|
|
69
|
+
* `decodeURIComponent` throws for the *whole* string if any escape in it is
|
|
70
|
+
* bad, so a single stray `%` anywhere used to abort decoding entirely and
|
|
71
|
+
* return the still-encoded input with `truncated: false`. Every downstream
|
|
72
|
+
* check then ran against the encoded form: `/a/%2e%2e/etc/passwd%` was
|
|
73
|
+
* reported valid, and so was `/a%0d%0aX-Evil:1%`. That is a fail-open guard.
|
|
74
|
+
*
|
|
75
|
+
* Decoding run-by-run instead keeps multi-byte sequences intact (a run of
|
|
76
|
+
* valid triplets is a complete UTF-8 character) while leaving an invalid run
|
|
77
|
+
* as literal text, so the rest of the string still collapses to its true
|
|
78
|
+
* shape and `malformed` records that something was wrong.
|
|
79
|
+
*/
|
|
80
|
+
function decodeOnce(value) {
|
|
81
|
+
let malformed = false;
|
|
82
|
+
const decoded = value.replace(/(?:%[0-9a-fA-F]{2})+/g, (run) => {
|
|
83
|
+
try {
|
|
84
|
+
return decodeURIComponent(run);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
malformed = true;
|
|
88
|
+
return run;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return { decoded, malformed };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Checks whether a path contains a traversal segment.
|
|
95
|
+
*
|
|
96
|
+
* Operates on the fully decoded form, and treats a backslash as a separator
|
|
97
|
+
* because Windows and some proxies do.
|
|
98
|
+
*
|
|
99
|
+
* @param path - The path to inspect.
|
|
100
|
+
* @returns True when a `..` segment is present.
|
|
31
101
|
*/
|
|
32
|
-
|
|
102
|
+
export function containsTraversal(path) {
|
|
103
|
+
const { decoded, truncated } = fullyDecodeUri(path);
|
|
104
|
+
if (truncated)
|
|
105
|
+
return true;
|
|
106
|
+
return decoded
|
|
107
|
+
.split(/[/\\]/)
|
|
108
|
+
.some((segment) => segment === ".." || segment === "...");
|
|
109
|
+
}
|
|
33
110
|
/**
|
|
34
111
|
* Validates a URL against security configuration.
|
|
35
112
|
*
|
|
@@ -69,25 +146,35 @@ export function validateUrl(url, config) {
|
|
|
69
146
|
if (!allowedProtocols.includes(parsed.protocol)) {
|
|
70
147
|
errors.push(`Protocol "${parsed.protocol}" is not allowed (allowed: ${allowedProtocols.join(", ")})`);
|
|
71
148
|
}
|
|
72
|
-
// Check for path traversal
|
|
149
|
+
// Check for path traversal against the raw path, decoded to a fixed point.
|
|
73
150
|
if (config?.blockTraversal !== false) {
|
|
74
|
-
// Extract raw path from URL before normalization
|
|
75
151
|
const rawPath = url.split("?")[0]?.split("#")[0] ?? "";
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
errors.push("URL contains path traversal attempts");
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
152
|
+
if (containsTraversal(rawPath)) {
|
|
153
|
+
errors.push("URL contains path traversal attempts");
|
|
81
154
|
}
|
|
82
155
|
}
|
|
156
|
+
// `normalizePaths` was declared on UrlValidationConfig and read only by
|
|
157
|
+
// validateRequestTarget, so `UrlValidationResult.normalized` was never once
|
|
158
|
+
// populated by this function — the field looked optional-by-chance rather
|
|
159
|
+
// than opt-in.
|
|
160
|
+
let normalized;
|
|
161
|
+
if (config?.normalizePaths === true) {
|
|
162
|
+
const rebuilt = new URL(parsed.href);
|
|
163
|
+
rebuilt.pathname = normalizePath(parsed.pathname);
|
|
164
|
+
normalized = rebuilt.href;
|
|
165
|
+
}
|
|
83
166
|
return {
|
|
84
167
|
valid: errors.length === 0,
|
|
168
|
+
normalized,
|
|
85
169
|
errors,
|
|
86
170
|
};
|
|
87
171
|
}
|
|
88
172
|
/**
|
|
89
173
|
* Normalizes a URL path by resolving . and .. segments.
|
|
90
174
|
*
|
|
175
|
+
* `..` never escapes the root: at depth zero it is discarded rather than
|
|
176
|
+
* popping into the parent.
|
|
177
|
+
*
|
|
91
178
|
* @param pathname - The path to normalize.
|
|
92
179
|
* @returns The normalized path.
|
|
93
180
|
*/
|
|
@@ -119,6 +206,13 @@ export function normalizePath(pathname) {
|
|
|
119
206
|
*/
|
|
120
207
|
export function validateRequestTarget(target, config) {
|
|
121
208
|
const errors = [];
|
|
209
|
+
// `maxLength` was accepted and discarded here, leaving the request target —
|
|
210
|
+
// the one input that arrives straight off the wire — with no length bound
|
|
211
|
+
// at all.
|
|
212
|
+
const maxLength = config?.maxLength ?? DEFAULT_MAX_URL_LENGTH;
|
|
213
|
+
if (target.length > maxLength) {
|
|
214
|
+
errors.push(`Request target length ${target.length} exceeds maximum ${maxLength}`);
|
|
215
|
+
}
|
|
122
216
|
// Check for null bytes
|
|
123
217
|
for (const pattern of NULL_BYTE_PATTERNS) {
|
|
124
218
|
if (pattern.test(target)) {
|
|
@@ -128,17 +222,28 @@ export function validateRequestTarget(target, config) {
|
|
|
128
222
|
}
|
|
129
223
|
// Check for path traversal
|
|
130
224
|
if (config?.blockTraversal !== false) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
errors.push("Request target contains path traversal attempts");
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
225
|
+
if (containsTraversal(target.split("?")[0] ?? "")) {
|
|
226
|
+
errors.push("Request target contains path traversal attempts");
|
|
136
227
|
}
|
|
137
228
|
}
|
|
138
|
-
// Check for control characters (
|
|
139
|
-
if (
|
|
229
|
+
// Check for control characters (tab is tolerated; CR and LF are not)
|
|
230
|
+
if (TARGET_CONTROL_CHARS.test(target)) {
|
|
140
231
|
errors.push("Request target contains control characters");
|
|
141
232
|
}
|
|
233
|
+
// `validateUrl` has always rejected a malformed escape; the request-target
|
|
234
|
+
// path did not, which is how a single trailing `%` used to disable both the
|
|
235
|
+
// traversal and the encoded-CRLF checks below.
|
|
236
|
+
if (INVALID_PERCENT_ENCODING.test(target)) {
|
|
237
|
+
errors.push("Request target contains invalid percent encoding (a % must be followed by two hex digits)");
|
|
238
|
+
}
|
|
239
|
+
// A decoded CR/LF is just as dangerous as a literal one. Run this whenever
|
|
240
|
+
// decoding produced anything, rather than only when the whole string
|
|
241
|
+
// changed.
|
|
242
|
+
const { decoded } = fullyDecodeUri(target);
|
|
243
|
+
if (!TARGET_CONTROL_CHARS.test(target) &&
|
|
244
|
+
TARGET_CONTROL_CHARS.test(decoded)) {
|
|
245
|
+
errors.push("Request target contains encoded control characters");
|
|
246
|
+
}
|
|
142
247
|
// Normalize if requested
|
|
143
248
|
let normalized;
|
|
144
249
|
if (config?.normalizePaths) {
|
|
@@ -155,33 +260,155 @@ export function validateRequestTarget(target, config) {
|
|
|
155
260
|
errors,
|
|
156
261
|
};
|
|
157
262
|
}
|
|
263
|
+
/* ─── Private network detection ──────────────────────────────────────────── */
|
|
264
|
+
/**
|
|
265
|
+
* Parses a dotted-quad IPv4 address into its four octets.
|
|
266
|
+
*
|
|
267
|
+
* Returns undefined for anything that is not already in canonical form —
|
|
268
|
+
* decimal (`2130706433`) and octal (`0177.0.0.1`) hosts are normalised to
|
|
269
|
+
* dotted-quad by the WHATWG URL parser before this is reached.
|
|
270
|
+
*/
|
|
271
|
+
function parseIpv4(hostname) {
|
|
272
|
+
const parts = hostname.split(".");
|
|
273
|
+
if (parts.length !== 4)
|
|
274
|
+
return undefined;
|
|
275
|
+
const octets = [];
|
|
276
|
+
for (const part of parts) {
|
|
277
|
+
if (!/^\d{1,3}$/.test(part))
|
|
278
|
+
return undefined;
|
|
279
|
+
const n = Number(part);
|
|
280
|
+
if (n > 255)
|
|
281
|
+
return undefined;
|
|
282
|
+
octets.push(n);
|
|
283
|
+
}
|
|
284
|
+
return octets;
|
|
285
|
+
}
|
|
286
|
+
/** True when the dotted-quad address falls in a private or special-use range. */
|
|
287
|
+
function isPrivateIpv4(octets) {
|
|
288
|
+
const [a = 0, b = 0] = octets;
|
|
289
|
+
return (a === 0 || // 0.0.0.0/8 "this network" — resolves to localhost on Linux
|
|
290
|
+
a === 10 || // 10.0.0.0/8 private
|
|
291
|
+
a === 127 || // 127.0.0.0/8 loopback (all of it, not just 127.0.0.1)
|
|
292
|
+
(a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 CGNAT
|
|
293
|
+
(a === 169 && b === 254) || // 169.254.0.0/16 link-local + cloud metadata
|
|
294
|
+
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
|
|
295
|
+
(a === 192 && b === 168) || // 192.168.0.0/16 private
|
|
296
|
+
(a === 192 && b === 0) || // 192.0.0.0/24 + 192.0.2.0/24 special-use
|
|
297
|
+
(a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 benchmarking
|
|
298
|
+
a >= 224 // 224/4 multicast, 240/4 reserved, 255.255.255.255 broadcast
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* True when an IPv6 hostname (already stripped of brackets) is private.
|
|
303
|
+
*
|
|
304
|
+
* Also unwraps IPv4-mapped and IPv4-compatible forms, so `::ffff:127.0.0.1`
|
|
305
|
+
* is recognised as loopback rather than treated as an opaque v6 address.
|
|
306
|
+
*/
|
|
307
|
+
function isPrivateIpv6(hostname) {
|
|
308
|
+
const host = hostname.toLowerCase();
|
|
309
|
+
if (host === "::1" || host === "::" || host === "::0")
|
|
310
|
+
return true;
|
|
311
|
+
// IPv4-mapped (::ffff:127.0.0.1) and IPv4-compatible (::127.0.0.1)
|
|
312
|
+
const mapped = /^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/.exec(host);
|
|
313
|
+
if (mapped?.[1]) {
|
|
314
|
+
const octets = parseIpv4(mapped[1]);
|
|
315
|
+
return octets ? isPrivateIpv4(octets) : true;
|
|
316
|
+
}
|
|
317
|
+
// Hex-form IPv4-mapped: ::ffff:7f00:1
|
|
318
|
+
const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
|
|
319
|
+
if (hexMapped?.[1] && hexMapped[2]) {
|
|
320
|
+
const high = parseInt(hexMapped[1], 16);
|
|
321
|
+
const low = parseInt(hexMapped[2], 16);
|
|
322
|
+
const octets = [high >> 8, high & 0xff, low >> 8, low & 0xff];
|
|
323
|
+
return isPrivateIpv4(octets);
|
|
324
|
+
}
|
|
325
|
+
const firstGroup = host.split(":")[0] ?? "";
|
|
326
|
+
const leading = parseInt(firstGroup.padEnd(4, "0"), 16);
|
|
327
|
+
// fc00::/7 unique local
|
|
328
|
+
if ((leading & 0xfe00) === 0xfc00)
|
|
329
|
+
return true;
|
|
330
|
+
// fe80::/10 link-local
|
|
331
|
+
if ((leading & 0xffc0) === 0xfe80)
|
|
332
|
+
return true;
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Hostnames that resolve inside the local network or to a metadata service.
|
|
337
|
+
*/
|
|
338
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
339
|
+
"localhost",
|
|
340
|
+
"metadata.google.internal",
|
|
341
|
+
"metadata",
|
|
342
|
+
"instance-data",
|
|
343
|
+
]);
|
|
344
|
+
/** Hostname suffixes reserved for local or internal resolution. */
|
|
345
|
+
const BLOCKED_HOST_SUFFIXES = [
|
|
346
|
+
".localhost",
|
|
347
|
+
".local",
|
|
348
|
+
".internal",
|
|
349
|
+
".localdomain",
|
|
350
|
+
];
|
|
351
|
+
/**
|
|
352
|
+
* Checks whether a hostname points at a private, loopback, link-local or
|
|
353
|
+
* otherwise internal destination.
|
|
354
|
+
*
|
|
355
|
+
* @param hostname - The hostname to check (as produced by `URL.hostname`).
|
|
356
|
+
* @returns True when the hostname is not safe to reach outward.
|
|
357
|
+
*/
|
|
358
|
+
export function isPrivateHostname(hostname) {
|
|
359
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
360
|
+
if (host.length === 0)
|
|
361
|
+
return true;
|
|
362
|
+
if (BLOCKED_HOSTNAMES.has(host))
|
|
363
|
+
return true;
|
|
364
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix)))
|
|
365
|
+
return true;
|
|
366
|
+
// URL.hostname keeps IPv6 literals in brackets.
|
|
367
|
+
if (host.startsWith("[") && host.endsWith("]")) {
|
|
368
|
+
return isPrivateIpv6(host.slice(1, -1));
|
|
369
|
+
}
|
|
370
|
+
if (host.includes(":")) {
|
|
371
|
+
return isPrivateIpv6(host);
|
|
372
|
+
}
|
|
373
|
+
const octets = parseIpv4(host);
|
|
374
|
+
if (octets) {
|
|
375
|
+
return isPrivateIpv4(octets);
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
158
379
|
/**
|
|
159
380
|
* Checks if a URL is safe to follow (not pointing to internal resources).
|
|
160
381
|
*
|
|
382
|
+
* Only `http:` and `https:` are allowed by default — every other scheme,
|
|
383
|
+
* including `file:`, `gopher:`, `data:` and `blob:`, is rejected rather than
|
|
384
|
+
* denied one at a time.
|
|
385
|
+
*
|
|
386
|
+
* **This cannot stop DNS rebinding.** A public hostname may resolve to a
|
|
387
|
+
* private address, and may resolve differently between this check and the
|
|
388
|
+
* connection. For outbound requests that must be safe, resolve the hostname
|
|
389
|
+
* yourself, run {@link isPrivateHostname} against the resolved address, and
|
|
390
|
+
* connect to that address directly.
|
|
391
|
+
*
|
|
161
392
|
* @param url - The URL to check.
|
|
393
|
+
* @param allowedProtocols - Protocols to permit (default: http and https).
|
|
162
394
|
* @returns True if the URL appears safe.
|
|
163
395
|
*/
|
|
164
|
-
export function isSafeUrl(url) {
|
|
396
|
+
export function isSafeUrl(url, allowedProtocols = DEFAULT_ALLOWED_PROTOCOLS) {
|
|
397
|
+
let parsed;
|
|
165
398
|
try {
|
|
166
|
-
|
|
167
|
-
// Block file:// protocol
|
|
168
|
-
if (parsed.protocol === "file:") {
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
// Block internal network ranges (basic check)
|
|
172
|
-
const hostname = parsed.hostname;
|
|
173
|
-
if (hostname === "localhost" ||
|
|
174
|
-
hostname === "127.0.0.1" ||
|
|
175
|
-
hostname === "::1" ||
|
|
176
|
-
hostname.startsWith("192.168.") ||
|
|
177
|
-
hostname.startsWith("10.") ||
|
|
178
|
-
hostname.startsWith("172.")) {
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
return true;
|
|
399
|
+
parsed = new URL(url);
|
|
182
400
|
}
|
|
183
401
|
catch {
|
|
184
402
|
return false;
|
|
185
403
|
}
|
|
404
|
+
if (!allowedProtocols.includes(parsed.protocol)) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
// Credentials in a URL are a redirect-laundering vector and never needed
|
|
408
|
+
// for a target this function is asked to vouch for.
|
|
409
|
+
if (parsed.username || parsed.password) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
return !isPrivateHostname(parsed.hostname);
|
|
186
413
|
}
|
|
187
414
|
//# sourceMappingURL=url.core.js.map
|