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.
- package/CHANGELOG.md +7 -0
- package/dist/cjs/adapter/undici.adapter.js +85 -11
- package/dist/cjs/adapter/undici.adapter.js.map +1 -1
- package/dist/cjs/client.js +84 -11
- package/dist/cjs/client.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 +85 -11
- package/dist/esm/adapter/undici.adapter.js.map +1 -1
- package/dist/esm/client.js +84 -11
- package/dist/esm/client.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 +21 -0
- package/dist/types/adapter/undici.adapter.d.ts.map +1 -1
- package/dist/types/client.d.ts +12 -0
- package/dist/types/client.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 +105 -11
- package/src/client.ts +112 -11
- package/src/security/ssrf.guard.ts +17 -6
- package/src/types/security.ts +21 -3
|
@@ -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
|
|
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
|
@@ -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
|
|
46
|
-
const
|
|
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:
|
|
224
|
-
headersTimeout:
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|