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/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(options.url);
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
- Host: ssrfResult.hostname
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(options: HttpRequestOptions): void {
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
- const targetUrl = new URL(options.url);
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
- this.logger.debug('Skip proxy for no_proxy matched host', {
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
- const isHttps = /https:?/.test(protocol);
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
- this.logger.warn('Forward proxy enabled but proxy URL not configured', {
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: userConfig.adapter ?? DEFAULT_HTTP_CLIENT_CONFIG.adapter,
36
+ adapter: normalizedUserConfig.adapter ?? DEFAULT_HTTP_CLIENT_CONFIG.adapter,
21
37
  security: {
22
38
  ...DEFAULT_SECURITY_CONFIG,
23
- ...userConfig.security
39
+ ...securityConfig
24
40
  },
25
41
  logging: {
26
42
  ...DEFAULT_LOGGING_CONFIG,
27
- ...userConfig.logging
43
+ ...loggingConfig
28
44
  },
29
45
  request: {
30
46
  ...DEFAULT_REQUEST_CONFIG,
31
- ...userConfig.request
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(url: string): Promise<{ safeUrl: string, result: SsrfGuardResult }> {
154
- const result = await this.validate(url);
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
-
@@ -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: (url: string) => Promise<{ safeUrl: string, result: SsrfGuardResult }>;
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
-