@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.
Files changed (81) hide show
  1. package/README.md +254 -11
  2. package/dist/body/body.core.d.ts +50 -2
  3. package/dist/body/body.core.js +150 -19
  4. package/dist/body/index.d.ts +1 -1
  5. package/dist/body/index.js +1 -1
  6. package/dist/cookie/cookie.core.d.ts +12 -0
  7. package/dist/cookie/cookie.core.js +98 -8
  8. package/dist/cors/cors.core.d.ts +8 -1
  9. package/dist/cors/cors.core.js +66 -9
  10. package/dist/csrf/csrf.core.d.ts +150 -9
  11. package/dist/csrf/csrf.core.js +199 -41
  12. package/dist/csrf/index.d.ts +2 -1
  13. package/dist/csrf/index.js +1 -1
  14. package/dist/header/header.core.js +18 -5
  15. package/dist/headers/headers.core.js +36 -4
  16. package/dist/index.d.ts +14 -9
  17. package/dist/index.js +10 -8
  18. package/dist/input/index.d.ts +1 -1
  19. package/dist/input/index.js +1 -1
  20. package/dist/input/input.core.d.ts +27 -0
  21. package/dist/input/input.core.js +125 -37
  22. package/dist/rateLimit/index.d.ts +2 -1
  23. package/dist/rateLimit/index.js +1 -1
  24. package/dist/rateLimit/rateLimit.core.d.ts +56 -11
  25. package/dist/rateLimit/rateLimit.core.js +160 -47
  26. package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
  27. package/dist/rateLimit/rateLimit.namespace.js +2 -1
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/security.type.d.ts +36 -19
  30. package/dist/types/security.type.js +26 -11
  31. package/dist/url/index.d.ts +2 -1
  32. package/dist/url/index.js +1 -1
  33. package/dist/url/url.core.d.ts +58 -2
  34. package/dist/url/url.core.js +270 -43
  35. package/package.json +14 -7
  36. package/dist/body/body.core.d.ts.map +0 -1
  37. package/dist/body/body.core.js.map +0 -1
  38. package/dist/body/index.d.ts.map +0 -1
  39. package/dist/body/index.js.map +0 -1
  40. package/dist/cookie/cookie.core.d.ts.map +0 -1
  41. package/dist/cookie/cookie.core.js.map +0 -1
  42. package/dist/cookie/index.d.ts.map +0 -1
  43. package/dist/cookie/index.js.map +0 -1
  44. package/dist/cors/cors.core.d.ts.map +0 -1
  45. package/dist/cors/cors.core.js.map +0 -1
  46. package/dist/cors/cors.namespace.d.ts.map +0 -1
  47. package/dist/cors/cors.namespace.js.map +0 -1
  48. package/dist/cors/index.d.ts.map +0 -1
  49. package/dist/cors/index.js.map +0 -1
  50. package/dist/csrf/csrf.core.d.ts.map +0 -1
  51. package/dist/csrf/csrf.core.js.map +0 -1
  52. package/dist/csrf/index.d.ts.map +0 -1
  53. package/dist/csrf/index.js.map +0 -1
  54. package/dist/header/header.core.d.ts.map +0 -1
  55. package/dist/header/header.core.js.map +0 -1
  56. package/dist/header/index.d.ts.map +0 -1
  57. package/dist/header/index.js.map +0 -1
  58. package/dist/headers/headers.core.d.ts.map +0 -1
  59. package/dist/headers/headers.core.js.map +0 -1
  60. package/dist/headers/index.d.ts.map +0 -1
  61. package/dist/headers/index.js.map +0 -1
  62. package/dist/index.d.ts.map +0 -1
  63. package/dist/index.js.map +0 -1
  64. package/dist/input/index.d.ts.map +0 -1
  65. package/dist/input/index.js.map +0 -1
  66. package/dist/input/input.core.d.ts.map +0 -1
  67. package/dist/input/input.core.js.map +0 -1
  68. package/dist/rateLimit/index.d.ts.map +0 -1
  69. package/dist/rateLimit/index.js.map +0 -1
  70. package/dist/rateLimit/rateLimit.core.d.ts.map +0 -1
  71. package/dist/rateLimit/rateLimit.core.js.map +0 -1
  72. package/dist/rateLimit/rateLimit.namespace.d.ts.map +0 -1
  73. package/dist/rateLimit/rateLimit.namespace.js.map +0 -1
  74. package/dist/types/index.d.ts.map +0 -1
  75. package/dist/types/index.js.map +0 -1
  76. package/dist/types/security.type.d.ts.map +0 -1
  77. package/dist/types/security.type.js.map +0 -1
  78. package/dist/url/index.d.ts.map +0 -1
  79. package/dist/url/index.js.map +0 -1
  80. package/dist/url/url.core.d.ts.map +0 -1
  81. package/dist/url/url.core.js.map +0 -1
