nstarter-http-request 0.1.7 → 0.1.9
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 +8 -0
- package/dist/cjs/adapter/undici.adapter.js +23 -1
- package/dist/cjs/adapter/undici.adapter.js.map +1 -1
- package/dist/cjs/client.js +97 -14
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/config.js +15 -4
- package/dist/cjs/config.js.map +1 -1
- package/dist/cjs/security/ssrf.guard.js +13 -5
- package/dist/cjs/security/ssrf.guard.js.map +1 -1
- package/dist/esm/adapter/undici.adapter.js +23 -1
- package/dist/esm/adapter/undici.adapter.js.map +1 -1
- package/dist/esm/client.js +97 -14
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/config.js +15 -4
- package/dist/esm/config.js.map +1 -1
- package/dist/esm/security/ssrf.guard.js +13 -5
- package/dist/esm/security/ssrf.guard.js.map +1 -1
- package/dist/types/adapter/undici.adapter.d.ts +5 -0
- package/dist/types/adapter/undici.adapter.d.ts.map +1 -1
- package/dist/types/client.d.ts +16 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/security/ssrf.guard.d.ts +5 -3
- package/dist/types/security/ssrf.guard.d.ts.map +1 -1
- package/dist/types/types/security.d.ts +17 -2
- package/dist/types/types/security.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/undici.adapter.ts +26 -1
- package/src/client.ts +126 -15
- package/src/config.ts +20 -5
- package/src/security/ssrf.guard.ts +17 -6
- package/src/types/security.ts +21 -3
package/src/client.ts
CHANGED
|
@@ -41,6 +41,20 @@ import {
|
|
|
41
41
|
} from 'node:stream';
|
|
42
42
|
import { MetricsTransform } from './stream';
|
|
43
43
|
|
|
44
|
+
type ForwardProxyDecision = {
|
|
45
|
+
shouldProxy: false,
|
|
46
|
+
reason: 'disabled' | 'invalid_url' | 'unsupported_protocol' | 'no_proxy' | 'missing_proxy_url',
|
|
47
|
+
hostname?: string,
|
|
48
|
+
protocol?: string,
|
|
49
|
+
pattern?: string
|
|
50
|
+
} | {
|
|
51
|
+
shouldProxy: true,
|
|
52
|
+
hostname: string,
|
|
53
|
+
protocol: string,
|
|
54
|
+
isHttps: boolean,
|
|
55
|
+
proxyUrl: string
|
|
56
|
+
};
|
|
57
|
+
|
|
44
58
|
/**
|
|
45
59
|
* HTTP 客户端
|
|
46
60
|
*/
|
|
@@ -175,27 +189,44 @@ export class NsHttpClient implements IHttpClient {
|
|
|
175
189
|
const startTime = Date.now();
|
|
176
190
|
|
|
177
191
|
try {
|
|
192
|
+
const forwardProxyDecision = this.resolveForwardProxyDecision(options.url);
|
|
193
|
+
|
|
178
194
|
// 安全验证
|
|
179
|
-
const { safeUrl, result: ssrfResult } = await this.ssrfGuard.validateAndGetSafeUrl(
|
|
195
|
+
const { safeUrl, result: ssrfResult } = await this.ssrfGuard.validateAndGetSafeUrl(
|
|
196
|
+
options.url,
|
|
197
|
+
{
|
|
198
|
+
skipDnsValidation: forwardProxyDecision.shouldProxy,
|
|
199
|
+
skipDnsValidationReason: forwardProxyDecision.shouldProxy ? 'forward_proxy' : undefined
|
|
200
|
+
}
|
|
201
|
+
);
|
|
180
202
|
|
|
181
203
|
this.logger.debug('URL validation passed', {
|
|
182
204
|
originalUrl: options.url,
|
|
183
205
|
safeUrl,
|
|
184
206
|
resolvedIps: ssrfResult.resolvedIps,
|
|
207
|
+
dnsValidationSkipped: ssrfResult.dnsValidationSkipped,
|
|
208
|
+
dnsValidationSkipReason: ssrfResult.dnsValidationSkipReason,
|
|
185
209
|
scenario: options.scenario
|
|
186
210
|
});
|
|
187
211
|
|
|
212
|
+
if (ssrfResult.dnsValidationSkipped && forwardProxyDecision.shouldProxy) {
|
|
213
|
+
this.logger.debug('Skipped target DNS validation because request uses forward proxy', {
|
|
214
|
+
hostname: forwardProxyDecision.hostname,
|
|
215
|
+
proxyUrl: forwardProxyDecision.proxyUrl
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
188
219
|
// 使用安全的 URL 发起请求
|
|
189
220
|
const safeOptions: HttpRequestOptions = {
|
|
190
221
|
...options,
|
|
191
222
|
url: safeUrl
|
|
192
223
|
};
|
|
193
224
|
|
|
194
|
-
// 如果 URL 被替换为 IP,需要设置 Host
|
|
195
|
-
if (ssrfResult.safeIp && ssrfResult.hostname !== ssrfResult.safeIp) {
|
|
225
|
+
// 如果 URL 被替换为 IP,需要设置 Host 头;调用方显式传入 Host 时优先尊重业务覆盖值。
|
|
226
|
+
if (ssrfResult.safeIp && ssrfResult.hostname !== ssrfResult.safeIp && !this.hasRequestHeader(safeOptions.headers, 'host')) {
|
|
196
227
|
safeOptions.headers = {
|
|
197
228
|
...safeOptions.headers,
|
|
198
|
-
|
|
229
|
+
host: ssrfResult.hostname
|
|
199
230
|
};
|
|
200
231
|
}
|
|
201
232
|
|
|
@@ -221,7 +252,7 @@ export class NsHttpClient implements IHttpClient {
|
|
|
221
252
|
|
|
222
253
|
// 应用正向代理配置
|
|
223
254
|
if (this.forwardProxy) {
|
|
224
|
-
this.applyForwardProxy(safeOptions);
|
|
255
|
+
this.applyForwardProxy(safeOptions, forwardProxyDecision);
|
|
225
256
|
}
|
|
226
257
|
|
|
227
258
|
// 发起请求
|
|
@@ -246,6 +277,17 @@ export class NsHttpClient implements IHttpClient {
|
|
|
246
277
|
}
|
|
247
278
|
}
|
|
248
279
|
|
|
280
|
+
/**
|
|
281
|
+
* 按 HTTP 头大小写不敏感规则判断请求头是否已存在。
|
|
282
|
+
*/
|
|
283
|
+
private hasRequestHeader(headers: HttpRequestOptions['headers'], headerName: string): boolean {
|
|
284
|
+
if (!headers) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
const lowerHeaderName = headerName.toLowerCase();
|
|
288
|
+
return Object.keys(headers).some(key => key.toLowerCase() === lowerHeaderName);
|
|
289
|
+
}
|
|
290
|
+
|
|
249
291
|
/**
|
|
250
292
|
* 便捷方法:GET 请求
|
|
251
293
|
*/
|
|
@@ -472,16 +514,40 @@ export class NsHttpClient implements IHttpClient {
|
|
|
472
514
|
/**
|
|
473
515
|
* 应用正向代理配置
|
|
474
516
|
* @param options 请求选项
|
|
517
|
+
* @param decision 已解析的正向代理路由决策
|
|
475
518
|
*/
|
|
476
|
-
private applyForwardProxy(
|
|
519
|
+
private applyForwardProxy(
|
|
520
|
+
options: HttpRequestOptions,
|
|
521
|
+
decision = this.resolveForwardProxyDecision(options.url)
|
|
522
|
+
): void {
|
|
523
|
+
this.applyForwardProxyDecision(options, decision);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* 解析正向代理路由决策
|
|
528
|
+
* @param url 请求 URL
|
|
529
|
+
*/
|
|
530
|
+
private resolveForwardProxyDecision(url: string): ForwardProxyDecision {
|
|
477
531
|
if (!this.forwardProxy || !this.forwardProxy.enabled) {
|
|
478
|
-
return
|
|
532
|
+
return {
|
|
533
|
+
shouldProxy: false,
|
|
534
|
+
reason: 'disabled'
|
|
535
|
+
};
|
|
479
536
|
}
|
|
480
537
|
|
|
481
538
|
const { https_proxy, http_proxy, no_proxy } = this.forwardProxy;
|
|
482
539
|
|
|
483
540
|
// 解析目标 URL
|
|
484
|
-
|
|
541
|
+
let targetUrl: URL;
|
|
542
|
+
try {
|
|
543
|
+
targetUrl = new URL(url);
|
|
544
|
+
} catch {
|
|
545
|
+
return {
|
|
546
|
+
shouldProxy: false,
|
|
547
|
+
reason: 'invalid_url'
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
485
551
|
const hostname = targetUrl.hostname;
|
|
486
552
|
|
|
487
553
|
// 检查 no_proxy 规则(使用传统匹配模式)
|
|
@@ -489,26 +555,72 @@ export class NsHttpClient implements IHttpClient {
|
|
|
489
555
|
if (noProxyRegExps.length > 0 && hostname) {
|
|
490
556
|
for (const noProxyRegex of noProxyRegExps) {
|
|
491
557
|
if (noProxyRegex.test(hostname)) {
|
|
492
|
-
|
|
558
|
+
return {
|
|
559
|
+
shouldProxy: false,
|
|
560
|
+
reason: 'no_proxy',
|
|
493
561
|
hostname,
|
|
494
562
|
pattern: noProxyRegex.source
|
|
495
|
-
}
|
|
496
|
-
return;
|
|
563
|
+
};
|
|
497
564
|
}
|
|
498
565
|
}
|
|
499
566
|
}
|
|
500
567
|
|
|
501
568
|
const protocol = targetUrl.protocol || 'http:';
|
|
502
|
-
|
|
569
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
570
|
+
return {
|
|
571
|
+
shouldProxy: false,
|
|
572
|
+
reason: 'unsupported_protocol',
|
|
573
|
+
hostname,
|
|
574
|
+
protocol
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const isHttps = protocol === 'https:';
|
|
503
579
|
const proxyUrl = isHttps ? https_proxy : http_proxy;
|
|
504
580
|
|
|
505
581
|
if (!proxyUrl) {
|
|
506
|
-
|
|
582
|
+
return {
|
|
583
|
+
shouldProxy: false,
|
|
584
|
+
reason: 'missing_proxy_url',
|
|
585
|
+
hostname,
|
|
507
586
|
protocol
|
|
508
|
-
}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
shouldProxy: true,
|
|
592
|
+
hostname,
|
|
593
|
+
protocol,
|
|
594
|
+
isHttps,
|
|
595
|
+
proxyUrl
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* 根据已解析的路由决策应用正向代理配置
|
|
601
|
+
* @param options 请求选项
|
|
602
|
+
* @param decision 正向代理路由决策
|
|
603
|
+
*/
|
|
604
|
+
private applyForwardProxyDecision(options: HttpRequestOptions, decision: ForwardProxyDecision): void {
|
|
605
|
+
if (!decision.shouldProxy) {
|
|
606
|
+
if (decision.reason === 'no_proxy') {
|
|
607
|
+
this.logger.debug('Skip proxy for no_proxy matched host', {
|
|
608
|
+
hostname: decision.hostname,
|
|
609
|
+
pattern: decision.pattern
|
|
610
|
+
});
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (decision.reason === 'missing_proxy_url') {
|
|
615
|
+
this.logger.warn('Forward proxy enabled but proxy URL not configured', {
|
|
616
|
+
protocol: decision.protocol
|
|
617
|
+
});
|
|
618
|
+
}
|
|
509
619
|
return;
|
|
510
620
|
}
|
|
511
621
|
|
|
622
|
+
const { proxyUrl, isHttps, protocol } = decision;
|
|
623
|
+
|
|
512
624
|
// 将代理信息附加到请求元数据中,由适配器处理
|
|
513
625
|
options.meta = {
|
|
514
626
|
...options.meta,
|
|
@@ -543,4 +655,3 @@ export class NsHttpClient implements IHttpClient {
|
|
|
543
655
|
return this.ssrfGuard.getDnsCacheSize();
|
|
544
656
|
}
|
|
545
657
|
}
|
|
546
|
-
|
package/src/config.ts
CHANGED
|
@@ -10,25 +10,41 @@ import {
|
|
|
10
10
|
MAX_DNS_CACHE_TTL
|
|
11
11
|
} from './const';
|
|
12
12
|
|
|
13
|
+
function omitNil<T extends object>(config?: Partial<T> | null): Partial<T> {
|
|
14
|
+
if (!config) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
Object.entries(config).filter(([, value]) => value !== undefined && value !== null)
|
|
20
|
+
) as Partial<T>;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
24
|
* 合并配置
|
|
15
25
|
* @param userConfig 用户配置
|
|
16
26
|
* @returns 完整配置
|
|
17
27
|
*/
|
|
18
28
|
export function mergeConfig(userConfig: PartialHttpClientConfig = {}): HttpClientConfig {
|
|
29
|
+
// 调用方常用 options?.xxx 或空值占位组装 Partial 配置,null/undefined 不能覆盖库内默认值。
|
|
30
|
+
const normalizedUserConfig = userConfig ?? {};
|
|
31
|
+
const securityConfig = omitNil(normalizedUserConfig.security);
|
|
32
|
+
const loggingConfig = omitNil(normalizedUserConfig.logging);
|
|
33
|
+
const requestConfig = omitNil(normalizedUserConfig.request);
|
|
34
|
+
|
|
19
35
|
return {
|
|
20
|
-
adapter:
|
|
36
|
+
adapter: normalizedUserConfig.adapter ?? DEFAULT_HTTP_CLIENT_CONFIG.adapter,
|
|
21
37
|
security: {
|
|
22
38
|
...DEFAULT_SECURITY_CONFIG,
|
|
23
|
-
...
|
|
39
|
+
...securityConfig
|
|
24
40
|
},
|
|
25
41
|
logging: {
|
|
26
42
|
...DEFAULT_LOGGING_CONFIG,
|
|
27
|
-
...
|
|
43
|
+
...loggingConfig
|
|
28
44
|
},
|
|
29
45
|
request: {
|
|
30
46
|
...DEFAULT_REQUEST_CONFIG,
|
|
31
|
-
...
|
|
47
|
+
...requestConfig
|
|
32
48
|
}
|
|
33
49
|
};
|
|
34
50
|
}
|
|
@@ -83,4 +99,3 @@ export function validateConfig(config: HttpClientConfig): void {
|
|
|
83
99
|
throw new Error('Body max length must be positive.');
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
|
-
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ISsrfGuard,
|
|
10
10
|
SecurityConfig,
|
|
11
11
|
SsrfGuardResult,
|
|
12
|
+
SsrfGuardValidateOptions
|
|
12
13
|
} from '../types';
|
|
13
14
|
import { SecurityError } from '../types';
|
|
14
15
|
import { DnsValidator } from './dns.validator';
|
|
@@ -36,9 +37,10 @@ export class SsrfGuard implements ISsrfGuard {
|
|
|
36
37
|
/**
|
|
37
38
|
* 验证 URL 是否安全
|
|
38
39
|
* @param url 要验证的 URL
|
|
40
|
+
* @param options 验证选项
|
|
39
41
|
* @returns 验证结果
|
|
40
42
|
*/
|
|
41
|
-
public async validate(url: string): Promise<SsrfGuardResult> {
|
|
43
|
+
public async validate(url: string, options: SsrfGuardValidateOptions = {}): Promise<SsrfGuardResult> {
|
|
42
44
|
let parsedUrl: URL;
|
|
43
45
|
|
|
44
46
|
try {
|
|
@@ -100,11 +102,17 @@ export class SsrfGuard implements ISsrfGuard {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
// 第三步:DNS 解析和验证
|
|
103
|
-
if (!this.config.enableDnsValidation) {
|
|
105
|
+
if (!this.config.enableDnsValidation || options.skipDnsValidation) {
|
|
106
|
+
// 走正向代理时,目标域名由代理侧解析,本机 DNS 结果不代表实际连接目标。
|
|
107
|
+
const dnsValidationSkipped = Boolean(this.config.enableDnsValidation && options.skipDnsValidation);
|
|
104
108
|
return {
|
|
105
109
|
passed: true,
|
|
106
110
|
originalUrl: url,
|
|
107
|
-
hostname
|
|
111
|
+
hostname,
|
|
112
|
+
...(dnsValidationSkipped && {
|
|
113
|
+
dnsValidationSkipped,
|
|
114
|
+
dnsValidationSkipReason: options.skipDnsValidationReason
|
|
115
|
+
})
|
|
108
116
|
};
|
|
109
117
|
}
|
|
110
118
|
|
|
@@ -147,11 +155,15 @@ export class SsrfGuard implements ISsrfGuard {
|
|
|
147
155
|
/**
|
|
148
156
|
* 验证并返回安全的 URL
|
|
149
157
|
* @param url 要验证的 URL
|
|
158
|
+
* @param options 验证选项
|
|
150
159
|
* @returns 安全的 URL 和验证结果
|
|
151
160
|
* @throws {SecurityError} 验证失败时抛出错误
|
|
152
161
|
*/
|
|
153
|
-
public async validateAndGetSafeUrl(
|
|
154
|
-
|
|
162
|
+
public async validateAndGetSafeUrl(
|
|
163
|
+
url: string,
|
|
164
|
+
options: SsrfGuardValidateOptions = {}
|
|
165
|
+
): Promise<{ safeUrl: string, result: SsrfGuardResult }> {
|
|
166
|
+
const result = await this.validate(url, options);
|
|
155
167
|
|
|
156
168
|
if (!result.passed) {
|
|
157
169
|
throw new SecurityError(
|
|
@@ -221,4 +233,3 @@ export class SsrfGuard implements ISsrfGuard {
|
|
|
221
233
|
return { ...this.config };
|
|
222
234
|
}
|
|
223
235
|
}
|
|
224
|
-
|
package/src/types/security.ts
CHANGED
|
@@ -71,10 +71,24 @@ export interface SsrfGuardResult {
|
|
|
71
71
|
resolvedIps?: string[];
|
|
72
72
|
/** 安全的 IP 地址(如果进行了 DNS 解析且通过验证) */
|
|
73
73
|
safeIp?: string;
|
|
74
|
+
/** 是否因外部路由决策跳过目标域名 DNS 校验 */
|
|
75
|
+
dnsValidationSkipped?: boolean;
|
|
76
|
+
/** 跳过目标域名 DNS 校验的原因 */
|
|
77
|
+
dnsValidationSkipReason?: string;
|
|
74
78
|
/** 失败原因 */
|
|
75
79
|
reason?: string;
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
/**
|
|
83
|
+
* SSRF 防护验证选项
|
|
84
|
+
*/
|
|
85
|
+
export interface SsrfGuardValidateOptions {
|
|
86
|
+
/** 跳过目标域名 DNS 校验,通常用于目标域名由正向代理解析的场景 */
|
|
87
|
+
skipDnsValidation?: boolean;
|
|
88
|
+
/** 跳过目标域名 DNS 校验的原因 */
|
|
89
|
+
skipDnsValidationReason?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
/**
|
|
79
93
|
* SSRF 防护接口
|
|
80
94
|
*/
|
|
@@ -82,17 +96,22 @@ export interface ISsrfGuard {
|
|
|
82
96
|
/**
|
|
83
97
|
* 验证 URL 是否安全
|
|
84
98
|
* @param url 要验证的 URL
|
|
99
|
+
* @param options 验证选项
|
|
85
100
|
* @returns 验证结果
|
|
86
101
|
*/
|
|
87
|
-
validate: (url: string) => Promise<SsrfGuardResult>;
|
|
102
|
+
validate: (url: string, options?: SsrfGuardValidateOptions) => Promise<SsrfGuardResult>;
|
|
88
103
|
|
|
89
104
|
/**
|
|
90
105
|
* 验证并返回安全的 URL
|
|
91
106
|
* @param url 要验证的 URL
|
|
107
|
+
* @param options 验证选项
|
|
92
108
|
* @returns 安全的 URL 和验证结果
|
|
93
109
|
* @throws {SecurityError} 验证失败时抛出错误
|
|
94
110
|
*/
|
|
95
|
-
validateAndGetSafeUrl: (
|
|
111
|
+
validateAndGetSafeUrl: (
|
|
112
|
+
url: string,
|
|
113
|
+
options?: SsrfGuardValidateOptions
|
|
114
|
+
) => Promise<{ safeUrl: string, result: SsrfGuardResult }>;
|
|
96
115
|
|
|
97
116
|
/**
|
|
98
117
|
* 更新配置
|
|
@@ -123,4 +142,3 @@ export interface ISsrfGuard {
|
|
|
123
142
|
getConfig: () => SecurityConfig;
|
|
124
143
|
}
|
|
125
144
|
|
|
126
|
-
|