@push.rocks/smartproxy 29.1.0 → 30.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/changelog.md +22 -0
- package/dist_rust/rustproxy_linux_amd64_musl.tsrust-build.json +4 -4
- package/dist_rust/rustproxy_linux_arm64_musl.tsrust-build.json +4 -4
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +10 -2
- package/dist_ts/proxies/smart-proxy/utils/index.d.ts +1 -1
- package/dist_ts/proxies/smart-proxy/utils/index.js +2 -2
- package/dist_ts/proxies/smart-proxy/utils/ip-entry-validation.d.ts +14 -0
- package/dist_ts/proxies/smart-proxy/utils/ip-entry-validation.js +200 -0
- package/dist_ts/proxies/smart-proxy/utils/route-validation-error.d.ts +9 -0
- package/dist_ts/proxies/smart-proxy/utils/route-validation-error.js +19 -1
- package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +9 -12
- package/dist_ts/proxies/smart-proxy/utils/route-validator.js +84 -81
- package/package.json +2 -2
- package/readme.md +14 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/proxies/smart-proxy/smart-proxy.ts +10 -1
- package/ts/proxies/smart-proxy/utils/index.ts +1 -1
- package/ts/proxies/smart-proxy/utils/ip-entry-validation.ts +201 -0
- package/ts/proxies/smart-proxy/utils/route-validation-error.ts +20 -0
- package/ts/proxies/smart-proxy/utils/route-validator.ts +94 -83
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export * from './route-validator.js';
|
|
10
10
|
|
|
11
11
|
// Export the error SmartProxy throws when it refuses a route set
|
|
12
|
-
export { RouteValidationError } from './route-validation-error.js';
|
|
12
|
+
export { RouteValidationError, SettingsValidationError } from './route-validation-error.js';
|
|
13
13
|
export type { IRouteValidationErrorEntry } from './route-validation-error.js';
|
|
14
14
|
|
|
15
15
|
// Export route utilities for route operations
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import * as plugins from '../../../plugins.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validation of configured IP entries, one rule per Rust parser that reads them.
|
|
5
|
+
*
|
|
6
|
+
* Every check accepts exactly what its Rust reader can match and answers why it refuses anything
|
|
7
|
+
* else, so a configuration that validates never carries an entry the engine silently ignores:
|
|
8
|
+
*
|
|
9
|
+
* - Security lists (`security.ipAllowList`, `security.ipBlockList`, `defaults.security`,
|
|
10
|
+
* `securityPolicy.blockedIps`/`blockedCidrs`) are read by `rustproxy-security` `IpPattern::parse`:
|
|
11
|
+
* `*`, an address or an `ipnet` prefix, compared as parsed addresses with the client converted
|
|
12
|
+
* from IPv4-mapped IPv6 to IPv4. Anything else becomes `0.0.0.0` and never matches.
|
|
13
|
+
* - Source matches (`match.clientIp`, `trustedProxyIPs` of a route policy or the settings) are read
|
|
14
|
+
* by `rustproxy-routing` `matchers::ip_matches`: `*`, an `ipnet` prefix, a four-part IPv4 wildcard,
|
|
15
|
+
* or an exact address compared as text against the client's Rust `Display` form, after a leading
|
|
16
|
+
* `::ffff:` is stripped from both sides.
|
|
17
|
+
* - VPN assigned addresses (`security.vpn.allowedAssignedIps`) are compared as text against the
|
|
18
|
+
* address the VPN metadata carries.
|
|
19
|
+
*
|
|
20
|
+
* A check answers `null` for an entry it accepts, else the reason it refuses it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ipv4MappedPrefixLength = 96;
|
|
24
|
+
|
|
25
|
+
/** An address without a zone index, in any textual form `node:net` and Rust both read. */
|
|
26
|
+
const ipFamilyOf = (value: string): 0 | 4 | 6 => {
|
|
27
|
+
if (value.includes('%')) return 0;
|
|
28
|
+
return plugins.net.isIP(value) as 0 | 4 | 6;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The eight 16-bit segments of an IPv6 address `node:net` already accepted. */
|
|
32
|
+
const ipv6SegmentsOf = (address: string): number[] => {
|
|
33
|
+
const toSegments = (text: string): number[] => {
|
|
34
|
+
if (text === '') return [];
|
|
35
|
+
const segments: number[] = [];
|
|
36
|
+
for (const part of text.split(':')) {
|
|
37
|
+
if (part.includes('.')) {
|
|
38
|
+
const [a, b, c, d] = part.split('.').map(Number);
|
|
39
|
+
segments.push((a << 8) | b, (c << 8) | d);
|
|
40
|
+
} else {
|
|
41
|
+
segments.push(parseInt(part, 16));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return segments;
|
|
45
|
+
};
|
|
46
|
+
const compressionAt = address.indexOf('::');
|
|
47
|
+
if (compressionAt === -1) {
|
|
48
|
+
return toSegments(address);
|
|
49
|
+
}
|
|
50
|
+
const head = toSegments(address.slice(0, compressionAt));
|
|
51
|
+
const tail = toSegments(address.slice(compressionAt + 2));
|
|
52
|
+
return [...head, ...new Array<number>(8 - head.length - tail.length).fill(0), ...tail];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The IPv4 address an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) carries, else `null`. */
|
|
56
|
+
const ipv4MappedOf = (segments: number[]): string | null => {
|
|
57
|
+
if (segments.slice(0, 5).some((segment) => segment !== 0) || segments[5] !== 0xffff) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return [segments[6] >> 8, segments[6] & 0xff, segments[7] >> 8, segments[7] & 0xff].join('.');
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The address as Rust's `Display` writes it: IPv4 unchanged (`node:net` admits only plain decimal
|
|
65
|
+
* octets), IPv4-mapped IPv6 as `::ffff:a.b.c.d`, any other IPv6 in lowercase hex without leading
|
|
66
|
+
* zeros and with the first longest run of two or more zero segments written as `::`.
|
|
67
|
+
*/
|
|
68
|
+
export const formatIpAddress = (address: string): string => {
|
|
69
|
+
if (ipFamilyOf(address) !== 6) {
|
|
70
|
+
return address;
|
|
71
|
+
}
|
|
72
|
+
const segments = ipv6SegmentsOf(address);
|
|
73
|
+
const mapped = ipv4MappedOf(segments);
|
|
74
|
+
if (mapped !== null) {
|
|
75
|
+
return `::ffff:${mapped}`;
|
|
76
|
+
}
|
|
77
|
+
let longestStart = 0;
|
|
78
|
+
let longestLength = 0;
|
|
79
|
+
let currentStart = 0;
|
|
80
|
+
let currentLength = 0;
|
|
81
|
+
segments.forEach((segment, index) => {
|
|
82
|
+
if (segment === 0) {
|
|
83
|
+
if (currentLength === 0) currentStart = index;
|
|
84
|
+
currentLength += 1;
|
|
85
|
+
if (currentLength > longestLength) {
|
|
86
|
+
longestStart = currentStart;
|
|
87
|
+
longestLength = currentLength;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
currentLength = 0;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
const hex = (part: number[]) => part.map((segment) => segment.toString(16)).join(':');
|
|
94
|
+
if (longestLength > 1) {
|
|
95
|
+
return `${hex(segments.slice(0, longestStart))}::${hex(segments.slice(longestStart + longestLength))}`;
|
|
96
|
+
}
|
|
97
|
+
return hex(segments);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
interface IParsedPrefix {
|
|
101
|
+
address: string;
|
|
102
|
+
family: 4 | 6;
|
|
103
|
+
length: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* An address, `/` and a prefix length as `ipnet` parses it: up to two digits and at most 32 for
|
|
108
|
+
* IPv4, up to three digits and at most 128 for IPv6. Host bits may be set.
|
|
109
|
+
*/
|
|
110
|
+
const parsePrefix = (value: string): IParsedPrefix | null => {
|
|
111
|
+
const parts = value.split('/');
|
|
112
|
+
if (parts.length !== 2) return null;
|
|
113
|
+
const [address, lengthText] = parts;
|
|
114
|
+
const family = ipFamilyOf(address);
|
|
115
|
+
if (family === 4 && /^\d{1,2}$/.test(lengthText) && Number(lengthText) <= 32) {
|
|
116
|
+
return { address, family, length: Number(lengthText) };
|
|
117
|
+
}
|
|
118
|
+
if (family === 6 && /^\d{1,3}$/.test(lengthText) && Number(lengthText) <= 128) {
|
|
119
|
+
return { address, family, length: Number(lengthText) };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The IPv4 prefix an IPv4-mapped IPv6 prefix of at least 96 bits covers, else `null`. */
|
|
125
|
+
const ipv4PrefixOfMapped = (prefix: IParsedPrefix): string | null => {
|
|
126
|
+
if (prefix.family !== 6 || prefix.length < ipv4MappedPrefixLength) return null;
|
|
127
|
+
const mapped = ipv4MappedOf(ipv6SegmentsOf(prefix.address));
|
|
128
|
+
return mapped === null ? null : `${mapped}/${prefix.length - ipv4MappedPrefixLength}`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** A four-part IPv4 wildcard with plain decimal octets first and only `*` parts after them. */
|
|
132
|
+
const isIpv4TrailingWildcard = (value: string): boolean => {
|
|
133
|
+
const parts = value.split('.');
|
|
134
|
+
if (parts.length !== 4) return false;
|
|
135
|
+
const firstWildcard = parts.indexOf('*');
|
|
136
|
+
if (firstWildcard === -1) return false;
|
|
137
|
+
return parts.slice(0, firstWildcard).every((part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255)
|
|
138
|
+
&& parts.slice(firstWildcard).every((part) => part === '*');
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const zoneIndexReason = 'a zone index (%…) is not supported';
|
|
142
|
+
|
|
143
|
+
/** Checks an entry of a security list: `ipAllowList`, `ipBlockList` or a global block list. */
|
|
144
|
+
export const checkSecurityIpEntry = (entry: unknown): string | null => {
|
|
145
|
+
if (typeof entry !== 'string') return 'must be a string';
|
|
146
|
+
if (entry === '*') return null;
|
|
147
|
+
if (entry.includes('%')) return zoneIndexReason;
|
|
148
|
+
const family = ipFamilyOf(entry);
|
|
149
|
+
if (family !== 0) {
|
|
150
|
+
const mapped = family === 6 ? ipv4MappedOf(ipv6SegmentsOf(entry)) : null;
|
|
151
|
+
return mapped === null
|
|
152
|
+
? null
|
|
153
|
+
: `an IPv4-mapped address never matches, because clients are compared as IPv4; write ${mapped}`;
|
|
154
|
+
}
|
|
155
|
+
const prefix = parsePrefix(entry);
|
|
156
|
+
if (prefix) {
|
|
157
|
+
const ipv4Prefix = ipv4PrefixOfMapped(prefix);
|
|
158
|
+
return ipv4Prefix === null
|
|
159
|
+
? null
|
|
160
|
+
: `an IPv4-mapped prefix never matches, because clients are compared as IPv4; write ${ipv4Prefix}`;
|
|
161
|
+
}
|
|
162
|
+
return "must be '*', an IPv4 or IPv6 address, or an address prefix such as 192.0.2.0/24";
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Checks a source-match entry: `match.clientIp` or a `trustedProxyIPs` entry. */
|
|
166
|
+
export const checkSourceMatchIpEntry = (entry: unknown): string | null => {
|
|
167
|
+
if (typeof entry !== 'string') return 'must be a string';
|
|
168
|
+
if (entry === '*') return null;
|
|
169
|
+
if (entry.includes('%')) return zoneIndexReason;
|
|
170
|
+
if (ipFamilyOf(entry) !== 0) {
|
|
171
|
+
const canonical = formatIpAddress(entry);
|
|
172
|
+
return canonical === entry
|
|
173
|
+
? null
|
|
174
|
+
: `an exact address is compared as text with the client address; write ${canonical}`;
|
|
175
|
+
}
|
|
176
|
+
const prefix = parsePrefix(entry);
|
|
177
|
+
if (prefix) {
|
|
178
|
+
const ipv4Prefix = ipv4PrefixOfMapped(prefix);
|
|
179
|
+
return ipv4Prefix === null
|
|
180
|
+
? null
|
|
181
|
+
: `an IPv4-mapped prefix never matches, because clients are compared as IPv4; write ${ipv4Prefix}`;
|
|
182
|
+
}
|
|
183
|
+
if (isIpv4TrailingWildcard(entry)) return null;
|
|
184
|
+
return "must be '*', an IPv4 or IPv6 address, an address prefix such as 192.0.2.0/24, "
|
|
185
|
+
+ 'or a four-part IPv4 wildcard such as 192.0.2.*';
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Checks a `security.vpn.allowedAssignedIps` entry. */
|
|
189
|
+
export const checkAssignedIpEntry = (entry: unknown): string | null => {
|
|
190
|
+
if (typeof entry !== 'string') return 'must be a string';
|
|
191
|
+
if (entry.includes('%')) return zoneIndexReason;
|
|
192
|
+
if (ipFamilyOf(entry) === 0) return 'must be one IPv4 or IPv6 address';
|
|
193
|
+
const canonical = formatIpAddress(entry);
|
|
194
|
+
return canonical === entry
|
|
195
|
+
? null
|
|
196
|
+
: `an assigned address is compared as text with the VPN client's address; write ${canonical}`;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Formats one refused entry for a validation message. */
|
|
200
|
+
export const describeRefusedIpEntry = (labelArg: string, entryArg: unknown, reasonArg: string): string =>
|
|
201
|
+
`${labelArg}: ${String(entryArg)} (${reasonArg})`;
|
|
@@ -58,3 +58,23 @@ export class RouteValidationError extends Error {
|
|
|
58
58
|
this.errors = errorsArg;
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Thrown when SmartProxy refuses settings outside its routes because they failed validation.
|
|
64
|
+
*
|
|
65
|
+
* `messages` lists every reason. The message names the first reasons only.
|
|
66
|
+
*/
|
|
67
|
+
export class SettingsValidationError extends Error {
|
|
68
|
+
public readonly messages: string[];
|
|
69
|
+
|
|
70
|
+
constructor(summaryArg: string, messagesArg: string[]) {
|
|
71
|
+
const shownMessages = messagesArg.slice(0, MAX_MESSAGES_PER_ROUTE_IN_MESSAGE).map(truncateText);
|
|
72
|
+
const hiddenMessageCount = messagesArg.length - shownMessages.length;
|
|
73
|
+
if (hiddenMessageCount > 0) {
|
|
74
|
+
shownMessages.push(`and ${hiddenMessageCount} more`);
|
|
75
|
+
}
|
|
76
|
+
super(`${summaryArg}: ${shownMessages.join('; ')}`);
|
|
77
|
+
this.name = 'SettingsValidationError';
|
|
78
|
+
this.messages = messagesArg;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { logger } from '../../../core/utils/logger.js';
|
|
2
|
+
import {
|
|
3
|
+
checkAssignedIpEntry,
|
|
4
|
+
checkSecurityIpEntry,
|
|
5
|
+
checkSourceMatchIpEntry,
|
|
6
|
+
describeRefusedIpEntry,
|
|
7
|
+
} from './ip-entry-validation.js';
|
|
2
8
|
import type { IInboundProxyProtocolPolicy, IRouteConfig, IRouteMatch, IRouteAction, TPortRange, TTransportProtocol } from '../models/route-types.js';
|
|
9
|
+
import type { ISmartProxyOptions, ISmartProxySecurityPolicy } from '../models/interfaces.js';
|
|
3
10
|
import { RouteValidationError, type IRouteValidationErrorEntry } from './route-validation-error.js';
|
|
4
11
|
|
|
5
12
|
/**
|
|
@@ -88,8 +95,9 @@ export class RouteValidator {
|
|
|
88
95
|
const ips = Array.isArray(route.match.clientIp) ? route.match.clientIp : [route.match.clientIp];
|
|
89
96
|
|
|
90
97
|
for (const ip of ips) {
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
const reason = checkSourceMatchIpEntry(ip);
|
|
99
|
+
if (reason !== null) {
|
|
100
|
+
errors.push(describeRefusedIpEntry('Invalid IP pattern', ip, reason));
|
|
93
101
|
}
|
|
94
102
|
}
|
|
95
103
|
}
|
|
@@ -105,8 +113,9 @@ export class RouteValidator {
|
|
|
105
113
|
errors.push('inboundProxyProtocol.trustedProxyIPs must be a non-empty array when specified');
|
|
106
114
|
} else {
|
|
107
115
|
for (const ip of policy.trustedProxyIPs) {
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
const reason = checkSourceMatchIpEntry(ip);
|
|
117
|
+
if (reason !== null) {
|
|
118
|
+
errors.push(describeRefusedIpEntry('Invalid trusted proxy IP pattern', ip, reason));
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
}
|
|
@@ -226,17 +235,19 @@ export class RouteValidator {
|
|
|
226
235
|
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
|
|
227
236
|
|
|
228
237
|
for (const entry of allowList) {
|
|
229
|
-
if (typeof entry === '
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
} else if (entry && typeof entry === 'object') {
|
|
234
|
-
if (!this.isValidIPPattern(entry.ip)) {
|
|
235
|
-
errors.push(`Invalid IP pattern in domain-scoped allow entry: ${entry.ip}`);
|
|
238
|
+
if (entry && typeof entry === 'object') {
|
|
239
|
+
const reason = checkSecurityIpEntry(entry.ip);
|
|
240
|
+
if (reason !== null) {
|
|
241
|
+
errors.push(describeRefusedIpEntry('Invalid IP pattern in domain-scoped allow entry', entry.ip, reason));
|
|
236
242
|
}
|
|
237
243
|
if (!Array.isArray(entry.domains) || entry.domains.length === 0) {
|
|
238
244
|
errors.push(`Domain-scoped allow entry for ${entry.ip} must have non-empty domains array`);
|
|
239
245
|
}
|
|
246
|
+
} else {
|
|
247
|
+
const reason = checkSecurityIpEntry(entry);
|
|
248
|
+
if (reason !== null) {
|
|
249
|
+
errors.push(describeRefusedIpEntry('Invalid IP pattern in allow list', entry, reason));
|
|
250
|
+
}
|
|
240
251
|
}
|
|
241
252
|
}
|
|
242
253
|
}
|
|
@@ -245,8 +256,23 @@ export class RouteValidator {
|
|
|
245
256
|
const blockList = Array.isArray(route.security.ipBlockList) ? route.security.ipBlockList : [route.security.ipBlockList];
|
|
246
257
|
|
|
247
258
|
for (const ip of blockList) {
|
|
248
|
-
|
|
249
|
-
|
|
259
|
+
const reason = checkSecurityIpEntry(ip);
|
|
260
|
+
if (reason !== null) {
|
|
261
|
+
errors.push(describeRefusedIpEntry('Invalid IP pattern in block list', ip, reason));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const assignedIps = route.security.vpn?.allowedAssignedIps;
|
|
267
|
+
if (assignedIps !== undefined) {
|
|
268
|
+
if (!Array.isArray(assignedIps)) {
|
|
269
|
+
errors.push('security.vpn.allowedAssignedIps must be an array');
|
|
270
|
+
} else {
|
|
271
|
+
for (const ip of assignedIps) {
|
|
272
|
+
const reason = checkAssignedIpEntry(ip);
|
|
273
|
+
if (reason !== null) {
|
|
274
|
+
errors.push(describeRefusedIpEntry('Invalid VPN assigned IP', ip, reason));
|
|
275
|
+
}
|
|
250
276
|
}
|
|
251
277
|
}
|
|
252
278
|
}
|
|
@@ -336,6 +362,61 @@ export class RouteValidator {
|
|
|
336
362
|
return baseLabels.map((label, index) => (labelCounts.get(label)! > 1 ? `${label}[${index}]` : label));
|
|
337
363
|
}
|
|
338
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Validate the IP entries of the settings outside routes: the global `trustedProxyIPs`, the
|
|
367
|
+
* `defaults.security` lists the engine copies into routes without security, and the global
|
|
368
|
+
* `securityPolicy`. Answers every reason, empty when the settings are valid.
|
|
369
|
+
*/
|
|
370
|
+
public static validateSettings(
|
|
371
|
+
settings: Pick<ISmartProxyOptions, 'trustedProxyIPs' | 'defaults' | 'securityPolicy'>,
|
|
372
|
+
): string[] {
|
|
373
|
+
const errors: string[] = [];
|
|
374
|
+
const checkList = (
|
|
375
|
+
list: unknown,
|
|
376
|
+
field: string,
|
|
377
|
+
check: (entry: unknown) => string | null,
|
|
378
|
+
) => {
|
|
379
|
+
if (list === undefined) return;
|
|
380
|
+
if (!Array.isArray(list)) {
|
|
381
|
+
errors.push(`${field} must be an array`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
for (const entry of list) {
|
|
385
|
+
const reason = check(entry);
|
|
386
|
+
if (reason !== null) {
|
|
387
|
+
errors.push(describeRefusedIpEntry(`Invalid IP pattern in ${field}`, entry, reason));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
checkList(settings.trustedProxyIPs, 'trustedProxyIPs', checkSourceMatchIpEntry);
|
|
392
|
+
checkList(settings.defaults?.security?.ipAllowList, 'defaults.security.ipAllowList', checkSecurityIpEntry);
|
|
393
|
+
checkList(settings.defaults?.security?.ipBlockList, 'defaults.security.ipBlockList', checkSecurityIpEntry);
|
|
394
|
+
if (settings.securityPolicy) {
|
|
395
|
+
errors.push(...this.validateSecurityPolicy(settings.securityPolicy));
|
|
396
|
+
}
|
|
397
|
+
return errors;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Validate the IP entries of a global ingress security policy. */
|
|
401
|
+
public static validateSecurityPolicy(policy: ISmartProxySecurityPolicy): string[] {
|
|
402
|
+
const errors: string[] = [];
|
|
403
|
+
for (const field of ['blockedIps', 'blockedCidrs'] as const) {
|
|
404
|
+
const list: unknown = policy[field];
|
|
405
|
+
if (list === undefined) continue;
|
|
406
|
+
if (!Array.isArray(list)) {
|
|
407
|
+
errors.push(`securityPolicy.${field} must be an array`);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
for (const entry of list) {
|
|
411
|
+
const reason = checkSecurityIpEntry(entry);
|
|
412
|
+
if (reason !== null) {
|
|
413
|
+
errors.push(describeRefusedIpEntry(`Invalid IP pattern in securityPolicy.${field}`, entry, reason));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return errors;
|
|
418
|
+
}
|
|
419
|
+
|
|
339
420
|
/**
|
|
340
421
|
* Validate multiple route configurations.
|
|
341
422
|
*
|
|
@@ -567,76 +648,6 @@ export class RouteValidator {
|
|
|
567
648
|
return true;
|
|
568
649
|
}
|
|
569
650
|
|
|
570
|
-
/**
|
|
571
|
-
* Validate IP pattern
|
|
572
|
-
*/
|
|
573
|
-
private static isValidIPPattern(ip: string): boolean {
|
|
574
|
-
if (!ip || typeof ip !== 'string') return false;
|
|
575
|
-
if (ip === '*') return true;
|
|
576
|
-
|
|
577
|
-
// Check for CIDR notation
|
|
578
|
-
if (ip.includes('/')) {
|
|
579
|
-
const [addr, prefix] = ip.split('/');
|
|
580
|
-
const prefixNum = parseInt(prefix, 10);
|
|
581
|
-
|
|
582
|
-
if (addr.includes(':')) {
|
|
583
|
-
// IPv6 CIDR
|
|
584
|
-
return this.isValidIPv6(addr) && prefixNum >= 0 && prefixNum <= 128;
|
|
585
|
-
} else {
|
|
586
|
-
// IPv4 CIDR
|
|
587
|
-
return this.isValidIPv4(addr) && prefixNum >= 0 && prefixNum <= 32;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Check for range
|
|
592
|
-
if (ip.includes('-')) {
|
|
593
|
-
const [start, end] = ip.split('-');
|
|
594
|
-
return (this.isValidIPv4(start) && this.isValidIPv4(end)) ||
|
|
595
|
-
(this.isValidIPv6(start) && this.isValidIPv6(end));
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Check for wildcards in IPv4
|
|
599
|
-
if (ip.includes('*') && !ip.includes(':')) {
|
|
600
|
-
const parts = ip.split('.');
|
|
601
|
-
// Allow 1-4 parts for wildcard patterns (e.g., '10.*', '192.168.*', '192.168.1.*')
|
|
602
|
-
if (parts.length < 1 || parts.length > 4) return false;
|
|
603
|
-
|
|
604
|
-
for (const part of parts) {
|
|
605
|
-
if (part !== '*' && !/^\d{1,3}$/.test(part)) return false;
|
|
606
|
-
if (part !== '*' && parseInt(part, 10) > 255) return false;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
return true;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// Regular IP address
|
|
613
|
-
return this.isValidIPv4(ip) || this.isValidIPv6(ip);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Validate IPv4 address
|
|
618
|
-
*/
|
|
619
|
-
private static isValidIPv4(ip: string): boolean {
|
|
620
|
-
const parts = ip.split('.');
|
|
621
|
-
if (parts.length !== 4) return false;
|
|
622
|
-
|
|
623
|
-
for (const part of parts) {
|
|
624
|
-
const num = parseInt(part, 10);
|
|
625
|
-
if (isNaN(num) || num < 0 || num > 255) return false;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
return true;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Validate IPv6 address
|
|
633
|
-
*/
|
|
634
|
-
private static isValidIPv6(ip: string): boolean {
|
|
635
|
-
// IPv6 validation including IPv6-mapped IPv4 addresses (::ffff:x.x.x.x)
|
|
636
|
-
const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){0,6}|::1|::|::ffff:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i;
|
|
637
|
-
return ipv6Pattern.test(ip);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
651
|
/**
|
|
641
652
|
* Log validation errors
|
|
642
653
|
*/
|