nstarter-http-request 0.1.8 → 0.1.10

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.
@@ -65,9 +65,22 @@ export interface SsrfGuardResult {
65
65
  resolvedIps?: string[];
66
66
  /** 安全的 IP 地址(如果进行了 DNS 解析且通过验证) */
67
67
  safeIp?: string;
68
+ /** 是否因外部路由决策跳过目标域名 DNS 校验 */
69
+ dnsValidationSkipped?: boolean;
70
+ /** 跳过目标域名 DNS 校验的原因 */
71
+ dnsValidationSkipReason?: string;
68
72
  /** 失败原因 */
69
73
  reason?: string;
70
74
  }
75
+ /**
76
+ * SSRF 防护验证选项
77
+ */
78
+ export interface SsrfGuardValidateOptions {
79
+ /** 跳过目标域名 DNS 校验,通常用于目标域名由正向代理解析的场景 */
80
+ skipDnsValidation?: boolean;
81
+ /** 跳过目标域名 DNS 校验的原因 */
82
+ skipDnsValidationReason?: string;
83
+ }
71
84
  /**
72
85
  * SSRF 防护接口
73
86
  */
@@ -75,16 +88,18 @@ export interface ISsrfGuard {
75
88
  /**
76
89
  * 验证 URL 是否安全
77
90
  * @param url 要验证的 URL
91
+ * @param options 验证选项
78
92
  * @returns 验证结果
79
93
  */
80
- validate: (url: string) => Promise<SsrfGuardResult>;
94
+ validate: (url: string, options?: SsrfGuardValidateOptions) => Promise<SsrfGuardResult>;
81
95
  /**
82
96
  * 验证并返回安全的 URL
83
97
  * @param url 要验证的 URL
98
+ * @param options 验证选项
84
99
  * @returns 安全的 URL 和验证结果
85
100
  * @throws {SecurityError} 验证失败时抛出错误
86
101
  */
87
- validateAndGetSafeUrl: (url: string) => Promise<{
102
+ validateAndGetSafeUrl: (url: string, options?: SsrfGuardValidateOptions) => Promise<{
88
103
  safeUrl: string;
89
104
  result: SsrfGuardResult;
90
105
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../../src/types/security.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C;;GAEG;AACH,MAAM,WAAW,SAAS;IACtB,SAAS;IACT,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB;IACnB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,YAAY;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU;IACV,GAAG,EAAE,MAAM,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAC/B,WAAW;IACX,KAAK,EAAE,OAAO,CAAC;IACf,YAAY;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,eAAe;IACf,SAAS,EAAE,OAAO,CAAC;IACnB,cAAc;IACd,UAAU,EAAE,OAAO,CAAC;IACpB,qBAAqB;IACrB,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc;IACd,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc;IACd,UAAU,EAAE,OAAO,CAAC;IACpB,4BAA4B;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAChC,WAAW;IACX,KAAK,EAAE,OAAO,CAAC;IACf,UAAU;IACV,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc;IACd,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc;IACd,WAAW,EAAE,OAAO,CAAC;IACrB,4BAA4B;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAGD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC5B,aAAa;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,mCAAmC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW;IACX,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACvB;;;;OAIG;IACH,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;IAEpD;;;;;OAKG;IACH,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,eAAe,CAAA;KAAE,CAAC,CAAC;IAE9F;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,cAAc,CAAC,KAAK,IAAI,CAAC;IAExD;;OAEG;IACH,aAAa,EAAE,MAAM,IAAI,CAAC;IAE1B;;;OAGG;IACH,eAAe,EAAE,MAAM,MAAM,CAAC;IAE9B;;OAEG;IACH,oBAAoB,EAAE,MAAM,IAAI,CAAC;IAEjC;;;OAGG;IACH,SAAS,EAAE,MAAM,cAAc,CAAC;CACnC"}
1
+ {"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../../src/types/security.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C;;GAEG;AACH,MAAM,WAAW,SAAS;IACtB,SAAS;IACT,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB;IACnB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,YAAY;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU;IACV,GAAG,EAAE,MAAM,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAC/B,WAAW;IACX,KAAK,EAAE,OAAO,CAAC;IACf,YAAY;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,eAAe;IACf,SAAS,EAAE,OAAO,CAAC;IACnB,cAAc;IACd,UAAU,EAAE,OAAO,CAAC;IACpB,qBAAqB;IACrB,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc;IACd,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc;IACd,UAAU,EAAE,OAAO,CAAC;IACpB,4BAA4B;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAChC,WAAW;IACX,KAAK,EAAE,OAAO,CAAC;IACf,UAAU;IACV,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc;IACd,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc;IACd,WAAW,EAAE,OAAO,CAAC;IACrB,4BAA4B;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAGD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC5B,aAAa;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,mCAAmC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6BAA6B;IAC7B,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,uBAAuB;IACvB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,WAAW;IACX,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACrC,uCAAuC;IACvC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,uBAAuB;IACvB,uBAAuB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACvB;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;IAExF;;;;;;OAMG;IACH,qBAAqB,EAAE,CACnB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,wBAAwB,KACjC,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,eAAe,CAAA;KAAE,CAAC,CAAC;IAE3D;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,cAAc,CAAC,KAAK,IAAI,CAAC;IAExD;;OAEG;IACH,aAAa,EAAE,MAAM,IAAI,CAAC;IAE1B;;;OAGG;IACH,eAAe,EAAE,MAAM,MAAM,CAAC;IAE9B;;OAEG;IACH,oBAAoB,EAAE,MAAM,IAAI,CAAC;IAEjC;;;OAGG;IACH,SAAS,EAAE,MAAM,cAAc,CAAC;CACnC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nstarter-http-request",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "nstarter HTTP 请求客户端插件",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,6 +17,13 @@ import undici, {
17
17
  import { STATUS_CODES } from 'http';
18
18
  import { EClientAdapter } from '../const';
19
19
 
20
+ interface RequestTimeoutState {
21
+ signal?: AbortSignal;
22
+ timeout?: number;
23
+ readonly timedOut: boolean;
24
+ cleanup: () => void;
25
+ }
26
+
20
27
  /**
21
28
  * Undici 适配器
22
29
  */
@@ -42,8 +49,9 @@ export class UndiciAdapter extends BaseHttpAdapter {
42
49
  const fullUrl = this.buildUrl(options.url, options.params);
43
50
  const headers = this.prepareHeaders(options.headers);
44
51
  const body = this.prepareBody(options.body, headers);
45
- const dispatcher = this.buildDispatcher(options);
46
- const undiciOptions = this.buildUndiciOptions(options, headers, body, dispatcher);
52
+ const timeoutState = this.createRequestTimeoutState(options);
53
+ const dispatcher = this.buildDispatcher(options, timeoutState.timeout);
54
+ const undiciOptions = this.buildUndiciOptions(options, headers, body, dispatcher, timeoutState);
47
55
 
48
56
  try {
49
57
  const startTime = Date.now();
@@ -57,6 +65,10 @@ export class UndiciAdapter extends BaseHttpAdapter {
57
65
  duration
58
66
  });
59
67
 
68
+ if (options.responseType === 'stream') {
69
+ timeoutState.cleanup();
70
+ }
71
+
60
72
  const data = await this.readResponseBody<T>(response, options.responseType);
61
73
 
62
74
  const httpResponse: HttpResponse<T> = {
@@ -72,7 +84,18 @@ export class UndiciAdapter extends BaseHttpAdapter {
72
84
 
73
85
  return httpResponse;
74
86
  } catch (error: unknown) {
87
+ if (timeoutState.timedOut) {
88
+ throw new HttpError(
89
+ `timeout of ${ timeoutState.timeout }ms exceeded`,
90
+ 'ECONNABORTED',
91
+ undefined,
92
+ undefined,
93
+ options
94
+ );
95
+ }
75
96
  throw this.handleUndiciError(error, options);
97
+ } finally {
98
+ timeoutState.cleanup();
76
99
  }
77
100
  }
78
101
 
@@ -130,13 +153,15 @@ export class UndiciAdapter extends BaseHttpAdapter {
130
153
  /**
131
154
  * 构建 Dispatcher(代理或 TLS 配置)
132
155
  * @param options 请求选项
156
+ * @param timeout 请求总超时时间
133
157
  */
134
- private buildDispatcher(options: HttpRequestOptions): Dispatcher | undefined {
158
+ private buildDispatcher(options: HttpRequestOptions, timeout?: number): Dispatcher | undefined {
135
159
  let dispatcher: Dispatcher | undefined;
136
160
 
137
161
  // 如果 rejectUnauthorized 为 false,需要创建 Agent 来设置 TLS 选项
138
162
  if (!this.securityConfig.rejectUnauthorized) {
139
163
  dispatcher = new Agent({
164
+ ...(this.isPositiveTimeout(timeout) ? { connectTimeout: timeout } : {}),
140
165
  connect: {
141
166
  rejectUnauthorized: false
142
167
  }
@@ -145,13 +170,13 @@ export class UndiciAdapter extends BaseHttpAdapter {
145
170
 
146
171
  // 有代理时,创建 ProxyAgent
147
172
  if (this.requestConfig.proxy) {
148
- dispatcher = this.createGlobalProxyAgent();
173
+ dispatcher = this.createGlobalProxyAgent(timeout);
149
174
  }
150
175
 
151
176
  // 设置正向代理(如果请求元数据中包含代理信息,会覆盖全局代理配置)
152
177
  const forwardProxyMeta = options.meta?.__forwardProxy;
153
178
  if (forwardProxyMeta) {
154
- dispatcher = this.createForwardProxyAgent(forwardProxyMeta.proxyUrl);
179
+ dispatcher = this.createForwardProxyAgent(forwardProxyMeta.proxyUrl, timeout);
155
180
  }
156
181
 
157
182
  return dispatcher;
@@ -159,8 +184,9 @@ export class UndiciAdapter extends BaseHttpAdapter {
159
184
 
160
185
  /**
161
186
  * 创建全局代理 Agent
187
+ * @param timeout 请求总超时时间
162
188
  */
163
- private createGlobalProxyAgent(): ProxyAgent {
189
+ private createGlobalProxyAgent(timeout?: number): ProxyAgent {
164
190
  const { protocol, host, port, auth } = this.requestConfig.proxy!;
165
191
  let proxyUrl = `${ protocol }://${ host }:${ port }`;
166
192
 
@@ -172,6 +198,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
172
198
 
173
199
  const agent = new ProxyAgent({
174
200
  uri: proxyUrl,
201
+ ...(this.isPositiveTimeout(timeout) ? { connectTimeout: timeout } : {}),
175
202
  connect: {
176
203
  rejectUnauthorized: this.securityConfig.rejectUnauthorized
177
204
  }
@@ -190,10 +217,12 @@ export class UndiciAdapter extends BaseHttpAdapter {
190
217
  /**
191
218
  * 创建正向代理 Agent
192
219
  * @param proxyUrl 代理 URL
220
+ * @param timeout 请求总超时时间
193
221
  */
194
- private createForwardProxyAgent(proxyUrl: string): ProxyAgent {
222
+ private createForwardProxyAgent(proxyUrl: string, timeout?: number): ProxyAgent {
195
223
  const agent = new ProxyAgent({
196
224
  uri: proxyUrl,
225
+ ...(this.isPositiveTimeout(timeout) ? { connectTimeout: timeout } : {}),
197
226
  connect: {
198
227
  rejectUnauthorized: this.securityConfig.rejectUnauthorized
199
228
  }
@@ -214,19 +243,67 @@ export class UndiciAdapter extends BaseHttpAdapter {
214
243
  options: HttpRequestOptions,
215
244
  headers: Record<string, string | string[]>,
216
245
  body: Dispatcher.DispatchOptions['body'],
217
- dispatcher?: Dispatcher
246
+ dispatcher?: Dispatcher,
247
+ timeoutState?: RequestTimeoutState
218
248
  ): Parameters<typeof undici.request>[1] {
249
+ const timeout = this.resolveTimeout(options);
219
250
  return {
220
251
  method: options.method as Dispatcher.HttpMethod,
221
252
  headers,
222
253
  body,
223
- bodyTimeout: options.timeout ?? this.requestConfig.timeout,
224
- headersTimeout: options.timeout ?? this.requestConfig.timeout,
254
+ bodyTimeout: timeout,
255
+ headersTimeout: timeout,
225
256
  maxRedirections: this.requestConfig.maxRedirects,
257
+ ...(timeoutState?.signal && { signal: timeoutState.signal }),
226
258
  ...(dispatcher && { dispatcher }),
227
259
  };
228
260
  }
229
261
 
262
+ /**
263
+ * 创建请求总耗时控制器。
264
+ * undici 的 headersTimeout/bodyTimeout 只覆盖阶段等待;这里补齐 axios timeout 的整次请求预算语义。
265
+ */
266
+ private createRequestTimeoutState(options: HttpRequestOptions): RequestTimeoutState {
267
+ const timeout = this.resolveTimeout(options);
268
+ if (!this.isPositiveTimeout(timeout)) {
269
+ return {
270
+ timedOut: false,
271
+ cleanup: () => {}
272
+ };
273
+ }
274
+
275
+ const controller = new AbortController();
276
+ let timedOut = false;
277
+ const timer = setTimeout(() => {
278
+ timedOut = true;
279
+ controller.abort();
280
+ }, timeout);
281
+ timer.unref?.();
282
+
283
+ return {
284
+ signal: controller.signal,
285
+ timeout,
286
+ get timedOut() {
287
+ return timedOut;
288
+ },
289
+ cleanup: () => clearTimeout(timer)
290
+ };
291
+ }
292
+
293
+ /**
294
+ * 获取请求级超时时间。
295
+ */
296
+ private resolveTimeout(options: HttpRequestOptions): number {
297
+ return options.timeout ?? this.requestConfig.timeout;
298
+ }
299
+
300
+ /**
301
+ * 判断超时时间是否启用。
302
+ */
303
+ private isPositiveTimeout(timeout?: number): timeout is number {
304
+ return typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0;
305
+ }
306
+
230
307
  /**
231
308
  * 处理 Undici 错误
232
309
  * @param error 错误对象
@@ -347,7 +424,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
347
424
  return (await response.body.formData()) as T;
348
425
  }
349
426
  case 'json': {
350
- return (await response.body.json()) as T;
427
+ return await this.readJsonResponseBody<T>(response);
351
428
  }
352
429
  default: {
353
430
  const text = await response.body.text();
@@ -365,6 +442,23 @@ export class UndiciAdapter extends BaseHttpAdapter {
365
442
  }
366
443
  }
367
444
 
445
+ /**
446
+ * 按 axios 的默认兼容行为读取 JSON 响应。
447
+ * 空响应体或非 JSON 文本不应让成功状态码请求因为解析失败变成异常。
448
+ */
449
+ private async readJsonResponseBody<T>(response: Dispatcher.ResponseData): Promise<T> {
450
+ const text = await response.body.text();
451
+ if (!text) {
452
+ return text as T;
453
+ }
454
+
455
+ try {
456
+ return JSON.parse(text) as T;
457
+ } catch {
458
+ return text as T;
459
+ }
460
+ }
461
+
368
462
  /**
369
463
  * 获取状态文本
370
464
  * @param statusCode 状态码
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,16 +189,33 @@ 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,
@@ -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
  // 发起请求
@@ -483,16 +514,40 @@ export class NsHttpClient implements IHttpClient {
483
514
  /**
484
515
  * 应用正向代理配置
485
516
  * @param options 请求选项
517
+ * @param decision 已解析的正向代理路由决策
518
+ */
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
486
529
  */
487
- private applyForwardProxy(options: HttpRequestOptions): void {
530
+ private resolveForwardProxyDecision(url: string): ForwardProxyDecision {
488
531
  if (!this.forwardProxy || !this.forwardProxy.enabled) {
489
- return;
532
+ return {
533
+ shouldProxy: false,
534
+ reason: 'disabled'
535
+ };
490
536
  }
491
537
 
492
538
  const { https_proxy, http_proxy, no_proxy } = this.forwardProxy;
493
539
 
494
540
  // 解析目标 URL
495
- 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
+
496
551
  const hostname = targetUrl.hostname;
497
552
 
498
553
  // 检查 no_proxy 规则(使用传统匹配模式)
@@ -500,26 +555,72 @@ export class NsHttpClient implements IHttpClient {
500
555
  if (noProxyRegExps.length > 0 && hostname) {
501
556
  for (const noProxyRegex of noProxyRegExps) {
502
557
  if (noProxyRegex.test(hostname)) {
503
- this.logger.debug('Skip proxy for no_proxy matched host', {
558
+ return {
559
+ shouldProxy: false,
560
+ reason: 'no_proxy',
504
561
  hostname,
505
562
  pattern: noProxyRegex.source
506
- });
507
- return;
563
+ };
508
564
  }
509
565
  }
510
566
  }
511
567
 
512
568
  const protocol = targetUrl.protocol || 'http:';
513
- 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:';
514
579
  const proxyUrl = isHttps ? https_proxy : http_proxy;
515
580
 
516
581
  if (!proxyUrl) {
517
- this.logger.warn('Forward proxy enabled but proxy URL not configured', {
582
+ return {
583
+ shouldProxy: false,
584
+ reason: 'missing_proxy_url',
585
+ hostname,
518
586
  protocol
519
- });
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
+ }
520
619
  return;
521
620
  }
522
621
 
622
+ const { proxyUrl, isHttps, protocol } = decision;
623
+
523
624
  // 将代理信息附加到请求元数据中,由适配器处理
524
625
  options.meta = {
525
626
  ...options.meta,
@@ -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
-