@data-fair/lib-node 2.13.3 → 2.14.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/axios-auth.js +6 -3
- package/axios-auth.js.map +1 -1
- package/axios.d.ts +1 -0
- package/axios.js +6 -1
- package/axios.js.map +1 -1
- package/http-agents.d.ts +3 -2
- package/http-agents.js +50 -22
- package/http-agents.js.map +1 -1
- package/http-agents.test.d.ts +1 -0
- package/http-agents.test.js +18 -0
- package/http-agents.test.js.map +1 -0
- package/package.json +2 -1
- package/ssrf.d.ts +33 -0
- package/ssrf.js +123 -0
- package/ssrf.js.map +1 -0
- package/ssrf.test.d.ts +1 -0
- package/ssrf.test.js +113 -0
- package/ssrf.test.js.map +1 -0
- package/ws-client.d.ts +1 -0
- package/ws-client.js +24 -7
- package/ws-client.js.map +1 -1
- package/ws-client.test.d.ts +1 -0
- package/ws-client.test.js +118 -0
- package/ws-client.test.js.map +1 -0
package/axios-auth.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// WARNING: use for integration testing only
|
|
3
3
|
import { Agent } from 'node:http';
|
|
4
4
|
import { CookieJar } from 'tough-cookie';
|
|
5
|
-
import {
|
|
5
|
+
import { privateAxiosInstance } from './axios.js';
|
|
6
|
+
import { privateHttpsAgent } from './http-agents.js';
|
|
6
7
|
import { axiosWithCookies } from './axios-with-cookies.js';
|
|
7
8
|
export async function axiosAuth(opts) {
|
|
8
9
|
const body = { email: opts.email, password: opts.password };
|
|
@@ -16,18 +17,20 @@ export async function axiosAuth(opts) {
|
|
|
16
17
|
body.orgStorage = opts.orgStorage;
|
|
17
18
|
const axiosOpts = {
|
|
18
19
|
httpAgent: new Agent({ keepAlive: false }),
|
|
20
|
+
// the directory is a service of our own infrastructure, not a user provided URL
|
|
21
|
+
httpsAgent: privateHttpsAgent,
|
|
19
22
|
maxRedirects: 0,
|
|
20
23
|
...opts.axiosOpts
|
|
21
24
|
};
|
|
22
25
|
const directoryUrl = opts.directoryUrl ?? 'http://localhost:8080';
|
|
23
|
-
let callbackUrl = (await
|
|
26
|
+
let callbackUrl = (await privateAxiosInstance.post(directoryUrl + '/api/auth/password', body, { params: { redirect: directoryUrl }, maxRedirects: 0 })).data;
|
|
24
27
|
if (callbackUrl.startsWith(directoryUrl + '/simple-directory')) {
|
|
25
28
|
callbackUrl = callbackUrl.replace(directoryUrl + '/simple-directory', directoryUrl);
|
|
26
29
|
}
|
|
27
30
|
const sdOrigin = new URL(directoryUrl).origin;
|
|
28
31
|
const cookieJar = new CookieJar();
|
|
29
32
|
try {
|
|
30
|
-
await
|
|
33
|
+
await privateAxiosInstance.get(callbackUrl, { maxRedirects: 0 });
|
|
31
34
|
}
|
|
32
35
|
catch (err) {
|
|
33
36
|
if (err.status !== 302)
|
package/axios-auth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"axios-auth.js","sourceRoot":"","sources":["axios-auth.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,4CAA4C;AAE5C,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAA;AACjC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"axios-auth.js","sourceRoot":"","sources":["axios-auth.ts"],"names":[],"mappings":"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","sourcesContent":["// build axios instances with sessions on a simple-directory instance\n// WARNING: use for integration testing only\n\nimport { Agent } from 'node:http'\nimport { CookieJar } from 'tough-cookie'\nimport { privateAxiosInstance } from './axios.js'\nimport { privateHttpsAgent } from './http-agents.js'\nimport { axiosWithCookies, type AxiosWithCookiesInstance } from './axios-with-cookies.js'\n\nexport interface AxiosAuthOptions {\n email: string\n password: string\n org?: string\n dep?: string\n adminMode?: boolean\n directoryUrl?: string\n axiosOpts?: any\n orgStorage?: boolean\n globalCookies?: boolean\n}\n\nexport type AxiosAuthInstance = AxiosWithCookiesInstance & {\n setOrg: (org: string, dep?: string) => void\n}\n\nexport async function axiosAuth (opts: AxiosAuthOptions): Promise<AxiosAuthInstance> {\n const body: any = { email: opts.email, password: opts.password }\n if (opts.org) body.org = opts.org\n if (opts.dep) body.dep = opts.dep\n if (opts.adminMode) body.adminMode = opts.adminMode\n if (opts.orgStorage) body.orgStorage = opts.orgStorage\n const axiosOpts = {\n httpAgent: new Agent({ keepAlive: false }),\n // the directory is a service of our own infrastructure, not a user provided URL\n httpsAgent: privateHttpsAgent,\n maxRedirects: 0,\n ...opts.axiosOpts\n }\n const directoryUrl = opts.directoryUrl ?? 'http://localhost:8080'\n let callbackUrl = (await privateAxiosInstance.post(directoryUrl + '/api/auth/password', body, { params: { redirect: directoryUrl }, maxRedirects: 0 })).data\n if (callbackUrl.startsWith(directoryUrl + '/simple-directory')) {\n callbackUrl = callbackUrl.replace(directoryUrl + '/simple-directory', directoryUrl)\n }\n\n const sdOrigin = new URL(directoryUrl).origin\n const cookieJar = new CookieJar()\n try {\n await privateAxiosInstance.get(callbackUrl, { maxRedirects: 0 })\n } catch (err: any) {\n if (err.status !== 302) throw err\n const redirectUrl = new URL(err.headers.location)\n const redirectError = redirectUrl.searchParams.get('error')\n if (redirectError) throw new Error(redirectError)\n for (const cookie of err.headers['set-cookie'] ?? []) cookieJar.setCookieSync(cookie, sdOrigin)\n }\n\n const ax = axiosWithCookies({\n axiosOpts,\n cookiesOrigin: sdOrigin,\n globalCookies: opts.globalCookies,\n cookieJar\n }) as AxiosAuthInstance\n\n ax.setOrg = (org: string, dep?: string) => {\n ax.cookieJar.setCookieSync(`id_token_org=${org}`, sdOrigin)\n ax.cookieJar.setCookieSync(`id_token_dep=${dep ?? ''}`, sdOrigin)\n }\n\n return ax\n}\n\nexport default axiosAuth\n"]}
|
package/axios.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AxiosInstance, CreateAxiosDefaults } from 'axios';
|
|
2
2
|
export declare function axiosBuilder(opts?: CreateAxiosDefaults, beforeInterceptors?: (ax: AxiosInstance) => void): AxiosInstance;
|
|
3
3
|
export declare const axiosInstance: AxiosInstance;
|
|
4
|
+
export declare const privateAxiosInstance: AxiosInstance;
|
|
4
5
|
export default axiosInstance;
|
package/axios.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// prepare an axios instance with improved error management and some performance tuning for nodejs usage
|
|
2
2
|
import axios from 'axios';
|
|
3
|
-
import { httpAgent, httpsAgent } from './http-agents.js';
|
|
3
|
+
import { httpAgent, httpsAgent, privateHttpAgent, privateHttpsAgent } from './http-agents.js';
|
|
4
4
|
const { stackTraceLimit } = Error;
|
|
5
5
|
class AxiosRequestError extends Error {
|
|
6
6
|
constructor() {
|
|
@@ -19,6 +19,7 @@ const requestInterceptor = (config) => {
|
|
|
19
19
|
config.errorContext = errorContext;
|
|
20
20
|
return config;
|
|
21
21
|
};
|
|
22
|
+
// public agents by default, pass privateHttpAgent / privateHttpsAgent in opts for a private instance
|
|
22
23
|
export function axiosBuilder(opts = {}, beforeInterceptors) {
|
|
23
24
|
const ax = axios.create({
|
|
24
25
|
httpAgent,
|
|
@@ -60,6 +61,10 @@ export function axiosBuilder(opts = {}, beforeInterceptors) {
|
|
|
60
61
|
});
|
|
61
62
|
return ax;
|
|
62
63
|
}
|
|
64
|
+
// public instance, refuses to connect to non public addresses (cf http-agents.ts)
|
|
65
|
+
// use it for every URL that a user can influence
|
|
63
66
|
export const axiosInstance = axiosBuilder();
|
|
67
|
+
// private instance, only for URLs that come from the configuration of the service
|
|
68
|
+
export const privateAxiosInstance = axiosBuilder({ httpAgent: privateHttpAgent, httpsAgent: privateHttpsAgent });
|
|
64
69
|
export default axiosInstance;
|
|
65
70
|
//# sourceMappingURL=axios.js.map
|
package/axios.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"axios.js","sourceRoot":"","sources":["axios.ts"],"names":[],"mappings":"AAAA,wGAAwG;AAGxG,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"axios.js","sourceRoot":"","sources":["axios.ts"],"names":[],"mappings":"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","sourcesContent":["// prepare an axios instance with improved error management and some performance tuning for nodejs usage\n\nimport type { InternalAxiosRequestConfig, AxiosInstance, CreateAxiosDefaults } from 'axios'\nimport axios from 'axios'\nimport { httpAgent, httpsAgent, privateHttpAgent, privateHttpsAgent } from './http-agents.js'\n\nconst { stackTraceLimit } = Error\n\nclass AxiosRequestError extends Error {\n constructor () {\n super()\n this.name = 'AxiosRequestError'\n }\n}\n\nconst requestInterceptor = (config: InternalAxiosRequestConfig) => {\n // better stack traces\n // see https://github.com/axios/axios/issues/2387#issuecomment-652242713\n Error.stackTraceLimit = 0\n const errorContext = new AxiosRequestError()\n Error.stackTraceLimit = stackTraceLimit\n Error.captureStackTrace(errorContext, requestInterceptor)\n // @ts-ignore\n config.errorContext = errorContext\n return config\n}\n\n// public agents by default, pass privateHttpAgent / privateHttpsAgent in opts for a private instance\nexport function axiosBuilder (opts: CreateAxiosDefaults = {}, beforeInterceptors?: (ax: AxiosInstance) => void): AxiosInstance {\n const ax = axios.create({\n httpAgent,\n httpsAgent,\n ...opts,\n headers: {\n // identifies requests coming from the data-fair stack, with a contact URL as\n // recommended/required by policies like https://foundation.wikimedia.org/wiki/Policy:Wikimedia_Foundation_User-Agent_Policy\n 'User-Agent': 'Data-Fair (https://github.com/data-fair)',\n ...opts.headers\n }\n })\n\n if (beforeInterceptors) beforeInterceptors(ax)\n\n ax.interceptors.request.use(requestInterceptor)\n\n // shorter stack traces\n ax.interceptors.response.use(response => response, error => {\n if (!error.response) {\n if (error.config?.errorContext?.stack) error.stack += '\\nRequest context:\\n' + error.config?.errorContext?.stack\n return Promise.reject(error)\n }\n\n delete error.response.request\n error.response.config = { method: error.response.config.method, url: error.response.config.url, data: error.response.config.data }\n if (error.response.config.data && error.response.config.data._writableState) delete error.response.config.data\n // destroy() a streamed error body before dropping it, else its socket leaks\n if (error.response.data && error.response.data._readableState) { error.response.data.destroy(); delete error.response.data }\n let messageText = error.response.statusText\n if (error.response.data) {\n messageText = typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data)\n }\n error.response.message = `${error.response.status} - ${messageText}`\n Object.assign(error.config.errorContext, error.response)\n return Promise.reject(error.config.errorContext)\n })\n return ax\n}\n\n// public instance, refuses to connect to non public addresses (cf http-agents.ts)\n// use it for every URL that a user can influence\nexport const axiosInstance = axiosBuilder()\n\n// private instance, only for URLs that come from the configuration of the service\nexport const privateAxiosInstance = axiosBuilder({ httpAgent: privateHttpAgent, httpsAgent: privateHttpsAgent })\n\nexport default axiosInstance\n"]}
|
package/http-agents.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import HttpAgent from 'agentkeepalive';
|
|
2
|
-
export declare const
|
|
3
|
-
export declare const
|
|
2
|
+
export declare const privateHttpAgent: HttpAgent;
|
|
3
|
+
export declare const privateHttpsAgent: HttpAgent.HttpsAgent;
|
|
4
|
+
export declare const httpAgent: HttpAgent, httpsAgent: HttpAgent.HttpsAgent;
|
package/http-agents.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
// use better DNS lookup than nodejs default and try to reduce number of socket openings
|
|
2
|
+
// and protect against server side request forgery (SSRF)
|
|
3
|
+
//
|
|
4
|
+
// public agents (httpAgent / httpsAgent, the default) refuse to connect to non public addresses,
|
|
5
|
+
// they must be used for every URL that a user can influence
|
|
6
|
+
// private agents (privateHttpAgent / privateHttpsAgent) must only be used for URLs that come
|
|
7
|
+
// from the configuration of the service (other services of the same infrastructure)
|
|
8
|
+
//
|
|
9
|
+
// SSRF_PUBLIC_IPS: comma separated IPs / CIDR ranges to consider public (an intranet source of a self-hosted instance)
|
|
10
|
+
// SSRF_PRIVATE_IPS: comma separated IPs / CIDR ranges to consider private (public IPs of databases, etc.)
|
|
2
11
|
import CacheableLookup from 'cacheable-lookup';
|
|
3
12
|
import HttpAgent, { HttpsAgent } from 'agentkeepalive';
|
|
4
13
|
import { Counter, Gauge } from 'prom-client';
|
|
14
|
+
import { SsrfHttpAgent, SsrfHttpsAgent, getSsrfRules, getProxyEnvName } from './ssrf.js';
|
|
5
15
|
// not very high number of sockets but we don't want to saturate our reverse proxies
|
|
6
16
|
// if a higher number is needed the service should probably be scaled anyway
|
|
7
17
|
const keepaliveOpts = {
|
|
@@ -10,47 +20,65 @@ const keepaliveOpts = {
|
|
|
10
20
|
timeout: 60000,
|
|
11
21
|
freeSocketTimeout: 30000
|
|
12
22
|
};
|
|
13
|
-
export const
|
|
14
|
-
export const
|
|
23
|
+
export const privateHttpAgent = new HttpAgent(keepaliveOpts);
|
|
24
|
+
export const privateHttpsAgent = new HttpsAgent(keepaliveOpts);
|
|
25
|
+
const createPublicAgents = () => {
|
|
26
|
+
const proxyEnvName = getProxyEnvName();
|
|
27
|
+
if (proxyEnvName) {
|
|
28
|
+
console.warn(`WARNING: ${proxyEnvName} is defined, the protection against server side request forgery (SSRF) is disabled, outgoing requests are not checked against non public addresses`);
|
|
29
|
+
return { httpAgent: new HttpAgent(keepaliveOpts), httpsAgent: new HttpsAgent(keepaliveOpts) };
|
|
30
|
+
}
|
|
31
|
+
const rules = getSsrfRules();
|
|
32
|
+
return { httpAgent: new SsrfHttpAgent(keepaliveOpts, rules), httpsAgent: new SsrfHttpsAgent(keepaliveOpts, rules) };
|
|
33
|
+
};
|
|
34
|
+
export const { httpAgent, httpsAgent } = createPublicAgents();
|
|
15
35
|
const cacheableLookup = new CacheableLookup();
|
|
16
|
-
|
|
17
|
-
cacheableLookup.install(
|
|
36
|
+
for (const agent of [httpAgent, httpsAgent, privateHttpAgent, privateHttpsAgent]) {
|
|
37
|
+
cacheableLookup.install(agent);
|
|
38
|
+
}
|
|
18
39
|
// monitor agent statuses
|
|
19
|
-
const agents = {
|
|
40
|
+
const agents = {
|
|
41
|
+
public: { http: httpAgent, https: httpsAgent },
|
|
42
|
+
private: { http: privateHttpAgent, https: privateHttpsAgent }
|
|
43
|
+
};
|
|
20
44
|
const socketsGauge = new Gauge({
|
|
21
45
|
name: 'df_http_agent_sockets',
|
|
22
46
|
help: 'Number of open sockets in the http agent',
|
|
23
|
-
labelNames: ['protocol', 'host']
|
|
47
|
+
labelNames: ['network', 'protocol', 'host']
|
|
24
48
|
});
|
|
25
49
|
const freeSocketsGauge = new Gauge({
|
|
26
50
|
name: 'df_http_agent_free_sockets',
|
|
27
51
|
help: 'Number of free sockets in the http agent',
|
|
28
|
-
labelNames: ['protocol', 'host']
|
|
52
|
+
labelNames: ['network', 'protocol', 'host']
|
|
29
53
|
});
|
|
30
54
|
const createSocketCounter = new Counter({
|
|
31
55
|
name: 'df_http_agent_create_socket_total',
|
|
32
56
|
help: 'Total number of sockets created by the http agent',
|
|
33
|
-
labelNames: ['protocol']
|
|
57
|
+
labelNames: ['network', 'protocol']
|
|
34
58
|
});
|
|
35
59
|
const requestCounter = new Counter({
|
|
36
60
|
name: 'df_http_agent_request_total',
|
|
37
61
|
help: 'Total number of requests created by the http agent',
|
|
38
|
-
labelNames: ['protocol']
|
|
62
|
+
labelNames: ['network', 'protocol']
|
|
39
63
|
});
|
|
64
|
+
// unref'd so that importing this module never keeps a process alive on its own:
|
|
65
|
+
// a server is kept running by its own handles and still gets the metrics, while
|
|
66
|
+
// CLIs, tests and build tools can exit normally
|
|
40
67
|
setInterval(() => {
|
|
41
|
-
for (const
|
|
42
|
-
const protocol
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
68
|
+
for (const network of ['public', 'private']) {
|
|
69
|
+
for (const protocol of ['http', 'https']) {
|
|
70
|
+
const agentStatus = agents[network][protocol].getCurrentStatus();
|
|
71
|
+
for (const host in agentStatus.sockets) {
|
|
72
|
+
socketsGauge.set({ network, protocol, host }, agentStatus.sockets[host]);
|
|
73
|
+
}
|
|
74
|
+
for (const host in agentStatus.freeSockets) {
|
|
75
|
+
freeSocketsGauge.set({ network, protocol, host }, agentStatus.freeSockets[host]);
|
|
76
|
+
}
|
|
77
|
+
createSocketCounter.remove({ network, protocol });
|
|
78
|
+
createSocketCounter.inc({ network, protocol }, agentStatus.createSocketCount);
|
|
79
|
+
requestCounter.remove({ network, protocol });
|
|
80
|
+
requestCounter.inc({ network, protocol }, agentStatus.requestCount);
|
|
49
81
|
}
|
|
50
|
-
createSocketCounter.remove({ protocol });
|
|
51
|
-
createSocketCounter.inc({ protocol }, agentStatus.createSocketCount);
|
|
52
|
-
requestCounter.remove({ protocol });
|
|
53
|
-
requestCounter.inc({ protocol }, agentStatus.requestCount);
|
|
54
82
|
}
|
|
55
|
-
}, 60000);
|
|
83
|
+
}, 60000).unref();
|
|
56
84
|
//# sourceMappingURL=http-agents.js.map
|
package/http-agents.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-agents.js","sourceRoot":"","sources":["http-agents.ts"],"names":[],"mappings":"AAAA,wFAAwF;
|
|
1
|
+
{"version":3,"file":"http-agents.js","sourceRoot":"","sources":["http-agents.ts"],"names":[],"mappings":"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","sourcesContent":["// use better DNS lookup than nodejs default and try to reduce number of socket openings\n// and protect against server side request forgery (SSRF)\n//\n// public agents (httpAgent / httpsAgent, the default) refuse to connect to non public addresses,\n// they must be used for every URL that a user can influence\n// private agents (privateHttpAgent / privateHttpsAgent) must only be used for URLs that come\n// from the configuration of the service (other services of the same infrastructure)\n//\n// SSRF_PUBLIC_IPS: comma separated IPs / CIDR ranges to consider public (an intranet source of a self-hosted instance)\n// SSRF_PRIVATE_IPS: comma separated IPs / CIDR ranges to consider private (public IPs of databases, etc.)\n\nimport CacheableLookup from 'cacheable-lookup'\nimport HttpAgent, { HttpsAgent } from 'agentkeepalive'\nimport { Counter, Gauge } from 'prom-client'\nimport { SsrfHttpAgent, SsrfHttpsAgent, getSsrfRules, getProxyEnvName } from './ssrf.js'\n\n// not very high number of sockets but we don't want to saturate our reverse proxies\n// if a higher number is needed the service should probably be scaled anyway\nconst keepaliveOpts = {\n maxSockets: 8,\n maxFreeSockets: 8,\n timeout: 60000,\n freeSocketTimeout: 30000\n}\n\nexport const privateHttpAgent = new HttpAgent(keepaliveOpts)\nexport const privateHttpsAgent = new HttpsAgent(keepaliveOpts)\n\nconst createPublicAgents = () => {\n const proxyEnvName = getProxyEnvName()\n if (proxyEnvName) {\n console.warn(`WARNING: ${proxyEnvName} is defined, the protection against server side request forgery (SSRF) is disabled, outgoing requests are not checked against non public addresses`)\n return { httpAgent: new HttpAgent(keepaliveOpts), httpsAgent: new HttpsAgent(keepaliveOpts) }\n }\n const rules = getSsrfRules()\n return { httpAgent: new SsrfHttpAgent(keepaliveOpts, rules), httpsAgent: new SsrfHttpsAgent(keepaliveOpts, rules) }\n}\n\nexport const { httpAgent, httpsAgent } = createPublicAgents()\n\nconst cacheableLookup = new CacheableLookup()\nfor (const agent of [httpAgent, httpsAgent, privateHttpAgent, privateHttpsAgent]) {\n cacheableLookup.install(agent)\n}\n\n// monitor agent statuses\nconst agents = {\n public: { http: httpAgent, https: httpsAgent },\n private: { http: privateHttpAgent, https: privateHttpsAgent }\n}\n\nconst socketsGauge = new Gauge({\n name: 'df_http_agent_sockets',\n help: 'Number of open sockets in the http agent',\n labelNames: ['network', 'protocol', 'host']\n})\nconst freeSocketsGauge = new Gauge({\n name: 'df_http_agent_free_sockets',\n help: 'Number of free sockets in the http agent',\n labelNames: ['network', 'protocol', 'host']\n})\nconst createSocketCounter = new Counter({\n name: 'df_http_agent_create_socket_total',\n help: 'Total number of sockets created by the http agent',\n labelNames: ['network', 'protocol']\n})\nconst requestCounter = new Counter({\n name: 'df_http_agent_request_total',\n help: 'Total number of requests created by the http agent',\n labelNames: ['network', 'protocol']\n})\n\n// unref'd so that importing this module never keeps a process alive on its own:\n// a server is kept running by its own handles and still gets the metrics, while\n// CLIs, tests and build tools can exit normally\nsetInterval(() => {\n for (const network of ['public', 'private'] as const) {\n for (const protocol of ['http', 'https'] as const) {\n const agentStatus = agents[network][protocol].getCurrentStatus()\n for (const host in agentStatus.sockets) {\n socketsGauge.set({ network, protocol, host }, agentStatus.sockets[host])\n }\n for (const host in agentStatus.freeSockets) {\n freeSocketsGauge.set({ network, protocol, host }, agentStatus.freeSockets[host])\n }\n createSocketCounter.remove({ network, protocol })\n createSocketCounter.inc({ network, protocol }, agentStatus.createSocketCount)\n requestCounter.remove({ network, protocol })\n requestCounter.inc({ network, protocol }, agentStatus.requestCount)\n }\n }\n}, 60000).unref()\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'assert';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
describe('http agents', () => {
|
|
8
|
+
it('should not keep a process alive just by being imported', async () => {
|
|
9
|
+
const modulePath = fileURLToPath(new URL('./http-agents.ts', import.meta.url));
|
|
10
|
+
try {
|
|
11
|
+
await execFileAsync(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', `import ${JSON.stringify(modulePath)}`], { timeout: 10000 });
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
assert.fail(`importing http-agents did not exit on its own (${err.message}), a module level timer is probably missing an unref()`);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
//# sourceMappingURL=http-agents.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-agents.test.js","sourceRoot":"","sources":["http-agents.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AACxC,OAAO,EAAE,MAAM,IAAI,MAAM,EAAE,MAAM,QAAQ,CAAA;AACzC,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC7C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AACrC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAExC,MAAM,aAAa,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAA;AAEzC,QAAQ,CAAC,aAAa,EAAE,GAAG,EAAE;IAC3B,EAAE,CAAC,wDAAwD,EAAE,KAAK,IAAI,EAAE;QACtE,MAAM,UAAU,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,kBAAkB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;QAC9E,IAAI,CAAC;YACH,MAAM,aAAa,CACjB,OAAO,CAAC,QAAQ,EAChB,CAAC,4BAA4B,EAAE,qBAAqB,EAAE,QAAQ,EAAE,UAAU,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,CAAC,EACvG,EAAE,OAAO,EAAE,KAAK,EAAE,CACnB,CAAA;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,CAAC,IAAI,CAAC,kDAAmD,GAAa,CAAC,OAAO,wDAAwD,CAAC,CAAA;QAC/I,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { describe, it } from 'node:test'\nimport { strict as assert } from 'assert'\nimport { execFile } from 'node:child_process'\nimport { promisify } from 'node:util'\nimport { fileURLToPath } from 'node:url'\n\nconst execFileAsync = promisify(execFile)\n\ndescribe('http agents', () => {\n it('should not keep a process alive just by being imported', async () => {\n const modulePath = fileURLToPath(new URL('./http-agents.ts', import.meta.url))\n try {\n await execFileAsync(\n process.execPath,\n ['--experimental-strip-types', '--input-type=module', '--eval', `import ${JSON.stringify(modulePath)}`],\n { timeout: 10000 }\n )\n } catch (err) {\n assert.fail(`importing http-agents did not exit on its own (${(err as Error).message}), a module level timer is probably missing an unref()`)\n }\n })\n})\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@data-fair/lib-node",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "Small nodejs related utility functions for the data-fair stack.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"axios": "^1.7.7",
|
|
23
23
|
"cacheable-lookup": "^7.0.0",
|
|
24
24
|
"cookie": "^0.7.2",
|
|
25
|
+
"ipaddr.js": "^2.5.0",
|
|
25
26
|
"jsonwebtoken": "^9.0.2",
|
|
26
27
|
"jwks-rsa": "^3.1.0",
|
|
27
28
|
"semver": "^7.6.3"
|
package/ssrf.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import ipaddr from 'ipaddr.js';
|
|
2
|
+
import HttpAgent, { HttpsAgent } from 'agentkeepalive';
|
|
3
|
+
type IPRange = [ipaddr.IPv4 | ipaddr.IPv6, number];
|
|
4
|
+
export type SsrfRules = {
|
|
5
|
+
publicIPs: IPRange[];
|
|
6
|
+
privateIPs: IPRange[];
|
|
7
|
+
};
|
|
8
|
+
export declare class SsrfError extends Error {
|
|
9
|
+
code: string;
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Parse a comma separated list of IP addresses and CIDR ranges, as found in SSRF_PUBLIC_IPS and SSRF_PRIVATE_IPS
|
|
14
|
+
*/
|
|
15
|
+
export declare const parseIPList: (value: string | undefined, name: string) => IPRange[];
|
|
16
|
+
export declare const getSsrfRules: (env?: NodeJS.ProcessEnv) => SsrfRules;
|
|
17
|
+
/**
|
|
18
|
+
* Check that an address is public, returns an error if it is not
|
|
19
|
+
* hostnames are not checked here, only the addresses they resolve to
|
|
20
|
+
*/
|
|
21
|
+
export declare const checkAddress: (address: string, host: string | undefined, rules: SsrfRules) => SsrfError | undefined;
|
|
22
|
+
export declare class SsrfHttpAgent extends HttpAgent {
|
|
23
|
+
constructor(opts: HttpAgent.HttpOptions, rules: SsrfRules);
|
|
24
|
+
}
|
|
25
|
+
export declare class SsrfHttpsAgent extends HttpsAgent {
|
|
26
|
+
constructor(opts: HttpAgent.HttpsOptions, rules: SsrfRules);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The name of the proxy env variable that disables the SSRF protection, if any
|
|
30
|
+
* behind a proxy our agents connect to the proxy, not to the target, so they cannot check the target
|
|
31
|
+
*/
|
|
32
|
+
export declare const getProxyEnvName: (env?: NodeJS.ProcessEnv) => string | undefined;
|
|
33
|
+
export {};
|
package/ssrf.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// protection against server side request forgery: refuse to connect to non public addresses
|
|
2
|
+
// the address checks are adapted from https://github.com/azu/request-filtering-agent
|
|
3
|
+
// they are applied on top of our keepalive agents instead of node's default agent
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import dns from 'node:dns';
|
|
6
|
+
import ipaddr from 'ipaddr.js';
|
|
7
|
+
import HttpAgent, { HttpsAgent } from 'agentkeepalive';
|
|
8
|
+
export class SsrfError extends Error {
|
|
9
|
+
code = 'ERR_SSRF_BLOCKED';
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'SsrfError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse a comma separated list of IP addresses and CIDR ranges, as found in SSRF_PUBLIC_IPS and SSRF_PRIVATE_IPS
|
|
17
|
+
*/
|
|
18
|
+
export const parseIPList = (value, name) => {
|
|
19
|
+
if (!value)
|
|
20
|
+
return [];
|
|
21
|
+
return value.split(',').map(s => s.trim()).filter(Boolean).map(s => {
|
|
22
|
+
try {
|
|
23
|
+
if (net.isIP(s)) {
|
|
24
|
+
const addr = ipaddr.process(s);
|
|
25
|
+
return [addr, addr.kind() === 'ipv4' ? 32 : 128];
|
|
26
|
+
}
|
|
27
|
+
const [addr, bits] = ipaddr.parseCIDR(s);
|
|
28
|
+
if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
|
|
29
|
+
return [addr.toIPv4Address(), bits - 96];
|
|
30
|
+
}
|
|
31
|
+
return [addr, bits];
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new Error(`invalid IP address or CIDR range "${s}" in ${name}`, { cause: err });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
export const getSsrfRules = (env = process.env) => ({
|
|
39
|
+
publicIPs: parseIPList(env.SSRF_PUBLIC_IPS, 'SSRF_PUBLIC_IPS'),
|
|
40
|
+
privateIPs: parseIPList(env.SSRF_PRIVATE_IPS, 'SSRF_PRIVATE_IPS')
|
|
41
|
+
});
|
|
42
|
+
const matchList = (addr, list) => {
|
|
43
|
+
return list.some(([range, bits]) => {
|
|
44
|
+
if (addr.kind() !== range.kind())
|
|
45
|
+
return false;
|
|
46
|
+
if (addr.kind() === 'ipv4')
|
|
47
|
+
return addr.match(range, bits);
|
|
48
|
+
return addr.match(range, bits);
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Check that an address is public, returns an error if it is not
|
|
53
|
+
* hostnames are not checked here, only the addresses they resolve to
|
|
54
|
+
*/
|
|
55
|
+
export const checkAddress = (address, host, rules) => {
|
|
56
|
+
if (!net.isIP(address))
|
|
57
|
+
return;
|
|
58
|
+
// ipv4 mapped ipv6 addresses (::ffff:127.0.0.1) are checked as the ipv4 address they represent
|
|
59
|
+
const addr = ipaddr.process(address);
|
|
60
|
+
const target = host && host !== address ? `${host} (${addr.toString()})` : addr.toString();
|
|
61
|
+
if (matchList(addr, rules.publicIPs))
|
|
62
|
+
return;
|
|
63
|
+
if (addr.range() !== 'unicast') {
|
|
64
|
+
return new SsrfError(`connection to ${target} refused, it is not a public address (${addr.range()}), if this target is legitimate add it to SSRF_PUBLIC_IPS`);
|
|
65
|
+
}
|
|
66
|
+
if (matchList(addr, rules.privateIPs)) {
|
|
67
|
+
return new SsrfError(`connection to ${target} refused, the address is declared private in SSRF_PRIVATE_IPS`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const guardLookup = (lookup, rules) => {
|
|
71
|
+
return (hostname, options, callback) => {
|
|
72
|
+
lookup(hostname, options, (err, address, family) => {
|
|
73
|
+
if (err)
|
|
74
|
+
return callback(err);
|
|
75
|
+
const addresses = Array.isArray(address) ? address.map(a => a.address) : [address];
|
|
76
|
+
for (const a of addresses) {
|
|
77
|
+
const ssrfError = checkAddress(a, hostname, rules);
|
|
78
|
+
if (ssrfError)
|
|
79
|
+
return callback(ssrfError);
|
|
80
|
+
}
|
|
81
|
+
callback(null, address, family);
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
// the check happens when the connection is created, on the exact address that will be used:
|
|
86
|
+
// IP literals are checked directly (no DNS lookup happens for them),
|
|
87
|
+
// hostnames are checked on every address returned by the lookup (no DNS rebinding),
|
|
88
|
+
// and each redirection creates a new connection that is checked again
|
|
89
|
+
const guardedCreateConnection = (rules, createConnection) => {
|
|
90
|
+
return (options, oncreate) => {
|
|
91
|
+
const host = options.host ?? options.hostname;
|
|
92
|
+
if (host) {
|
|
93
|
+
// brackets of ipv6 literals are removed by node before reaching the agent, but be defensive
|
|
94
|
+
const ssrfError = checkAddress(host.replace(/^\[(.*)\]$/, '$1'), undefined, rules);
|
|
95
|
+
if (ssrfError) {
|
|
96
|
+
oncreate(ssrfError);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return createConnection({ ...options, lookup: guardLookup(options.lookup ?? dns.lookup, rules) }, oncreate);
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
export class SsrfHttpAgent extends HttpAgent {
|
|
104
|
+
constructor(opts, rules) {
|
|
105
|
+
super(opts);
|
|
106
|
+
// @ts-ignore createConnection is not declared in the agentkeepalive typings
|
|
107
|
+
this.createConnection = guardedCreateConnection(rules, super.createConnection.bind(this));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export class SsrfHttpsAgent extends HttpsAgent {
|
|
111
|
+
constructor(opts, rules) {
|
|
112
|
+
super(opts);
|
|
113
|
+
// @ts-ignore createConnection is not declared in the agentkeepalive typings
|
|
114
|
+
this.createConnection = guardedCreateConnection(rules, super.createConnection.bind(this));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const proxyEnvNames = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'];
|
|
118
|
+
/**
|
|
119
|
+
* The name of the proxy env variable that disables the SSRF protection, if any
|
|
120
|
+
* behind a proxy our agents connect to the proxy, not to the target, so they cannot check the target
|
|
121
|
+
*/
|
|
122
|
+
export const getProxyEnvName = (env = process.env) => proxyEnvNames.find(name => !!env[name]);
|
|
123
|
+
//# sourceMappingURL=ssrf.js.map
|
package/ssrf.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrf.js","sourceRoot":"","sources":["ssrf.ts"],"names":[],"mappings":"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","sourcesContent":["// protection against server side request forgery: refuse to connect to non public addresses\n// the address checks are adapted from https://github.com/azu/request-filtering-agent\n// they are applied on top of our keepalive agents instead of node's default agent\n\nimport net from 'node:net'\nimport dns from 'node:dns'\nimport ipaddr from 'ipaddr.js'\nimport HttpAgent, { HttpsAgent } from 'agentkeepalive'\n\ntype IPRange = [ipaddr.IPv4 | ipaddr.IPv6, number]\n\nexport type SsrfRules = {\n publicIPs: IPRange[],\n privateIPs: IPRange[]\n}\n\nexport class SsrfError extends Error {\n code = 'ERR_SSRF_BLOCKED'\n constructor (message: string) {\n super(message)\n this.name = 'SsrfError'\n }\n}\n\n/**\n * Parse a comma separated list of IP addresses and CIDR ranges, as found in SSRF_PUBLIC_IPS and SSRF_PRIVATE_IPS\n */\nexport const parseIPList = (value: string | undefined, name: string): IPRange[] => {\n if (!value) return []\n return value.split(',').map(s => s.trim()).filter(Boolean).map(s => {\n try {\n if (net.isIP(s)) {\n const addr = ipaddr.process(s)\n return [addr, addr.kind() === 'ipv4' ? 32 : 128] as IPRange\n }\n const [addr, bits] = ipaddr.parseCIDR(s)\n if (addr.kind() === 'ipv6' && (addr as ipaddr.IPv6).isIPv4MappedAddress()) {\n return [(addr as ipaddr.IPv6).toIPv4Address(), bits - 96] as IPRange\n }\n return [addr, bits] as IPRange\n } catch (err) {\n throw new Error(`invalid IP address or CIDR range \"${s}\" in ${name}`, { cause: err })\n }\n })\n}\n\nexport const getSsrfRules = (env: NodeJS.ProcessEnv = process.env): SsrfRules => ({\n publicIPs: parseIPList(env.SSRF_PUBLIC_IPS, 'SSRF_PUBLIC_IPS'),\n privateIPs: parseIPList(env.SSRF_PRIVATE_IPS, 'SSRF_PRIVATE_IPS')\n})\n\nconst matchList = (addr: ipaddr.IPv4 | ipaddr.IPv6, list: IPRange[]) => {\n return list.some(([range, bits]) => {\n if (addr.kind() !== range.kind()) return false\n if (addr.kind() === 'ipv4') return (addr as ipaddr.IPv4).match(range as ipaddr.IPv4, bits)\n return (addr as ipaddr.IPv6).match(range as ipaddr.IPv6, bits)\n })\n}\n\n/**\n * Check that an address is public, returns an error if it is not\n * hostnames are not checked here, only the addresses they resolve to\n */\nexport const checkAddress = (address: string, host: string | undefined, rules: SsrfRules): SsrfError | undefined => {\n if (!net.isIP(address)) return\n // ipv4 mapped ipv6 addresses (::ffff:127.0.0.1) are checked as the ipv4 address they represent\n const addr = ipaddr.process(address)\n const target = host && host !== address ? `${host} (${addr.toString()})` : addr.toString()\n if (matchList(addr, rules.publicIPs)) return\n if (addr.range() !== 'unicast') {\n return new SsrfError(`connection to ${target} refused, it is not a public address (${addr.range()}), if this target is legitimate add it to SSRF_PUBLIC_IPS`)\n }\n if (matchList(addr, rules.privateIPs)) {\n return new SsrfError(`connection to ${target} refused, the address is declared private in SSRF_PRIVATE_IPS`)\n }\n}\n\ntype LookupFunction = (hostname: string, options: any, callback: (...args: any[]) => void) => void\n\nconst guardLookup = (lookup: LookupFunction, rules: SsrfRules): LookupFunction => {\n return (hostname, options, callback) => {\n lookup(hostname, options, (err: Error | null, address: string | dns.LookupAddress[], family?: number) => {\n if (err) return callback(err)\n const addresses = Array.isArray(address) ? address.map(a => a.address) : [address]\n for (const a of addresses) {\n const ssrfError = checkAddress(a, hostname, rules)\n if (ssrfError) return callback(ssrfError)\n }\n callback(null, address, family)\n })\n }\n}\n\n// the check happens when the connection is created, on the exact address that will be used:\n// IP literals are checked directly (no DNS lookup happens for them),\n// hostnames are checked on every address returned by the lookup (no DNS rebinding),\n// and each redirection creates a new connection that is checked again\nconst guardedCreateConnection = (rules: SsrfRules, createConnection: (options: any, oncreate: any) => any) => {\n return (options: any, oncreate: (err: Error | null, socket?: net.Socket) => void) => {\n const host = options.host ?? options.hostname\n if (host) {\n // brackets of ipv6 literals are removed by node before reaching the agent, but be defensive\n const ssrfError = checkAddress(host.replace(/^\\[(.*)\\]$/, '$1'), undefined, rules)\n if (ssrfError) {\n oncreate(ssrfError)\n return\n }\n }\n return createConnection({ ...options, lookup: guardLookup(options.lookup ?? dns.lookup, rules) }, oncreate)\n }\n}\n\nexport class SsrfHttpAgent extends HttpAgent {\n constructor (opts: HttpAgent.HttpOptions, rules: SsrfRules) {\n super(opts)\n // @ts-ignore createConnection is not declared in the agentkeepalive typings\n this.createConnection = guardedCreateConnection(rules, super.createConnection.bind(this))\n }\n}\n\nexport class SsrfHttpsAgent extends HttpsAgent {\n constructor (opts: HttpAgent.HttpsOptions, rules: SsrfRules) {\n super(opts)\n // @ts-ignore createConnection is not declared in the agentkeepalive typings\n this.createConnection = guardedCreateConnection(rules, super.createConnection.bind(this))\n }\n}\n\nconst proxyEnvNames = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy']\n\n/**\n * The name of the proxy env variable that disables the SSRF protection, if any\n * behind a proxy our agents connect to the proxy, not to the target, so they cannot check the target\n */\nexport const getProxyEnvName = (env: NodeJS.ProcessEnv = process.env) => proxyEnvNames.find(name => !!env[name])\n"]}
|
package/ssrf.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/ssrf.test.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'assert';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
import CacheableLookup from 'cacheable-lookup';
|
|
9
|
+
import { checkAddress, parseIPList, getSsrfRules, getProxyEnvName, SsrfHttpAgent, SsrfHttpsAgent } from './ssrf.js';
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const noRules = { publicIPs: [], privateIPs: [] };
|
|
12
|
+
describe('ssrf address checks', () => {
|
|
13
|
+
it('should refuse non public addresses', () => {
|
|
14
|
+
for (const address of [
|
|
15
|
+
'127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '100.64.0.1', '169.254.169.254', '0.0.0.0',
|
|
16
|
+
'255.255.255.255', '224.0.0.1', '::1', '::', 'fd00::1', 'fe80::1', '::ffff:127.0.0.1', '::ffff:169.254.169.254',
|
|
17
|
+
'64:ff9b::a9fe:a9fe', '2002:a9fe:a9fe::1'
|
|
18
|
+
]) {
|
|
19
|
+
assert.ok(checkAddress(address, undefined, noRules), `${address} should be refused`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it('should accept public addresses and ignore hostnames', () => {
|
|
23
|
+
for (const address of ['8.8.8.8', '141.95.149.122', '2001:4860:4860::8888', '::ffff:8.8.8.8', 'koumoul.com']) {
|
|
24
|
+
assert.equal(checkAddress(address, undefined, noRules), undefined, `${address} should be accepted`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('should extend public and private addresses from env variables', () => {
|
|
28
|
+
const rules = getSsrfRules({ SSRF_PUBLIC_IPS: '10.0.0.0/8, 192.168.1.1', SSRF_PRIVATE_IPS: '141.95.149.120/30,2001:4860:4860::8888' });
|
|
29
|
+
assert.equal(checkAddress('10.1.2.3', undefined, rules), undefined);
|
|
30
|
+
assert.equal(checkAddress('::ffff:10.1.2.3', undefined, rules), undefined);
|
|
31
|
+
assert.equal(checkAddress('192.168.1.1', undefined, rules), undefined);
|
|
32
|
+
assert.ok(checkAddress('192.168.1.2', undefined, rules));
|
|
33
|
+
assert.ok(checkAddress('141.95.149.122', undefined, rules)?.message.includes('SSRF_PRIVATE_IPS'));
|
|
34
|
+
assert.equal(checkAddress('141.95.149.124', undefined, rules), undefined);
|
|
35
|
+
assert.ok(checkAddress('2001:4860:4860::8888', undefined, rules));
|
|
36
|
+
});
|
|
37
|
+
it('should name the env variable to change in the error message', () => {
|
|
38
|
+
const err = checkAddress('10.1.2.3', 'internal.example.com', noRules);
|
|
39
|
+
assert.ok(err?.message.includes('internal.example.com (10.1.2.3)'));
|
|
40
|
+
assert.ok(err?.message.includes('SSRF_PUBLIC_IPS'));
|
|
41
|
+
assert.equal(err?.code, 'ERR_SSRF_BLOCKED');
|
|
42
|
+
});
|
|
43
|
+
it('should reject invalid lists', () => {
|
|
44
|
+
assert.throws(() => parseIPList('10.0.0.0/8,not-an-ip', 'SSRF_PUBLIC_IPS'), /not-an-ip.*SSRF_PUBLIC_IPS/);
|
|
45
|
+
assert.deepEqual(parseIPList(' , ', 'SSRF_PUBLIC_IPS'), []);
|
|
46
|
+
});
|
|
47
|
+
it('should detect proxy env variables', () => {
|
|
48
|
+
assert.equal(getProxyEnvName({}), undefined);
|
|
49
|
+
assert.equal(getProxyEnvName({ https_proxy: 'http://proxy:3128' }), 'https_proxy');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('ssrf agents', () => {
|
|
53
|
+
let server;
|
|
54
|
+
let port;
|
|
55
|
+
before(async () => {
|
|
56
|
+
server = http.createServer((req, res) => {
|
|
57
|
+
if (req.url === '/redirect') {
|
|
58
|
+
res.writeHead(302, { location: `http://127.0.0.2:${port}/` });
|
|
59
|
+
return res.end();
|
|
60
|
+
}
|
|
61
|
+
res.end('ok');
|
|
62
|
+
});
|
|
63
|
+
await new Promise(resolve => server.listen(0, '0.0.0.0', resolve));
|
|
64
|
+
port = server.address().port;
|
|
65
|
+
});
|
|
66
|
+
after(() => { server.close(); });
|
|
67
|
+
// same setup as http-agents.ts
|
|
68
|
+
const buildAgents = (rules) => {
|
|
69
|
+
const httpAgent = new SsrfHttpAgent({ keepAlive: true }, rules);
|
|
70
|
+
const httpsAgent = new SsrfHttpsAgent({ keepAlive: true }, rules);
|
|
71
|
+
const cacheableLookup = new CacheableLookup();
|
|
72
|
+
cacheableLookup.install(httpAgent);
|
|
73
|
+
cacheableLookup.install(httpsAgent);
|
|
74
|
+
return { httpAgent, httpsAgent, proxy: false };
|
|
75
|
+
};
|
|
76
|
+
it('should refuse IP literals before connecting', async () => {
|
|
77
|
+
await assert.rejects(axios.get(`http://127.0.0.1:${port}/`, buildAgents(noRules)), { code: 'ERR_SSRF_BLOCKED' });
|
|
78
|
+
await assert.rejects(axios.get('https://169.254.169.254/', buildAgents(noRules)), { code: 'ERR_SSRF_BLOCKED' });
|
|
79
|
+
});
|
|
80
|
+
it('should refuse hostnames resolving to non public addresses', async () => {
|
|
81
|
+
await assert.rejects(axios.get(`http://localhost:${port}/`, buildAgents(noRules)), { code: 'ERR_SSRF_BLOCKED' });
|
|
82
|
+
});
|
|
83
|
+
it('should accept addresses declared public', async () => {
|
|
84
|
+
const agents = buildAgents(getSsrfRules({ SSRF_PUBLIC_IPS: '127.0.0.1,::1' }));
|
|
85
|
+
assert.equal((await axios.get(`http://127.0.0.1:${port}/`, agents)).data, 'ok');
|
|
86
|
+
assert.equal((await axios.get(`http://localhost:${port}/`, agents)).data, 'ok');
|
|
87
|
+
});
|
|
88
|
+
it('should check every redirection', async () => {
|
|
89
|
+
const agents = buildAgents(getSsrfRules({ SSRF_PUBLIC_IPS: '127.0.0.1' }));
|
|
90
|
+
await assert.rejects(axios.get(`http://127.0.0.1:${port}/redirect`, agents), { code: 'ERR_SSRF_BLOCKED' });
|
|
91
|
+
const allAgents = buildAgents(getSsrfRules({ SSRF_PUBLIC_IPS: '127.0.0.0/8' }));
|
|
92
|
+
assert.equal((await axios.get(`http://127.0.0.1:${port}/redirect`, allAgents)).data, 'ok');
|
|
93
|
+
});
|
|
94
|
+
it('should guard the default lib instance, not the private one', async () => {
|
|
95
|
+
const script = `
|
|
96
|
+
import { axiosInstance, privateAxiosInstance } from ${JSON.stringify(fileURLToPath(new URL('./axios.ts', import.meta.url)))}
|
|
97
|
+
const url = 'http://localhost:${port}/'
|
|
98
|
+
try { await axiosInstance.get(url); console.log('public:allowed') } catch (err) { console.log('public:' + err.code) }
|
|
99
|
+
console.log('private:' + (await privateAxiosInstance.get(url)).data)
|
|
100
|
+
`;
|
|
101
|
+
const env = { ...process.env, SSRF_PUBLIC_IPS: '', HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', http_proxy: '', https_proxy: '', all_proxy: '' };
|
|
102
|
+
const { stdout } = await execFileAsync(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', script], { env, timeout: 10000 });
|
|
103
|
+
assert.match(stdout, /public:ERR_SSRF_BLOCKED/);
|
|
104
|
+
assert.match(stdout, /private:ok/);
|
|
105
|
+
});
|
|
106
|
+
it('should disable the protection with a warning behind a proxy', async () => {
|
|
107
|
+
const script = `import ${JSON.stringify(fileURLToPath(new URL('./http-agents.ts', import.meta.url)))}`;
|
|
108
|
+
const env = { ...process.env, HTTPS_PROXY: 'http://proxy.example.com:3128' };
|
|
109
|
+
const { stderr } = await execFileAsync(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', script], { env, timeout: 10000 });
|
|
110
|
+
assert.match(stderr, /WARNING: HTTPS_PROXY is defined, the protection against server side request forgery \(SSRF\) is disabled/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
//# sourceMappingURL=ssrf.test.js.map
|
package/ssrf.test.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrf.test.js","sourceRoot":"","sources":["ssrf.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, before, after } from 'node:test'\nimport { strict as assert } from 'assert'\nimport http from 'node:http'\nimport type { AddressInfo } from 'node:net'\nimport { execFile } from 'node:child_process'\nimport { promisify } from 'node:util'\nimport { fileURLToPath } from 'node:url'\nimport axios from 'axios'\nimport CacheableLookup from 'cacheable-lookup'\nimport { checkAddress, parseIPList, getSsrfRules, getProxyEnvName, SsrfHttpAgent, SsrfHttpsAgent, type SsrfRules } from './ssrf.js'\n\nconst execFileAsync = promisify(execFile)\n\nconst noRules: SsrfRules = { publicIPs: [], privateIPs: [] }\n\ndescribe('ssrf address checks', () => {\n it('should refuse non public addresses', () => {\n for (const address of [\n '127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '100.64.0.1', '169.254.169.254', '0.0.0.0',\n '255.255.255.255', '224.0.0.1', '::1', '::', 'fd00::1', 'fe80::1', '::ffff:127.0.0.1', '::ffff:169.254.169.254',\n '64:ff9b::a9fe:a9fe', '2002:a9fe:a9fe::1'\n ]) {\n assert.ok(checkAddress(address, undefined, noRules), `${address} should be refused`)\n }\n })\n\n it('should accept public addresses and ignore hostnames', () => {\n for (const address of ['8.8.8.8', '141.95.149.122', '2001:4860:4860::8888', '::ffff:8.8.8.8', 'koumoul.com']) {\n assert.equal(checkAddress(address, undefined, noRules), undefined, `${address} should be accepted`)\n }\n })\n\n it('should extend public and private addresses from env variables', () => {\n const rules = getSsrfRules({ SSRF_PUBLIC_IPS: '10.0.0.0/8, 192.168.1.1', SSRF_PRIVATE_IPS: '141.95.149.120/30,2001:4860:4860::8888' })\n assert.equal(checkAddress('10.1.2.3', undefined, rules), undefined)\n assert.equal(checkAddress('::ffff:10.1.2.3', undefined, rules), undefined)\n assert.equal(checkAddress('192.168.1.1', undefined, rules), undefined)\n assert.ok(checkAddress('192.168.1.2', undefined, rules))\n assert.ok(checkAddress('141.95.149.122', undefined, rules)?.message.includes('SSRF_PRIVATE_IPS'))\n assert.equal(checkAddress('141.95.149.124', undefined, rules), undefined)\n assert.ok(checkAddress('2001:4860:4860::8888', undefined, rules))\n })\n\n it('should name the env variable to change in the error message', () => {\n const err = checkAddress('10.1.2.3', 'internal.example.com', noRules)\n assert.ok(err?.message.includes('internal.example.com (10.1.2.3)'))\n assert.ok(err?.message.includes('SSRF_PUBLIC_IPS'))\n assert.equal(err?.code, 'ERR_SSRF_BLOCKED')\n })\n\n it('should reject invalid lists', () => {\n assert.throws(() => parseIPList('10.0.0.0/8,not-an-ip', 'SSRF_PUBLIC_IPS'), /not-an-ip.*SSRF_PUBLIC_IPS/)\n assert.deepEqual(parseIPList(' , ', 'SSRF_PUBLIC_IPS'), [])\n })\n\n it('should detect proxy env variables', () => {\n assert.equal(getProxyEnvName({}), undefined)\n assert.equal(getProxyEnvName({ https_proxy: 'http://proxy:3128' }), 'https_proxy')\n })\n})\n\ndescribe('ssrf agents', () => {\n let server: http.Server\n let port: number\n\n before(async () => {\n server = http.createServer((req, res) => {\n if (req.url === '/redirect') {\n res.writeHead(302, { location: `http://127.0.0.2:${port}/` })\n return res.end()\n }\n res.end('ok')\n })\n await new Promise<void>(resolve => server.listen(0, '0.0.0.0', resolve))\n port = (server.address() as AddressInfo).port\n })\n\n after(() => { server.close() })\n\n // same setup as http-agents.ts\n const buildAgents = (rules: SsrfRules) => {\n const httpAgent = new SsrfHttpAgent({ keepAlive: true }, rules)\n const httpsAgent = new SsrfHttpsAgent({ keepAlive: true }, rules)\n const cacheableLookup = new CacheableLookup()\n cacheableLookup.install(httpAgent)\n cacheableLookup.install(httpsAgent)\n return { httpAgent, httpsAgent, proxy: false as const }\n }\n\n it('should refuse IP literals before connecting', async () => {\n await assert.rejects(axios.get(`http://127.0.0.1:${port}/`, buildAgents(noRules)), { code: 'ERR_SSRF_BLOCKED' })\n await assert.rejects(axios.get('https://169.254.169.254/', buildAgents(noRules)), { code: 'ERR_SSRF_BLOCKED' })\n })\n\n it('should refuse hostnames resolving to non public addresses', async () => {\n await assert.rejects(axios.get(`http://localhost:${port}/`, buildAgents(noRules)), { code: 'ERR_SSRF_BLOCKED' })\n })\n\n it('should accept addresses declared public', async () => {\n const agents = buildAgents(getSsrfRules({ SSRF_PUBLIC_IPS: '127.0.0.1,::1' }))\n assert.equal((await axios.get(`http://127.0.0.1:${port}/`, agents)).data, 'ok')\n assert.equal((await axios.get(`http://localhost:${port}/`, agents)).data, 'ok')\n })\n\n it('should check every redirection', async () => {\n const agents = buildAgents(getSsrfRules({ SSRF_PUBLIC_IPS: '127.0.0.1' }))\n await assert.rejects(axios.get(`http://127.0.0.1:${port}/redirect`, agents), { code: 'ERR_SSRF_BLOCKED' })\n const allAgents = buildAgents(getSsrfRules({ SSRF_PUBLIC_IPS: '127.0.0.0/8' }))\n assert.equal((await axios.get(`http://127.0.0.1:${port}/redirect`, allAgents)).data, 'ok')\n })\n\n it('should guard the default lib instance, not the private one', async () => {\n const script = `\n import { axiosInstance, privateAxiosInstance } from ${JSON.stringify(fileURLToPath(new URL('./axios.ts', import.meta.url)))}\n const url = 'http://localhost:${port}/'\n try { await axiosInstance.get(url); console.log('public:allowed') } catch (err) { console.log('public:' + err.code) }\n console.log('private:' + (await privateAxiosInstance.get(url)).data)\n `\n const env = { ...process.env, SSRF_PUBLIC_IPS: '', HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', http_proxy: '', https_proxy: '', all_proxy: '' }\n const { stdout } = await execFileAsync(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', script], { env, timeout: 10000 })\n assert.match(stdout, /public:ERR_SSRF_BLOCKED/)\n assert.match(stdout, /private:ok/)\n })\n\n it('should disable the protection with a warning behind a proxy', async () => {\n const script = `import ${JSON.stringify(fileURLToPath(new URL('./http-agents.ts', import.meta.url)))}`\n const env = { ...process.env, HTTPS_PROXY: 'http://proxy.example.com:3128' }\n const { stderr } = await execFileAsync(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', script], { env, timeout: 10000 })\n assert.match(stderr, /WARNING: HTTPS_PROXY is defined, the protection against server side request forgery \\(SSRF\\) is disabled/)\n })\n})\n"]}
|
package/ws-client.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type FullWsClientOpts = WsClientOpts & Required<Pick<WsClientOpts, 'log'>
|
|
|
21
21
|
export declare class WsClient extends EventEmitter {
|
|
22
22
|
private _channels;
|
|
23
23
|
private _ws;
|
|
24
|
+
private _closed;
|
|
24
25
|
opts: FullWsClientOpts;
|
|
25
26
|
constructor(opts: WsClientOpts);
|
|
26
27
|
private _connect;
|
package/ws-client.js
CHANGED
|
@@ -14,6 +14,7 @@ class WsClientError extends Error {
|
|
|
14
14
|
export class WsClient extends EventEmitter {
|
|
15
15
|
_channels;
|
|
16
16
|
_ws;
|
|
17
|
+
_closed = false;
|
|
17
18
|
opts;
|
|
18
19
|
constructor(opts) {
|
|
19
20
|
super();
|
|
@@ -28,9 +29,13 @@ export class WsClient extends EventEmitter {
|
|
|
28
29
|
this._ws = ws;
|
|
29
30
|
ws.on('error', (err) => {
|
|
30
31
|
debug('WS encountered an error', err.message);
|
|
31
|
-
this._reconnect();
|
|
32
32
|
reject(err);
|
|
33
33
|
});
|
|
34
|
+
// emitted after an error too, and when the server closes the connection
|
|
35
|
+
ws.on('close', () => {
|
|
36
|
+
if (this._ws === ws)
|
|
37
|
+
this._reconnect();
|
|
38
|
+
});
|
|
34
39
|
ws.once('open', () => {
|
|
35
40
|
debug('WS is opened');
|
|
36
41
|
resolve(ws);
|
|
@@ -43,13 +48,21 @@ export class WsClient extends EventEmitter {
|
|
|
43
48
|
});
|
|
44
49
|
}
|
|
45
50
|
async _reconnect() {
|
|
46
|
-
if (
|
|
51
|
+
if (this._closed)
|
|
52
|
+
return;
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
54
|
+
if (this._closed)
|
|
47
55
|
return;
|
|
48
56
|
debug('reconnect');
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
try {
|
|
58
|
+
await this._connect();
|
|
59
|
+
for (const channel of this._channels) {
|
|
60
|
+
await this.subscribe(channel, true);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
// a failed connection is closed and schedules its own reconnection
|
|
65
|
+
debug('WS failed to reconnect', err.message);
|
|
53
66
|
}
|
|
54
67
|
}
|
|
55
68
|
async subscribe(channel, force = false, timeout = 2000) {
|
|
@@ -77,7 +90,7 @@ export class WsClient extends EventEmitter {
|
|
|
77
90
|
error.stack += '\nSubscribe context:\n' + errorContext.stack;
|
|
78
91
|
throw error;
|
|
79
92
|
}
|
|
80
|
-
if (this._channels.includes(channel))
|
|
93
|
+
if (!this._channels.includes(channel))
|
|
81
94
|
this._channels.push(channel);
|
|
82
95
|
}
|
|
83
96
|
async waitFor(channel, filter, timeout = 300000, skipSubscribe = false, fullMessage = false, _errorContext) {
|
|
@@ -103,6 +116,9 @@ export class WsClient extends EventEmitter {
|
|
|
103
116
|
reject(error);
|
|
104
117
|
}, timeout);
|
|
105
118
|
const messageCb = (message) => {
|
|
119
|
+
// subscribe confirmations carry no data, ex: when channels are re-subscribed after a reconnection
|
|
120
|
+
if (!fullMessage && message.type !== 'message')
|
|
121
|
+
return;
|
|
106
122
|
if (message.channel === channel && (!filter || filter(fullMessage ? message : message.data))) {
|
|
107
123
|
clearTimeout(_timeout);
|
|
108
124
|
this.off('message', messageCb);
|
|
@@ -113,6 +129,7 @@ export class WsClient extends EventEmitter {
|
|
|
113
129
|
});
|
|
114
130
|
}
|
|
115
131
|
close() {
|
|
132
|
+
this._closed = true;
|
|
116
133
|
if (this._ws)
|
|
117
134
|
this._ws.terminate();
|
|
118
135
|
}
|
package/ws-client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws-client.js","sourceRoot":"","sources":["ws-client.ts"],"names":[],"mappings":"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","sourcesContent":["// a wrapper around a websocket connection with auto reconnect\n// JSON parsing and channel subscriptions\n\nimport { type Message } from '@data-fair/lib-common-types/ws.js'\nimport { type Account } from '@data-fair/lib-common-types/session/index.js'\nimport EventEmitter from 'node:events'\nimport WebSocket from 'ws'\nimport Debug from 'debug'\n\nconst debug = Debug('ws')\n\nconst { stackTraceLimit } = Error\n\nclass WsClientError extends Error {\n constructor () {\n super()\n this.name = 'WsClientError'\n }\n}\n\ntype logFn = (msg: string, ...args: any[]) => void\n\nexport interface WsClientOpts {\n log?: { info: logFn, error: logFn, debug: logFn }\n url: string\n headers?: Record<string, string>\n apiKey?: string\n adminMode?: boolean\n account?: Account\n}\n\nexport type FullWsClientOpts = WsClientOpts & Required<Pick<WsClientOpts, 'log'>>\n\nexport class WsClient extends EventEmitter {\n private _channels: string[]\n private _ws: WebSocket | undefined\n opts: FullWsClientOpts\n\n constructor (opts: WsClientOpts) {\n super()\n this._channels = []\n this.opts = { log: console, ...opts }\n }\n\n private async _connect (): Promise<WebSocket> {\n return await new Promise((resolve, reject) => {\n const wsUrl = this.opts.url.replace('http://', 'ws://').replace('https://', 'wss://') + '/'\n debug(`connect Web Socket to ${wsUrl}`)\n const ws = new WebSocket(wsUrl, { headers: this.opts.headers ?? {} })\n this._ws = ws\n ws.on('error', (err: any) => {\n debug('WS encountered an error', err.message)\n this._reconnect()\n reject(err)\n })\n ws.once('open', () => {\n debug('WS is opened')\n resolve(ws)\n })\n ws.on('message', (msg: string) => {\n const message = JSON.parse(msg) as Message\n debug('received message', message)\n this.emit('message', message)\n })\n })\n }\n\n private async _reconnect () {\n if (!this._ws) return\n debug('reconnect')\n this._ws.terminate()\n await this._connect()\n for (const channel of this._channels) {\n await this.subscribe(channel, true)\n }\n }\n\n async subscribe (channel: string, force = false, timeout = 2000) {\n if (this._channels.includes(channel) && !force) return\n const ws = this._ws ?? await this._connect()\n\n // better stack traces\n // see https://github.com/axios/axios/issues/2387#issuecomment-652242713\n Error.stackTraceLimit = 0\n const errorContext = new WsClientError()\n Error.stackTraceLimit = stackTraceLimit\n Error.captureStackTrace(errorContext, this.subscribe)\n\n debug('subscribe to channel', channel)\n const subscribeMessage: any = { type: 'subscribe', channel }\n if (this.opts.apiKey) subscribeMessage.apiKey = this.opts.apiKey\n if (this.opts.adminMode && this.opts.account) subscribeMessage.account = JSON.stringify(this.opts.account)\n ws.send(JSON.stringify(subscribeMessage))\n const event = await this.waitFor(\n channel,\n (e: Message) => {\n return e.type === 'subscribe-confirm' || e.type === 'error'\n },\n timeout,\n true,\n true,\n errorContext\n )\n if (event.type === 'error') {\n const error = new Error(event.data)\n error.stack += '\\nSubscribe context:\\n' + errorContext.stack\n throw error\n }\n if (this._channels.includes(channel)) this._channels.push(channel)\n }\n\n async waitFor (channel: string, filter?: (message: Message) => boolean, timeout = 300000, skipSubscribe = false, fullMessage = false, _errorContext?: WsClientError): Promise<Message> {\n if (!skipSubscribe) await this.subscribe(channel)\n\n // better stack traces\n // see https://github.com/axios/axios/issues/2387#issuecomment-652242713\n let errorContext: WsClientError\n if (_errorContext) {\n errorContext = _errorContext\n } else {\n Error.stackTraceLimit = 0\n errorContext = new WsClientError()\n Error.stackTraceLimit = stackTraceLimit\n Error.captureStackTrace(errorContext, this.waitFor)\n }\n\n return await new Promise((resolve, reject) => {\n const _timeout = setTimeout(() => {\n this.off('message', messageCb)\n const error = new Error(`Timeout of ${timeout}ms exceeded`)\n error.stack += '\\nWaitFor context:\\n' + errorContext.stack\n reject(error)\n }, timeout)\n const messageCb = (message: any) => {\n if (message.channel === channel && (!filter || filter(fullMessage ? message : message.data))) {\n clearTimeout(_timeout)\n this.off('message', messageCb)\n resolve(fullMessage ? message : message.data)\n }\n }\n this.on('message', messageCb)\n })\n }\n\n close () {\n if (this._ws) this._ws.terminate()\n }\n}\n\nexport class DataFairWsClient extends WsClient {\n async waitForJournal (datasetId: string, eventType: string, timeout = 300000) {\n await this.opts.log.info(`wait for event \"${eventType}\" on dataset \"${datasetId}\"`)\n\n // better stack traces\n // see https://github.com/axios/axios/issues/2387#issuecomment-652242713\n Error.stackTraceLimit = 0\n const errorContext = new WsClientError()\n Error.stackTraceLimit = stackTraceLimit\n Error.captureStackTrace(errorContext, this.waitForJournal)\n\n const event = await this.waitFor(\n `datasets/${datasetId}/journal`,\n (e) => {\n return e.type === eventType || e.type === 'error'\n },\n timeout,\n false,\n false,\n errorContext\n )\n if (event.type === 'error') {\n const error = new Error(event.data)\n error.stack += '\\nWaitForJournal context:\\n' + errorContext.stack\n throw error\n }\n return event\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ws-client.js","sourceRoot":"","sources":["ws-client.ts"],"names":[],"mappings":"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","sourcesContent":["// a wrapper around a websocket connection with auto reconnect\n// JSON parsing and channel subscriptions\n\nimport { type Message } from '@data-fair/lib-common-types/ws.js'\nimport { type Account } from '@data-fair/lib-common-types/session/index.js'\nimport EventEmitter from 'node:events'\nimport WebSocket from 'ws'\nimport Debug from 'debug'\n\nconst debug = Debug('ws')\n\nconst { stackTraceLimit } = Error\n\nclass WsClientError extends Error {\n constructor () {\n super()\n this.name = 'WsClientError'\n }\n}\n\ntype logFn = (msg: string, ...args: any[]) => void\n\nexport interface WsClientOpts {\n log?: { info: logFn, error: logFn, debug: logFn }\n url: string\n headers?: Record<string, string>\n apiKey?: string\n adminMode?: boolean\n account?: Account\n}\n\nexport type FullWsClientOpts = WsClientOpts & Required<Pick<WsClientOpts, 'log'>>\n\nexport class WsClient extends EventEmitter {\n private _channels: string[]\n private _ws: WebSocket | undefined\n private _closed = false\n opts: FullWsClientOpts\n\n constructor (opts: WsClientOpts) {\n super()\n this._channels = []\n this.opts = { log: console, ...opts }\n }\n\n private async _connect (): Promise<WebSocket> {\n return await new Promise((resolve, reject) => {\n const wsUrl = this.opts.url.replace('http://', 'ws://').replace('https://', 'wss://') + '/'\n debug(`connect Web Socket to ${wsUrl}`)\n const ws = new WebSocket(wsUrl, { headers: this.opts.headers ?? {} })\n this._ws = ws\n ws.on('error', (err: any) => {\n debug('WS encountered an error', err.message)\n reject(err)\n })\n // emitted after an error too, and when the server closes the connection\n ws.on('close', () => {\n if (this._ws === ws) this._reconnect()\n })\n ws.once('open', () => {\n debug('WS is opened')\n resolve(ws)\n })\n ws.on('message', (msg: string) => {\n const message = JSON.parse(msg) as Message\n debug('received message', message)\n this.emit('message', message)\n })\n })\n }\n\n private async _reconnect () {\n if (this._closed) return\n await new Promise(resolve => setTimeout(resolve, 1000))\n if (this._closed) return\n debug('reconnect')\n try {\n await this._connect()\n for (const channel of this._channels) {\n await this.subscribe(channel, true)\n }\n } catch (err: any) {\n // a failed connection is closed and schedules its own reconnection\n debug('WS failed to reconnect', err.message)\n }\n }\n\n async subscribe (channel: string, force = false, timeout = 2000) {\n if (this._channels.includes(channel) && !force) return\n const ws = this._ws ?? await this._connect()\n\n // better stack traces\n // see https://github.com/axios/axios/issues/2387#issuecomment-652242713\n Error.stackTraceLimit = 0\n const errorContext = new WsClientError()\n Error.stackTraceLimit = stackTraceLimit\n Error.captureStackTrace(errorContext, this.subscribe)\n\n debug('subscribe to channel', channel)\n const subscribeMessage: any = { type: 'subscribe', channel }\n if (this.opts.apiKey) subscribeMessage.apiKey = this.opts.apiKey\n if (this.opts.adminMode && this.opts.account) subscribeMessage.account = JSON.stringify(this.opts.account)\n ws.send(JSON.stringify(subscribeMessage))\n const event = await this.waitFor(\n channel,\n (e: Message) => {\n return e.type === 'subscribe-confirm' || e.type === 'error'\n },\n timeout,\n true,\n true,\n errorContext\n )\n if (event.type === 'error') {\n const error = new Error(event.data)\n error.stack += '\\nSubscribe context:\\n' + errorContext.stack\n throw error\n }\n if (!this._channels.includes(channel)) this._channels.push(channel)\n }\n\n async waitFor (channel: string, filter?: (message: Message) => boolean, timeout = 300000, skipSubscribe = false, fullMessage = false, _errorContext?: WsClientError): Promise<Message> {\n if (!skipSubscribe) await this.subscribe(channel)\n\n // better stack traces\n // see https://github.com/axios/axios/issues/2387#issuecomment-652242713\n let errorContext: WsClientError\n if (_errorContext) {\n errorContext = _errorContext\n } else {\n Error.stackTraceLimit = 0\n errorContext = new WsClientError()\n Error.stackTraceLimit = stackTraceLimit\n Error.captureStackTrace(errorContext, this.waitFor)\n }\n\n return await new Promise((resolve, reject) => {\n const _timeout = setTimeout(() => {\n this.off('message', messageCb)\n const error = new Error(`Timeout of ${timeout}ms exceeded`)\n error.stack += '\\nWaitFor context:\\n' + errorContext.stack\n reject(error)\n }, timeout)\n const messageCb = (message: any) => {\n // subscribe confirmations carry no data, ex: when channels are re-subscribed after a reconnection\n if (!fullMessage && message.type !== 'message') return\n if (message.channel === channel && (!filter || filter(fullMessage ? message : message.data))) {\n clearTimeout(_timeout)\n this.off('message', messageCb)\n resolve(fullMessage ? message : message.data)\n }\n }\n this.on('message', messageCb)\n })\n }\n\n close () {\n this._closed = true\n if (this._ws) this._ws.terminate()\n }\n}\n\nexport class DataFairWsClient extends WsClient {\n async waitForJournal (datasetId: string, eventType: string, timeout = 300000) {\n await this.opts.log.info(`wait for event \"${eventType}\" on dataset \"${datasetId}\"`)\n\n // better stack traces\n // see https://github.com/axios/axios/issues/2387#issuecomment-652242713\n Error.stackTraceLimit = 0\n const errorContext = new WsClientError()\n Error.stackTraceLimit = stackTraceLimit\n Error.captureStackTrace(errorContext, this.waitForJournal)\n\n const event = await this.waitFor(\n `datasets/${datasetId}/journal`,\n (e) => {\n return e.type === eventType || e.type === 'error'\n },\n timeout,\n false,\n false,\n errorContext\n )\n if (event.type === 'error') {\n const error = new Error(event.data)\n error.stack += '\\nWaitForJournal context:\\n' + errorContext.stack\n throw error\n }\n return event\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'assert';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { WebSocketServer } from 'ws';
|
|
5
|
+
import { WsClient } from './ws-client.js';
|
|
6
|
+
// minimal server following the protocol of @data-fair/lib-express/ws-server
|
|
7
|
+
const startServer = async () => {
|
|
8
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
9
|
+
await once(wss, 'listening');
|
|
10
|
+
const subscriptions = new Map();
|
|
11
|
+
wss.on('connection', (ws) => {
|
|
12
|
+
subscriptions.set(ws, new Set());
|
|
13
|
+
ws.on('message', (str) => {
|
|
14
|
+
const message = JSON.parse(str.toString());
|
|
15
|
+
subscriptions.get(ws).add(message.channel);
|
|
16
|
+
ws.send(JSON.stringify({ type: 'subscribe-confirm', channel: message.channel }));
|
|
17
|
+
});
|
|
18
|
+
ws.on('close', () => subscriptions.delete(ws));
|
|
19
|
+
});
|
|
20
|
+
const publish = (channel, data) => {
|
|
21
|
+
for (const [ws, channels] of subscriptions) {
|
|
22
|
+
if (channels.has(channel))
|
|
23
|
+
ws.send(JSON.stringify({ type: 'message', channel, data }));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
return { wss, subscriptions, publish, url: `http://localhost:${wss.address().port}` };
|
|
27
|
+
};
|
|
28
|
+
describe('ws client', () => {
|
|
29
|
+
const disconnections = {
|
|
30
|
+
// the client receives an invalid frame and emits an error
|
|
31
|
+
'a connection error': (serverWs) => {
|
|
32
|
+
// @ts-ignore
|
|
33
|
+
serverWs._socket.write(Buffer.from([0xff, 0x00]));
|
|
34
|
+
},
|
|
35
|
+
// for example when the server restarts
|
|
36
|
+
'a connection closed by the server': (serverWs) => serverWs.terminate()
|
|
37
|
+
};
|
|
38
|
+
for (const [name, disconnect] of Object.entries(disconnections)) {
|
|
39
|
+
it(`should re-subscribe to its channels after ${name}`, { timeout: 5000 }, async () => {
|
|
40
|
+
const server = await startServer();
|
|
41
|
+
const client = new WsClient({ url: server.url });
|
|
42
|
+
try {
|
|
43
|
+
await client.subscribe('channel1');
|
|
44
|
+
const [serverWs] = server.subscriptions.keys();
|
|
45
|
+
// pending during the disconnection, like a processing waiting for a journal event
|
|
46
|
+
const received = client.waitFor('channel1', (data) => data.value === 'hello', 4000);
|
|
47
|
+
const reconnected = once(server.wss, 'connection');
|
|
48
|
+
disconnect(serverWs);
|
|
49
|
+
await reconnected;
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
51
|
+
const [newServerWs] = server.subscriptions.keys();
|
|
52
|
+
assert.notEqual(newServerWs, serverWs);
|
|
53
|
+
assert.deepEqual([...server.subscriptions.get(newServerWs)], ['channel1']);
|
|
54
|
+
server.publish('channel1', { value: 'hello' });
|
|
55
|
+
assert.deepEqual(await received, { value: 'hello' });
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
client.close();
|
|
59
|
+
server.wss.close();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
it('should retry to connect at a limited pace and stop when closed', { timeout: 5000 }, async () => {
|
|
64
|
+
const unhandledRejections = [];
|
|
65
|
+
const onUnhandledRejection = (err) => unhandledRejections.push(err);
|
|
66
|
+
process.on('unhandledRejection', onUnhandledRejection);
|
|
67
|
+
const client = new WsClient({ url: 'http://localhost:1' });
|
|
68
|
+
let nbConnect = 0;
|
|
69
|
+
// @ts-ignore
|
|
70
|
+
const connect = client._connect.bind(client);
|
|
71
|
+
// @ts-ignore
|
|
72
|
+
client._connect = () => { nbConnect++; return connect(); };
|
|
73
|
+
try {
|
|
74
|
+
await assert.rejects(client.subscribe('channel1'), { code: 'ECONNREFUSED' });
|
|
75
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
76
|
+
assert.equal(nbConnect, 2);
|
|
77
|
+
client.close();
|
|
78
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
79
|
+
assert.equal(nbConnect, 2);
|
|
80
|
+
assert.deepEqual(unhandledRejections, []);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
client.close();
|
|
84
|
+
process.off('unhandledRejection', onUnhandledRejection);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
it('should not reconnect after being closed', async () => {
|
|
88
|
+
const server = await startServer();
|
|
89
|
+
const client = new WsClient({ url: server.url });
|
|
90
|
+
let nbConnections = 0;
|
|
91
|
+
server.wss.on('connection', () => { nbConnections++; });
|
|
92
|
+
try {
|
|
93
|
+
await client.subscribe('channel1');
|
|
94
|
+
client.close();
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
96
|
+
assert.equal(nbConnections, 1);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
server.wss.close();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
it('should send a single subscribe message per channel', async () => {
|
|
103
|
+
const server = await startServer();
|
|
104
|
+
const client = new WsClient({ url: server.url });
|
|
105
|
+
let nbSubscribe = 0;
|
|
106
|
+
server.wss.on('connection', ws => ws.on('message', () => { nbSubscribe++; }));
|
|
107
|
+
try {
|
|
108
|
+
await client.subscribe('channel1');
|
|
109
|
+
await client.subscribe('channel1');
|
|
110
|
+
assert.equal(nbSubscribe, 1);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
client.close();
|
|
114
|
+
server.wss.close();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
//# sourceMappingURL=ws-client.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws-client.test.js","sourceRoot":"","sources":["ws-client.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AACxC,OAAO,EAAE,MAAM,IAAI,MAAM,EAAE,MAAM,QAAQ,CAAA;AACzC,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAClC,OAAO,EAAE,eAAe,EAAkB,MAAM,IAAI,CAAA;AACpD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAEzC,4EAA4E;AAC5E,MAAM,WAAW,GAAG,KAAK,IAAI,EAAE;IAC7B,MAAM,GAAG,GAAG,IAAI,eAAe,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAA;IAC5C,MAAM,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAA;IAC5B,MAAM,aAAa,GAAG,IAAI,GAAG,EAA0B,CAAA;IACvD,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,EAAE,EAAE,EAAE;QAC1B,aAAa,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,GAAG,EAAE,CAAC,CAAA;QAChC,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,EAAE;YACvB,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAA;YAC1C,aAAa,CAAC,GAAG,CAAC,EAAE,CAAE,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;YAC3C,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,mBAAmB,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAA;QAClF,CAAC,CAAC,CAAA;QACF,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAA;IAChD,CAAC,CAAC,CAAA;IACF,MAAM,OAAO,GAAG,CAAC,OAAe,EAAE,IAAS,EAAE,EAAE;QAC7C,KAAK,MAAM,CAAC,EAAE,EAAE,QAAQ,CAAC,IAAI,aAAa,EAAE,CAAC;YAC3C,IAAI,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC;gBAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;QACxF,CAAC;IACH,CAAC,CAAA;IACD,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,OAAO,EAAE,GAAG,EAAE,oBAAqB,GAAG,CAAC,OAAO,EAAU,CAAC,IAAI,EAAE,EAAE,CAAA;AAChG,CAAC,CAAA;AAED,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;IACzB,MAAM,cAAc,GAAkD;QACpE,0DAA0D;QAC1D,oBAAoB,EAAE,CAAC,QAAQ,EAAE,EAAE;YACjC,aAAa;YACb,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAA;QACnD,CAAC;QACD,uCAAuC;QACvC,mCAAmC,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,SAAS,EAAE;KACxE,CAAA;IACD,KAAK,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,cAAc,CAAC,EAAE,CAAC;QAChE,EAAE,CAAC,6CAA6C,IAAI,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,KAAK,IAAI,EAAE;YACpF,MAAM,MAAM,GAAG,MAAM,WAAW,EAAE,CAAA;YAClC,MAAM,MAAM,GAAG,IAAI,QAAQ,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAA;YAChD,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAA;gBAElC,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,CAAC,aAAa,CAAC,IAAI,EAAE,CAAA;gBAC9C,kFAAkF;gBAClF,MAAM,QAAQ,GAAG,MAAM,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,IAAS,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,KAAK,OAAO,EAAE,IAAI,CAAC,CAAA;gBACxF,MAAM,WAAW,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,YAAY,CAAC,CAAA;gBAClD,UAAU,CAAC,QAAQ,CAAC,CAAA;gBACpB,MAAM,WAAW,CAAA;gBACjB,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,CAAA;gBAEtD,MAAM,CAAC,WAAW,CAAC,GAAG,MAAM,CAAC,aAAa,CAAC,IAAI,EAAE,CAAA;gBACjD,MAAM,CAAC,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;gBACtC,MAAM,CAAC,SAAS,CAAC,CAAC,GAAG,MAAM,CAAC,aAAa,CAAC,GAAG,CAAC,WAAW,CAAE,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAA;gBAE3E,MAAM,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAA;gBAC9C,MAAM,CAAC,SAAS,CAAC,MAAM,QAAQ,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAA;YACtD,CAAC;oBAAS,CAAC;gBACT,MAAM,CAAC,KAAK,EAAE,CAAA;gBACd,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,CAAA;YACpB,CAAC;QACH,CAAC,CAAC,CAAA;IACJ,CAAC;IAED,EAAE,CAAC,gEAAgE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,KAAK,IAAI,EAAE;QACjG,MAAM,mBAAmB,GAAU,EAAE,CAAA;QACrC,MAAM,oBAAoB,GAAG,CAAC,GAAQ,EAAE,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACxE,OAAO,CAAC,EAAE,CAAC,oBAAoB,EAAE,oBAAoB,CAAC,CAAA;QACtD,MAAM,MAAM,GAAG,IAAI,QAAQ,CAAC,EAAE,GAAG,EAAE,oBAAoB,EAAE,CAAC,CAAA;QAC1D,IAAI,SAAS,GAAG,CAAC,CAAA;QACjB,aAAa;QACb,MAAM,OAAO,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAC5C,aAAa;QACb,MAAM,CAAC,QAAQ,GAAG,GAAG,EAAE,GAAG,SAAS,EAAE,CAAC,CAAC,OAAO,OAAO,EAAE,CAAA,CAAC,CAAC,CAAA;QACzD,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAA;YAC5E,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAA;YACvD,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC,CAAC,CAAA;YAC1B,MAAM,CAAC,KAAK,EAAE,CAAA;YACd,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAA;YACvD,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC,CAAC,CAAA;YAC1B,MAAM,CAAC,SAAS,CAAC,mBAAmB,EAAE,EAAE,CAAC,CAAA;QAC3C,CAAC;gBAAS,CAAC;YACT,MAAM,CAAC,KAAK,EAAE,CAAA;YACd,OAAO,CAAC,GAAG,CAAC,oBAAoB,EAAE,oBAAoB,CAAC,CAAA;QACzD,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yCAAyC,EAAE,KAAK,IAAI,EAAE;QACvD,MAAM,MAAM,GAAG,MAAM,WAAW,EAAE,CAAA;QAClC,MAAM,MAAM,GAAG,IAAI,QAAQ,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAA;QAChD,IAAI,aAAa,GAAG,CAAC,CAAA;QACrB,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,GAAG,EAAE,GAAG,aAAa,EAAE,CAAA,CAAC,CAAC,CAAC,CAAA;QACtD,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAA;YAClC,MAAM,CAAC,KAAK,EAAE,CAAA;YACd,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAA;YACvD,MAAM,CAAC,KAAK,CAAC,aAAa,EAAE,CAAC,CAAC,CAAA;QAChC,CAAC;gBAAS,CAAC;YACT,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,CAAA;QACpB,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QAClE,MAAM,MAAM,GAAG,MAAM,WAAW,EAAE,CAAA;QAClC,MAAM,MAAM,GAAG,IAAI,QAAQ,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAA;QAChD,IAAI,WAAW,GAAG,CAAC,CAAA;QACnB,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,GAAG,WAAW,EAAE,CAAA,CAAC,CAAC,CAAC,CAAC,CAAA;QAC5E,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAA;YAClC,MAAM,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAA;YAClC,MAAM,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAA;QAC9B,CAAC;gBAAS,CAAC;YACT,MAAM,CAAC,KAAK,EAAE,CAAA;YACd,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,CAAA;QACpB,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { describe, it } from 'node:test'\nimport { strict as assert } from 'assert'\nimport { once } from 'node:events'\nimport { WebSocketServer, type WebSocket } from 'ws'\nimport { WsClient } from './ws-client.js'\n\n// minimal server following the protocol of @data-fair/lib-express/ws-server\nconst startServer = async () => {\n const wss = new WebSocketServer({ port: 0 })\n await once(wss, 'listening')\n const subscriptions = new Map<WebSocket, Set<string>>()\n wss.on('connection', (ws) => {\n subscriptions.set(ws, new Set())\n ws.on('message', (str) => {\n const message = JSON.parse(str.toString())\n subscriptions.get(ws)!.add(message.channel)\n ws.send(JSON.stringify({ type: 'subscribe-confirm', channel: message.channel }))\n })\n ws.on('close', () => subscriptions.delete(ws))\n })\n const publish = (channel: string, data: any) => {\n for (const [ws, channels] of subscriptions) {\n if (channels.has(channel)) ws.send(JSON.stringify({ type: 'message', channel, data }))\n }\n }\n return { wss, subscriptions, publish, url: `http://localhost:${(wss.address() as any).port}` }\n}\n\ndescribe('ws client', () => {\n const disconnections: Record<string, (serverWs: WebSocket) => void> = {\n // the client receives an invalid frame and emits an error\n 'a connection error': (serverWs) => {\n // @ts-ignore\n serverWs._socket.write(Buffer.from([0xff, 0x00]))\n },\n // for example when the server restarts\n 'a connection closed by the server': (serverWs) => serverWs.terminate()\n }\n for (const [name, disconnect] of Object.entries(disconnections)) {\n it(`should re-subscribe to its channels after ${name}`, { timeout: 5000 }, async () => {\n const server = await startServer()\n const client = new WsClient({ url: server.url })\n try {\n await client.subscribe('channel1')\n\n const [serverWs] = server.subscriptions.keys()\n // pending during the disconnection, like a processing waiting for a journal event\n const received = client.waitFor('channel1', (data: any) => data.value === 'hello', 4000)\n const reconnected = once(server.wss, 'connection')\n disconnect(serverWs)\n await reconnected\n await new Promise(resolve => setTimeout(resolve, 200))\n\n const [newServerWs] = server.subscriptions.keys()\n assert.notEqual(newServerWs, serverWs)\n assert.deepEqual([...server.subscriptions.get(newServerWs)!], ['channel1'])\n\n server.publish('channel1', { value: 'hello' })\n assert.deepEqual(await received, { value: 'hello' })\n } finally {\n client.close()\n server.wss.close()\n }\n })\n }\n\n it('should retry to connect at a limited pace and stop when closed', { timeout: 5000 }, async () => {\n const unhandledRejections: any[] = []\n const onUnhandledRejection = (err: any) => unhandledRejections.push(err)\n process.on('unhandledRejection', onUnhandledRejection)\n const client = new WsClient({ url: 'http://localhost:1' })\n let nbConnect = 0\n // @ts-ignore\n const connect = client._connect.bind(client)\n // @ts-ignore\n client._connect = () => { nbConnect++; return connect() }\n try {\n await assert.rejects(client.subscribe('channel1'), { code: 'ECONNREFUSED' })\n await new Promise(resolve => setTimeout(resolve, 1500))\n assert.equal(nbConnect, 2)\n client.close()\n await new Promise(resolve => setTimeout(resolve, 1500))\n assert.equal(nbConnect, 2)\n assert.deepEqual(unhandledRejections, [])\n } finally {\n client.close()\n process.off('unhandledRejection', onUnhandledRejection)\n }\n })\n\n it('should not reconnect after being closed', async () => {\n const server = await startServer()\n const client = new WsClient({ url: server.url })\n let nbConnections = 0\n server.wss.on('connection', () => { nbConnections++ })\n try {\n await client.subscribe('channel1')\n client.close()\n await new Promise(resolve => setTimeout(resolve, 1500))\n assert.equal(nbConnections, 1)\n } finally {\n server.wss.close()\n }\n })\n\n it('should send a single subscribe message per channel', async () => {\n const server = await startServer()\n const client = new WsClient({ url: server.url })\n let nbSubscribe = 0\n server.wss.on('connection', ws => ws.on('message', () => { nbSubscribe++ }))\n try {\n await client.subscribe('channel1')\n await client.subscribe('channel1')\n assert.equal(nbSubscribe, 1)\n } finally {\n client.close()\n server.wss.close()\n }\n })\n})\n"]}
|