@@ -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, RequestValidationConfig, InputSanitizationConfig, } from "./security.type.js";
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
- /** Custom handler when rate limit is exceeded. */
120
- readonly handler?: (request: RateLimitRequest, response: RateLimitResponse) => void;
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
- /** Common SQL injection patterns. */
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
- /** Common XSS patterns. */
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
- /** Common SQL injection patterns. */
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
- /** Common XSS patterns. */
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[^>]*>/gi,
22
- /<script\b[^>]*>/gi,
23
- /<\/script[^>]*>/gi,
24
- /javascript:/gi,
25
- /on\w+\s*=/gi,
26
- /data:text\/html/gi,
27
- /<iframe\b[^>]*>/gi,
28
- /<object\b[^>]*>/gi,
29
- /<embed\b[^>]*>/gi,
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
@@ -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
@@ -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?: UrlValidationConfig): UrlValidationResult;
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
@@ -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
- * Path traversal patterns that indicate directory traversal attacks.
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 patterns.
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
- const INVALID_PERCENT_ENCODING = /%[^0-9a-fA-F]|%(?:[0-9a-fA-F][^0-9a-fA-F])/;
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 (check raw URL, not normalized pathname)
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
- for (const pattern of TRAVERSAL_PATTERNS) {
77
- if (pattern.test(rawPath)) {
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
- for (const pattern of TRAVERSAL_PATTERNS) {
132
- if (pattern.test(target)) {
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 (except tab)
139
- if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(target)) {
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
- const parsed = new URL(url);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/security",
3
- "version": "0.0.1",
3
+ "version": "1.0.0",
4
4
  "description": "Security primitives for input validation, header security, CORS, CSRF protection, rate limiting, and security headers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,11 +14,14 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "!dist/**/*.map",
19
+ "!dist/**/*.tsbuildinfo",
20
+ "!dist/.tsbuildinfo"
18
21
  ],
19
22
  "dependencies": {
20
- "@zudojs/errors": "0.0.1",
21
- "@zudojs/constants": "0.0.1"
23
+ "@zudojs/errors": "1.0.0",
24
+ "@zudojs/constants": "1.0.0"
22
25
  },
23
26
  "devDependencies": {
24
27
  "typescript": "7.0.2",
@@ -39,14 +42,18 @@
39
42
  "rate-limiting"
40
43
  ],
41
44
  "homepage": "https://github.com/oyinlola-tech/zudo#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/oyinlola-tech/zudo/issues"
47
+ },
42
48
  "repository": {
43
49
  "type": "git",
44
- "url": "https://github.com/oyinlola-tech/zudo"
50
+ "url": "https://github.com/oyinlola-tech/zudo",
51
+ "directory": "packages/security"
45
52
  },
46
53
  "scripts": {
47
54
  "build": "tsc -p tsconfig.json",
48
- "clean": "rm -rf dist",
49
- "typecheck": "tsc -p tsconfig.json --noEmit",
55
+ "clean": "rm -rf dist *.tsbuildinfo",
56
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
50
57
  "test": "vitest run",
51
58
  "test:watch": "vitest"
52
59
  }