@atproto-labs/fetch-node 0.3.1 → 0.3.3

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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # @atproto-labs/fetch-node
2
2
 
3
+ ## 0.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [#5148](https://github.com/bluesky-social/atproto/pull/5148) [`60e9b83`](https://github.com/bluesky-social/atproto/commit/60e9b8391f212c274b1f21991ee2a3a2d14f2f88) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Wrap all errors thrown through `unicastFetchWrap` in Whatwg fetch like `TypeError`.
8
+
9
+ - [#5148](https://github.com/bluesky-social/atproto/pull/5148) [`60e9b83`](https://github.com/bluesky-social/atproto/commit/60e9b8391f212c274b1f21991ee2a3a2d14f2f88) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Allow `unicastFetchWrap` to be called without options object
10
+
11
+ - [#5148](https://github.com/bluesky-social/atproto/pull/5148) [`60e9b83`](https://github.com/bluesky-social/atproto/commit/60e9b8391f212c274b1f21991ee2a3a2d14f2f88) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Rename `isUnicastIp` to `isUnicastIpHostname` (`isUnicastIp` is still exported as deprecated for backwards compatibility).
12
+
13
+ - [#5148](https://github.com/bluesky-social/atproto/pull/5148) [`60e9b83`](https://github.com/bluesky-social/atproto/commit/60e9b8391f212c274b1f21991ee2a3a2d14f2f88) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Add tests for unicast ip lookup and utilities
14
+
15
+ - [#5151](https://github.com/bluesky-social/atproto/pull/5151) [`a51c45d`](https://github.com/bluesky-social/atproto/commit/a51c45d38f6bd7b8765f640e564cf921d52162e7) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Update dependencies
16
+
17
+ - [#5148](https://github.com/bluesky-social/atproto/pull/5148) [`60e9b83`](https://github.com/bluesky-social/atproto/commit/60e9b8391f212c274b1f21991ee2a3a2d14f2f88) Thanks [@matthieusieben](https://github.com/matthieusieben)! - `unicastFetchWrap` now cancels request bodies when throwing an error (to avoid resource leak)
18
+
19
+ - Updated dependencies [[`60e9b83`](https://github.com/bluesky-social/atproto/commit/60e9b8391f212c274b1f21991ee2a3a2d14f2f88), [`60e9b83`](https://github.com/bluesky-social/atproto/commit/60e9b8391f212c274b1f21991ee2a3a2d14f2f88), [`a51c45d`](https://github.com/bluesky-social/atproto/commit/a51c45d38f6bd7b8765f640e564cf921d52162e7)]:
20
+ - @atproto-labs/fetch@0.3.2
21
+ - @atproto-labs/pipe@0.2.2
22
+
23
+ ## 0.3.2
24
+
25
+ ### Patch Changes
26
+
27
+ - [#5116](https://github.com/bluesky-social/atproto/pull/5116) [`39f5c01`](https://github.com/bluesky-social/atproto/commit/39f5c018791ae70391fc86f44be283075dfa206b) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Update undici dependency to v8.5.0
28
+
3
29
  ## 0.3.1
4
30
 
5
31
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from '@atproto-labs/fetch';
2
2
  export * from './safe.js';
3
3
  export * from './unicast.js';
4
- export * from './util.js';
4
+ export { isUnicastIpHostname,
5
+ /** @deprecated use {@link isUnicastIpHostname} instead */
6
+ isUnicastIpHostname as isUnicastIp, } from './util.js';
5
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AAEnC,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AAEnC,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAE5B,OAAO,EACL,mBAAmB;AACnB,0DAA0D;AAC1D,mBAAmB,IAAI,WAAW,GACnC,MAAM,WAAW,CAAA"}
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from '@atproto-labs/fetch';
2
2
  export * from './safe.js';
3
3
  export * from './unicast.js';
4
- export * from './util.js';
4
+ export { isUnicastIpHostname,
5
+ /** @deprecated use {@link isUnicastIpHostname} instead */
6
+ isUnicastIpHostname as isUnicastIp, } from './util.js';
5
7
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AAEnC,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA","sourcesContent":["export * from '@atproto-labs/fetch'\n\nexport * from './safe.js'\nexport * from './unicast.js'\nexport * from './util.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AAEnC,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAE5B,OAAO,EACL,mBAAmB;AACnB,0DAA0D;AAC1D,mBAAmB,IAAI,WAAW,GACnC,MAAM,WAAW,CAAA","sourcesContent":["export * from '@atproto-labs/fetch'\n\nexport * from './safe.js'\nexport * from './unicast.js'\n\nexport {\n isUnicastIpHostname,\n /** @deprecated use {@link isUnicastIpHostname} instead */\n isUnicastIpHostname as isUnicastIp,\n} from './util.js'\n"]}
package/dist/unicast.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import dns from 'node:dns';
2
- import { LookupFunction } from 'node:net';
1
+ import type { LookupOptions } from 'node:dns';
2
+ import type { LookupFunction } from 'node:net';
3
3
  import { Fetch, FetchContext } from '@atproto-labs/fetch';
4
4
  export type UnicastFetchWrapOptions<C = FetchContext> = {
5
5
  fetch?: Fetch<C>;
@@ -7,6 +7,6 @@ export type UnicastFetchWrapOptions<C = FetchContext> = {
7
7
  /**
8
8
  * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
9
9
  */
10
- export declare function unicastFetchWrap<C = FetchContext>({ fetch, }: UnicastFetchWrapOptions<C>): Fetch<C>;
11
- export declare function unicastLookup(hostname: string, options: dns.LookupOptions, callback: Parameters<LookupFunction>[2]): void;
10
+ export declare function unicastFetchWrap<C = FetchContext>({ fetch, }?: UnicastFetchWrapOptions<C>): Fetch<C>;
11
+ export declare function unicastLookup(hostname: string, options: LookupOptions, callback: Parameters<LookupFunction>[2]): void;
12
12
  //# sourceMappingURL=unicast.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"unicast.d.ts","sourceRoot":"","sources":["../src/unicast.ts"],"names":[],"mappings":"AAAA,OAAO,GAAsB,MAAM,UAAU,CAAA;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AAGzC,OAAO,EACL,KAAK,EACL,YAAY,EAIb,MAAM,qBAAqB,CAAA;AAK5B,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,YAAY,IAAI;IACtD,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAA;CACjB,CAAA;AAKD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,GAAG,YAAY,EAAE,EACjD,KAAwB,GACzB,EAAE,uBAAuB,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAkCvC;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,GAAG,CAAC,aAAa,EAC1B,QAAQ,EAAE,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,QA0BxC"}
1
+ {"version":3,"file":"unicast.d.ts","sourceRoot":"","sources":["../src/unicast.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,aAAa,EAAE,MAAM,UAAU,CAAA;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AAK9C,OAAO,EACL,KAAK,EACL,YAAY,EAIb,MAAM,qBAAqB,CAAA;AAK5B,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,YAAY,IAAI;IACtD,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAA;CACjB,CAAA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,GAAG,YAAY,EAAE,EACjD,KAAwB,GACzB,GAAE,uBAAuB,CAAC,CAAC,CAAM,GAAG,KAAK,CAAC,CAAC,CAAC,CAwE5C;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,QA0BxC"}
package/dist/unicast.js CHANGED
@@ -1,31 +1,65 @@
1
- import dns from 'node:dns';
1
+ import { lookup } from 'node:dns';
2
2
  import ipaddr from 'ipaddr.js';
3
- import { Agent } from 'undici';
3
+ import { Agent as Undici6Agent } from 'undici_v6'; // NodeJS 22
4
+ import { Agent as Undici7Agent } from 'undici_v7'; // NodeJS 24
5
+ import { Agent as Undici8Agent } from 'undici_v8'; // NodeJS 26
4
6
  import { FetchRequestError, asRequest, extractUrl, } from '@atproto-labs/fetch';
5
- import { isUnicastIp } from './util.js';
7
+ import { compareVersions, isUnicastIpHostname, parseVersion } from './util.js';
6
8
  const { IPv4, IPv6 } = ipaddr;
7
- const SUPPORTS_REQUEST_INIT_DISPATCHER = Number(process.versions.node.split('.')[0]) >= 20;
8
9
  /**
9
10
  * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
10
11
  */
11
- export function unicastFetchWrap({ fetch = globalThis.fetch, }) {
12
- if (!SUPPORTS_REQUEST_INIT_DISPATCHER) {
13
- throw new Error('Unicast SSRF protection unavailable on your platform. Update to Node.js 22+.');
12
+ export function unicastFetchWrap({ fetch = globalThis.fetch, } = {}) {
13
+ // @NOTE we parse here instead of top-level to allow version mocking in tests.
14
+ const nodeUndiciVersion = parseVersion(process.versions.undici);
15
+ if (!nodeUndiciVersion ||
16
+ // https://github.com/nodejs/undici/pull/2928
17
+ compareVersions(nodeUndiciVersion, [6, 11, 1]) < 0) {
18
+ throw new Error('Unicast SSRF protection requires Node.js 20.6+');
19
+ }
20
+ // @NOTE Since major versions of undici are not guaranteed to be backwards
21
+ // compatible, we need to check the major version and use the appropriate
22
+ // Agent class for that version, to ensure that the dispatcher interface is
23
+ // compatible with the version of undici being used.
24
+ const dispatcher = nodeUndiciVersion[0] === 6
25
+ ? new Undici6Agent({ connect: { lookup: unicastLookup } })
26
+ : nodeUndiciVersion[0] === 7
27
+ ? new Undici7Agent({ connect: { lookup: unicastLookup } })
28
+ : nodeUndiciVersion[0] === 8
29
+ ? new Undici8Agent({ connect: { lookup: unicastLookup } })
30
+ : null;
31
+ // @NOTE Because this is a security feature, we don't want to fallback to
32
+ // using Agent8 to "future proof" this package. Although future version of
33
+ // undici may have a backwards compatible dispatcher interface, we don't want
34
+ // to assume that and risk a security issue.
35
+ if (!dispatcher) {
36
+ throw new Error('This version of @atproto-labs/fetch-node does not support your version of undici. Please upgrade @atproto-labs/fetch-node, and use a version of NodeJS that uses undici 6, 7, or 8 internally.');
14
37
  }
15
- const dispatcher = new Agent({
16
- connect: { lookup: unicastLookup },
17
- });
18
38
  return async function (input, init) {
19
- if (init?.dispatcher) {
20
- throw new FetchRequestError(asRequest(input, init), 500, 'SSRF protection cannot be used with a custom request dispatcher');
39
+ if (init != null && 'dispatcher' in init && init.dispatcher != null) {
40
+ const request = asRequest(input, init);
41
+ await request.body?.cancel();
42
+ const cause = new FetchRequestError(request, 500, 'SSRF protection cannot be used with a custom request dispatcher');
43
+ // @NOTE Use Whatwg style errors so that the error is similar whether
44
+ // caused by this line of an error caused by the lookup function
45
+ throw new TypeError('fetch failed', { cause });
21
46
  }
22
47
  const url = extractUrl(input);
23
- if (url.hostname && isUnicastIp(url.hostname) === false) {
24
- throw new FetchRequestError(asRequest(input, init), 400, 'Hostname is a non-unicast address');
48
+ if (url.hostname && isUnicastIpHostname(url.hostname) === false) {
49
+ const request = asRequest(input, init);
50
+ await request.body?.cancel();
51
+ const cause = new FetchRequestError(request, 400, 'Hostname is a non-unicast address');
52
+ // @NOTE Use Whatwg style errors so that the error is similar whether
53
+ // caused by this line of an error caused by the lookup function
54
+ throw new TypeError('fetch failed', { cause });
25
55
  }
26
- // @ts-expect-error non-standard option
27
- const request = new Request(input, { ...init, dispatcher });
28
- return fetch.call(this, request);
56
+ return fetch.call(this, input, {
57
+ ...init,
58
+ // @ts-ignore There is a type mismatch because of undici version
59
+ // differences, but we know this is safe because we are using the correct
60
+ // Agent class for the undici version.
61
+ dispatcher,
62
+ });
29
63
  };
30
64
  }
31
65
  export function unicastLookup(hostname, options, callback) {
@@ -33,7 +67,7 @@ export function unicastLookup(hostname, options, callback) {
33
67
  callback(new Error('Hostname is not a public domain'), []);
34
68
  return;
35
69
  }
36
- dns.lookup(hostname, options, (err, address, family) => {
70
+ lookup(hostname, options, (err, address, family) => {
37
71
  if (err) {
38
72
  callback(err, address, family);
39
73
  }
@@ -1 +1 @@
1
- {"version":3,"file":"unicast.js","sourceRoot":"","sources":["../src/unicast.ts"],"names":[],"mappings":"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","sourcesContent":["import dns, { LookupAddress } from 'node:dns'\nimport { LookupFunction } from 'node:net'\nimport ipaddr from 'ipaddr.js'\nimport { Agent } from 'undici'\nimport {\n Fetch,\n FetchContext,\n FetchRequestError,\n asRequest,\n extractUrl,\n} from '@atproto-labs/fetch'\nimport { isUnicastIp } from './util.js'\n\nconst { IPv4, IPv6 } = ipaddr\n\nexport type UnicastFetchWrapOptions<C = FetchContext> = {\n fetch?: Fetch<C>\n}\n\nconst SUPPORTS_REQUEST_INIT_DISPATCHER =\n Number(process.versions.node.split('.')[0]) >= 20\n\n/**\n * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}\n */\nexport function unicastFetchWrap<C = FetchContext>({\n fetch = globalThis.fetch,\n}: UnicastFetchWrapOptions<C>): Fetch<C> {\n if (!SUPPORTS_REQUEST_INIT_DISPATCHER) {\n throw new Error(\n 'Unicast SSRF protection unavailable on your platform. Update to Node.js 22+.',\n )\n }\n\n const dispatcher = new Agent({\n connect: { lookup: unicastLookup },\n })\n\n return async function (input, init): Promise<Response> {\n if (init?.dispatcher) {\n throw new FetchRequestError(\n asRequest(input, init),\n 500,\n 'SSRF protection cannot be used with a custom request dispatcher',\n )\n }\n\n const url = extractUrl(input)\n\n if (url.hostname && isUnicastIp(url.hostname) === false) {\n throw new FetchRequestError(\n asRequest(input, init),\n 400,\n 'Hostname is a non-unicast address',\n )\n }\n\n // @ts-expect-error non-standard option\n const request = new Request(input, { ...init, dispatcher })\n return fetch.call(this, request)\n }\n}\n\nexport function unicastLookup(\n hostname: string,\n options: dns.LookupOptions,\n callback: Parameters<LookupFunction>[2],\n) {\n if (isLocalHostname(hostname)) {\n callback(new Error('Hostname is not a public domain'), [])\n return\n }\n\n dns.lookup(hostname, options, (err, address, family) => {\n if (err) {\n callback(err, address, family)\n } else {\n const ips = Array.isArray(address)\n ? address.map(parseLookupAddress)\n : [parseLookupAddress({ address, family })]\n\n if (ips.some(isNotUnicast)) {\n callback(\n new Error('Hostname resolved to non-unicast address'),\n address,\n family,\n )\n } else {\n callback(null, address, family)\n }\n }\n })\n}\n\n/**\n * @param hostname - a syntactically valid hostname\n * @returns whether the hostname is a name typically used for on locale area networks.\n * @note **DO NOT** use for security reasons. Only as heuristic.\n */\nfunction isLocalHostname(hostname: string): boolean {\n const parts = hostname.split('.')\n if (parts.length < 2) return true\n\n const tld = parts.at(-1)!.toLowerCase()\n return (\n tld === 'test' ||\n tld === 'local' ||\n tld === 'localhost' ||\n tld === 'invalid' ||\n tld === 'example'\n )\n}\n\nfunction isNotUnicast(ip: ipaddr.IPv4 | ipaddr.IPv6): boolean {\n return ip.range() !== 'unicast'\n}\n\nfunction parseLookupAddress({\n address,\n family,\n}: LookupAddress): ipaddr.IPv4 | ipaddr.IPv6 {\n const ip = family === 4 ? IPv4.parse(address) : IPv6.parse(address)\n\n if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {\n return ip.toIPv4Address()\n } else {\n return ip\n }\n}\n"]}
1
+ {"version":3,"file":"unicast.js","sourceRoot":"","sources":["../src/unicast.ts"],"names":[],"mappings":"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","sourcesContent":["import type { LookupAddress, LookupOptions } from 'node:dns'\nimport { lookup } from 'node:dns'\nimport type { LookupFunction } from 'node:net'\nimport ipaddr from 'ipaddr.js'\nimport { Agent as Undici6Agent } from 'undici_v6' // NodeJS 22\nimport { Agent as Undici7Agent } from 'undici_v7' // NodeJS 24\nimport { Agent as Undici8Agent } from 'undici_v8' // NodeJS 26\nimport {\n Fetch,\n FetchContext,\n FetchRequestError,\n asRequest,\n extractUrl,\n} from '@atproto-labs/fetch'\nimport { compareVersions, isUnicastIpHostname, parseVersion } from './util.js'\n\nconst { IPv4, IPv6 } = ipaddr\n\nexport type UnicastFetchWrapOptions<C = FetchContext> = {\n fetch?: Fetch<C>\n}\n\n/**\n * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}\n */\nexport function unicastFetchWrap<C = FetchContext>({\n fetch = globalThis.fetch,\n}: UnicastFetchWrapOptions<C> = {}): Fetch<C> {\n // @NOTE we parse here instead of top-level to allow version mocking in tests.\n const nodeUndiciVersion = parseVersion(process.versions.undici)\n\n if (\n !nodeUndiciVersion ||\n // https://github.com/nodejs/undici/pull/2928\n compareVersions(nodeUndiciVersion, [6, 11, 1]) < 0\n ) {\n throw new Error('Unicast SSRF protection requires Node.js 20.6+')\n }\n\n // @NOTE Since major versions of undici are not guaranteed to be backwards\n // compatible, we need to check the major version and use the appropriate\n // Agent class for that version, to ensure that the dispatcher interface is\n // compatible with the version of undici being used.\n const dispatcher =\n nodeUndiciVersion[0] === 6\n ? new Undici6Agent({ connect: { lookup: unicastLookup } })\n : nodeUndiciVersion[0] === 7\n ? new Undici7Agent({ connect: { lookup: unicastLookup } })\n : nodeUndiciVersion[0] === 8\n ? new Undici8Agent({ connect: { lookup: unicastLookup } })\n : null\n\n // @NOTE Because this is a security feature, we don't want to fallback to\n // using Agent8 to \"future proof\" this package. Although future version of\n // undici may have a backwards compatible dispatcher interface, we don't want\n // to assume that and risk a security issue.\n if (!dispatcher) {\n throw new Error(\n 'This version of @atproto-labs/fetch-node does not support your version of undici. Please upgrade @atproto-labs/fetch-node, and use a version of NodeJS that uses undici 6, 7, or 8 internally.',\n )\n }\n\n return async function (this: C, input, init): Promise<Response> {\n if (init != null && 'dispatcher' in init && init.dispatcher != null) {\n const request = asRequest(input, init)\n await request.body?.cancel()\n const cause = new FetchRequestError(\n request,\n 500,\n 'SSRF protection cannot be used with a custom request dispatcher',\n )\n // @NOTE Use Whatwg style errors so that the error is similar whether\n // caused by this line of an error caused by the lookup function\n throw new TypeError('fetch failed', { cause })\n }\n\n const url = extractUrl(input)\n\n if (url.hostname && isUnicastIpHostname(url.hostname) === false) {\n const request = asRequest(input, init)\n await request.body?.cancel()\n const cause = new FetchRequestError(\n request,\n 400,\n 'Hostname is a non-unicast address',\n )\n // @NOTE Use Whatwg style errors so that the error is similar whether\n // caused by this line of an error caused by the lookup function\n throw new TypeError('fetch failed', { cause })\n }\n\n return fetch.call(this, input, {\n ...init,\n // @ts-ignore There is a type mismatch because of undici version\n // differences, but we know this is safe because we are using the correct\n // Agent class for the undici version.\n dispatcher,\n })\n }\n}\n\nexport function unicastLookup(\n hostname: string,\n options: LookupOptions,\n callback: Parameters<LookupFunction>[2],\n) {\n if (isLocalHostname(hostname)) {\n callback(new Error('Hostname is not a public domain'), [])\n return\n }\n\n lookup(hostname, options, (err, address, family) => {\n if (err) {\n callback(err, address, family)\n } else {\n const ips = Array.isArray(address)\n ? address.map(parseLookupAddress)\n : [parseLookupAddress({ address, family })]\n\n if (ips.some(isNotUnicast)) {\n callback(\n new Error('Hostname resolved to non-unicast address'),\n address,\n family,\n )\n } else {\n callback(null, address, family)\n }\n }\n })\n}\n\n/**\n * @param hostname - a syntactically valid hostname\n * @returns whether the hostname is a name typically used for on locale area networks.\n * @note **DO NOT** use for security reasons. Only as heuristic.\n */\nfunction isLocalHostname(hostname: string): boolean {\n const parts = hostname.split('.')\n if (parts.length < 2) return true\n\n const tld = parts.at(-1)!.toLowerCase()\n return (\n tld === 'test' ||\n tld === 'local' ||\n tld === 'localhost' ||\n tld === 'invalid' ||\n tld === 'example'\n )\n}\n\nfunction isNotUnicast(ip: ipaddr.IPv4 | ipaddr.IPv6): boolean {\n return ip.range() !== 'unicast'\n}\n\nfunction parseLookupAddress({\n address,\n family,\n}: LookupAddress): ipaddr.IPv4 | ipaddr.IPv6 {\n const ip = family === 4 ? IPv4.parse(address) : IPv6.parse(address)\n\n if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {\n return ip.toIPv4Address()\n } else {\n return ip\n }\n}\n"]}
package/dist/util.d.ts CHANGED
@@ -1,2 +1,5 @@
1
- export declare function isUnicastIp(hostname: string): boolean | undefined;
1
+ export declare function isUnicastIpHostname(hostname: string): boolean | undefined;
2
+ export type Version = [major: number, minor: number, patch: number];
3
+ export declare function parseVersion(version?: string): Version | undefined;
4
+ export declare function compareVersions(a: Version, b: Version): number;
2
5
  //# sourceMappingURL=util.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAkBA,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAGjE"}
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAkBA,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAGzE;AAED,MAAM,MAAM,OAAO,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAA;AACnE,wBAAgB,YAAY,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAIlE;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,MAAM,CAM9D"}
package/dist/util.js CHANGED
@@ -9,8 +9,23 @@ function parseIpHostname(hostname) {
9
9
  }
10
10
  return undefined;
11
11
  }
12
- export function isUnicastIp(hostname) {
12
+ export function isUnicastIpHostname(hostname) {
13
13
  const ip = parseIpHostname(hostname);
14
14
  return ip ? ip.range() === 'unicast' : undefined;
15
15
  }
16
+ export function parseVersion(version) {
17
+ const match = version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
18
+ if (!match)
19
+ return undefined;
20
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
21
+ }
22
+ export function compareVersions(a, b) {
23
+ for (let i = 0; i < 3; i++) {
24
+ if (a[i] < b[i])
25
+ return -1;
26
+ if (a[i] > b[i])
27
+ return 1;
28
+ }
29
+ return 0;
30
+ }
16
31
  //# sourceMappingURL=util.js.map
package/dist/util.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,WAAW,CAAA;AAE9B,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,MAAM,CAAA;AAE7B,SAAS,eAAe,CACtB,QAAgB;IAEhB,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;IAC7B,CAAC;IAED,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QACvD,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;IAC1C,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,QAAgB;IAC1C,MAAM,EAAE,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAA;IACpC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;AAClD,CAAC","sourcesContent":["import ipaddr from 'ipaddr.js'\n\nconst { IPv4, IPv6 } = ipaddr\n\nfunction parseIpHostname(\n hostname: string,\n): ipaddr.IPv4 | ipaddr.IPv6 | undefined {\n if (IPv4.isIPv4(hostname)) {\n return IPv4.parse(hostname)\n }\n\n if (hostname.startsWith('[') && hostname.endsWith(']')) {\n return IPv6.parse(hostname.slice(1, -1))\n }\n\n return undefined\n}\n\nexport function isUnicastIp(hostname: string): boolean | undefined {\n const ip = parseIpHostname(hostname)\n return ip ? ip.range() === 'unicast' : undefined\n}\n"]}
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,WAAW,CAAA;AAE9B,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,MAAM,CAAA;AAE7B,SAAS,eAAe,CACtB,QAAgB;IAEhB,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;IAC7B,CAAC;IAED,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QACvD,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;IAC1C,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,QAAgB;IAClD,MAAM,EAAE,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAA;IACpC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;AAClD,CAAC;AAGD,MAAM,UAAU,YAAY,CAAC,OAAgB;IAC3C,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,CAAC,uBAAuB,CAAC,CAAA;IACrD,IAAI,CAAC,KAAK;QAAE,OAAO,SAAS,CAAA;IAC5B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAC/D,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,CAAU,EAAE,CAAU;IACpD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3B,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YAAE,OAAO,CAAC,CAAC,CAAA;QAC1B,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YAAE,OAAO,CAAC,CAAA;IAC3B,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC","sourcesContent":["import ipaddr from 'ipaddr.js'\n\nconst { IPv4, IPv6 } = ipaddr\n\nfunction parseIpHostname(\n hostname: string,\n): ipaddr.IPv4 | ipaddr.IPv6 | undefined {\n if (IPv4.isIPv4(hostname)) {\n return IPv4.parse(hostname)\n }\n\n if (hostname.startsWith('[') && hostname.endsWith(']')) {\n return IPv6.parse(hostname.slice(1, -1))\n }\n\n return undefined\n}\n\nexport function isUnicastIpHostname(hostname: string): boolean | undefined {\n const ip = parseIpHostname(hostname)\n return ip ? ip.range() === 'unicast' : undefined\n}\n\nexport type Version = [major: number, minor: number, patch: number]\nexport function parseVersion(version?: string): Version | undefined {\n const match = version?.match(/^(\\d+)\\.(\\d+)\\.(\\d+)$/)\n if (!match) return undefined\n return [Number(match[1]), Number(match[2]), Number(match[3])]\n}\n\nexport function compareVersions(a: Version, b: Version): number {\n for (let i = 0; i < 3; i++) {\n if (a[i] < b[i]) return -1\n if (a[i] > b[i]) return 1\n }\n return 0\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto-labs/fetch-node",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "license": "MIT",
5
5
  "description": "SSRF protection for fetch() in Node.js",
6
6
  "keywords": [
@@ -26,12 +26,17 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "ipaddr.js": "^2.1.0",
29
- "undici": "^6.14.1",
30
- "@atproto-labs/fetch": "^0.3.1",
31
- "@atproto-labs/pipe": "^0.2.1"
29
+ "undici_v6": "npm:undici@^6.x",
30
+ "undici_v7": "npm:undici@^7.x",
31
+ "undici_v8": "npm:undici@^8.x",
32
+ "@atproto-labs/fetch": "^0.3.2",
33
+ "@atproto-labs/pipe": "^0.2.2"
34
+ },
35
+ "devDependencies": {
36
+ "vitest": "^4.0.16"
32
37
  },
33
- "devDependencies": {},
34
38
  "scripts": {
35
- "build": "tsgo --build tsconfig.json"
39
+ "build": "tsgo --build tsconfig.json",
40
+ "test": "vitest run"
36
41
  }
37
42
  }
package/src/index.ts CHANGED
@@ -2,4 +2,9 @@ export * from '@atproto-labs/fetch'
2
2
 
3
3
  export * from './safe.js'
4
4
  export * from './unicast.js'
5
- export * from './util.js'
5
+
6
+ export {
7
+ isUnicastIpHostname,
8
+ /** @deprecated use {@link isUnicastIpHostname} instead */
9
+ isUnicastIpHostname as isUnicastIp,
10
+ } from './util.js'
@@ -0,0 +1,135 @@
1
+ import { assert, describe, expect, it, vi } from 'vitest'
2
+ import { unicastFetchWrap, unicastLookup } from './unicast.js'
3
+
4
+ vi.mock(import('node:dns'), async (importOriginal) => {
5
+ const dns = await importOriginal()
6
+ return {
7
+ ...dns,
8
+ lookup: ((hostname, options, callback) => {
9
+ if (hostname === 'invalid.com') {
10
+ setTimeout(callback, 0, null, '127.2.2.2', 4)
11
+ return
12
+ }
13
+
14
+ if (hostname === 'valid.com') {
15
+ setTimeout(callback, 0, null, '1.2.3.4', 4)
16
+ return
17
+ }
18
+
19
+ // @ts-ignore
20
+ return dns.lookup(hostname, options, callback)
21
+ }) as typeof dns.lookup,
22
+ }
23
+ })
24
+
25
+ describe(unicastLookup, () => {
26
+ it('should reject hostnames that resolve to private IPs', async () => {
27
+ await expect(
28
+ new Promise((resolve, reject) => {
29
+ unicastLookup('invalid.com', { all: true }, (err, address, family) => {
30
+ if (err) {
31
+ reject(err)
32
+ } else {
33
+ resolve({ address, family })
34
+ }
35
+ })
36
+ }),
37
+ ).rejects.toThrow('Hostname resolved to non-unicast address')
38
+ })
39
+
40
+ it('should allow hostnames that resolve to public IPs', async () => {
41
+ await expect(
42
+ new Promise((resolve, reject) => {
43
+ unicastLookup('valid.com', { all: true }, (err, address, family) => {
44
+ if (err) {
45
+ reject(err)
46
+ } else {
47
+ resolve({ address, family })
48
+ }
49
+ })
50
+ }),
51
+ ).resolves.toEqual({ address: '1.2.3.4', family: 4 })
52
+ })
53
+ })
54
+
55
+ describe(unicastFetchWrap, () => {
56
+ describe('expected failures', () => {
57
+ it('should reject private IPv4 hostnames', async () => {
58
+ const fetch = unicastFetchWrap()
59
+ await expect(fetch('http://127.0.0.1/test')).rejects.toSatisfy(
60
+ fetchFailedErrorCauseBy('Hostname is a non-unicast address'),
61
+ )
62
+ })
63
+
64
+ it('should reject private IPv6 hostnames', async () => {
65
+ const fetch = unicastFetchWrap()
66
+ await expect(fetch('http://[::1]/test')).rejects.toSatisfy(
67
+ fetchFailedErrorCauseBy('Hostname is a non-unicast address'),
68
+ )
69
+ })
70
+
71
+ it('should reject hostnames that are not public domain tlds', async () => {
72
+ const fetch = unicastFetchWrap()
73
+ await expect(fetch('http://localhost/test')).rejects.toSatisfy(
74
+ fetchFailedErrorCauseBy('Hostname is not a public domain'),
75
+ )
76
+
77
+ await expect(fetch('https://printer.local')).rejects.toSatisfy(
78
+ fetchFailedErrorCauseBy('Hostname is not a public domain'),
79
+ )
80
+ })
81
+
82
+ it('should reject hostnames that resolve to private IPs', async () => {
83
+ const fetch = unicastFetchWrap()
84
+ await expect(fetch('http://invalid.com/test')).rejects.toSatisfy(
85
+ fetchFailedErrorCauseBy('Hostname resolved to non-unicast address'),
86
+ )
87
+ })
88
+ })
89
+
90
+ describe('expected successes', () => {
91
+ // Let's avoid calling actual services in tests. There is no easy way to
92
+ // ensure this works without a real public domain hostname that resolves to
93
+ // a public IP. So we skip this test for now.
94
+ it.skip('should allow public domain hostnames that resolve to public IPs', async () => {
95
+ const fetch = unicastFetchWrap()
96
+ await fetch('http://atproto.com/@atproto/node-fetch/test')
97
+ })
98
+ })
99
+
100
+ describe('version handling', () => {
101
+ it('should throw an error if undici version is too old', () => {
102
+ using _ = vi
103
+ .spyOn(process.versions, 'undici', 'get')
104
+ .mockReturnValue('6.11.0')
105
+ expect(process.versions.undici).toBe('6.11.0')
106
+ expect(() => unicastFetchWrap()).toThrowError()
107
+ })
108
+
109
+ it('should support undici version 6.11.1', () => {
110
+ using _ = vi
111
+ .spyOn(process.versions, 'undici', 'get')
112
+ .mockReturnValue('6.11.1')
113
+ expect(process.versions.undici).toBe('6.11.1')
114
+ expect(() => unicastFetchWrap()).not.toThrowError()
115
+ })
116
+
117
+ it('should throw an error if undici version is too new', () => {
118
+ using _ = vi
119
+ .spyOn(process.versions, 'undici', 'get')
120
+ .mockReturnValue('9.0.0')
121
+ expect(process.versions.undici).toBe('9.0.0')
122
+ expect(() => unicastFetchWrap()).toThrowError()
123
+ })
124
+ })
125
+ })
126
+
127
+ function fetchFailedErrorCauseBy(message: string) {
128
+ return (err: unknown) => {
129
+ assert(err instanceof TypeError)
130
+ expect(err.message).toBe('fetch failed')
131
+ assert(err.cause instanceof Error)
132
+ expect(err.cause.message).toBe(message)
133
+ return true
134
+ }
135
+ }
package/src/unicast.ts CHANGED
@@ -1,7 +1,10 @@
1
- import dns, { LookupAddress } from 'node:dns'
2
- import { LookupFunction } from 'node:net'
1
+ import type { LookupAddress, LookupOptions } from 'node:dns'
2
+ import { lookup } from 'node:dns'
3
+ import type { LookupFunction } from 'node:net'
3
4
  import ipaddr from 'ipaddr.js'
4
- import { Agent } from 'undici'
5
+ import { Agent as Undici6Agent } from 'undici_v6' // NodeJS 22
6
+ import { Agent as Undici7Agent } from 'undici_v7' // NodeJS 24
7
+ import { Agent as Undici8Agent } from 'undici_v8' // NodeJS 26
5
8
  import {
6
9
  Fetch,
7
10
  FetchContext,
@@ -9,7 +12,7 @@ import {
9
12
  asRequest,
10
13
  extractUrl,
11
14
  } from '@atproto-labs/fetch'
12
- import { isUnicastIp } from './util.js'
15
+ import { compareVersions, isUnicastIpHostname, parseVersion } from './util.js'
13
16
 
14
17
  const { IPv4, IPv6 } = ipaddr
15
18
 
@@ -17,53 +20,88 @@ export type UnicastFetchWrapOptions<C = FetchContext> = {
17
20
  fetch?: Fetch<C>
18
21
  }
19
22
 
20
- const SUPPORTS_REQUEST_INIT_DISPATCHER =
21
- Number(process.versions.node.split('.')[0]) >= 20
22
-
23
23
  /**
24
24
  * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
25
25
  */
26
26
  export function unicastFetchWrap<C = FetchContext>({
27
27
  fetch = globalThis.fetch,
28
- }: UnicastFetchWrapOptions<C>): Fetch<C> {
29
- if (!SUPPORTS_REQUEST_INIT_DISPATCHER) {
28
+ }: UnicastFetchWrapOptions<C> = {}): Fetch<C> {
29
+ // @NOTE we parse here instead of top-level to allow version mocking in tests.
30
+ const nodeUndiciVersion = parseVersion(process.versions.undici)
31
+
32
+ if (
33
+ !nodeUndiciVersion ||
34
+ // https://github.com/nodejs/undici/pull/2928
35
+ compareVersions(nodeUndiciVersion, [6, 11, 1]) < 0
36
+ ) {
37
+ throw new Error('Unicast SSRF protection requires Node.js 20.6+')
38
+ }
39
+
40
+ // @NOTE Since major versions of undici are not guaranteed to be backwards
41
+ // compatible, we need to check the major version and use the appropriate
42
+ // Agent class for that version, to ensure that the dispatcher interface is
43
+ // compatible with the version of undici being used.
44
+ const dispatcher =
45
+ nodeUndiciVersion[0] === 6
46
+ ? new Undici6Agent({ connect: { lookup: unicastLookup } })
47
+ : nodeUndiciVersion[0] === 7
48
+ ? new Undici7Agent({ connect: { lookup: unicastLookup } })
49
+ : nodeUndiciVersion[0] === 8
50
+ ? new Undici8Agent({ connect: { lookup: unicastLookup } })
51
+ : null
52
+
53
+ // @NOTE Because this is a security feature, we don't want to fallback to
54
+ // using Agent8 to "future proof" this package. Although future version of
55
+ // undici may have a backwards compatible dispatcher interface, we don't want
56
+ // to assume that and risk a security issue.
57
+ if (!dispatcher) {
30
58
  throw new Error(
31
- 'Unicast SSRF protection unavailable on your platform. Update to Node.js 22+.',
59
+ 'This version of @atproto-labs/fetch-node does not support your version of undici. Please upgrade @atproto-labs/fetch-node, and use a version of NodeJS that uses undici 6, 7, or 8 internally.',
32
60
  )
33
61
  }
34
62
 
35
- const dispatcher = new Agent({
36
- connect: { lookup: unicastLookup },
37
- })
38
-
39
- return async function (input, init): Promise<Response> {
40
- if (init?.dispatcher) {
41
- throw new FetchRequestError(
42
- asRequest(input, init),
63
+ return async function (this: C, input, init): Promise<Response> {
64
+ if (init != null && 'dispatcher' in init && init.dispatcher != null) {
65
+ const request = asRequest(input, init)
66
+ await request.body?.cancel()
67
+ const cause = new FetchRequestError(
68
+ request,
43
69
  500,
44
70
  'SSRF protection cannot be used with a custom request dispatcher',
45
71
  )
72
+ // @NOTE Use Whatwg style errors so that the error is similar whether
73
+ // caused by this line of an error caused by the lookup function
74
+ throw new TypeError('fetch failed', { cause })
46
75
  }
47
76
 
48
77
  const url = extractUrl(input)
49
78
 
50
- if (url.hostname && isUnicastIp(url.hostname) === false) {
51
- throw new FetchRequestError(
52
- asRequest(input, init),
79
+ if (url.hostname && isUnicastIpHostname(url.hostname) === false) {
80
+ const request = asRequest(input, init)
81
+ await request.body?.cancel()
82
+ const cause = new FetchRequestError(
83
+ request,
53
84
  400,
54
85
  'Hostname is a non-unicast address',
55
86
  )
87
+ // @NOTE Use Whatwg style errors so that the error is similar whether
88
+ // caused by this line of an error caused by the lookup function
89
+ throw new TypeError('fetch failed', { cause })
56
90
  }
57
91
 
58
- // @ts-expect-error non-standard option
59
- const request = new Request(input, { ...init, dispatcher })
60
- return fetch.call(this, request)
92
+ return fetch.call(this, input, {
93
+ ...init,
94
+ // @ts-ignore There is a type mismatch because of undici version
95
+ // differences, but we know this is safe because we are using the correct
96
+ // Agent class for the undici version.
97
+ dispatcher,
98
+ })
61
99
  }
62
100
  }
63
101
 
64
102
  export function unicastLookup(
65
103
  hostname: string,
66
- options: dns.LookupOptions,
104
+ options: LookupOptions,
67
105
  callback: Parameters<LookupFunction>[2],
68
106
  ) {
69
107
  if (isLocalHostname(hostname)) {
@@ -71,7 +109,7 @@ export function unicastLookup(
71
109
  return
72
110
  }
73
111
 
74
- dns.lookup(hostname, options, (err, address, family) => {
112
+ lookup(hostname, options, (err, address, family) => {
75
113
  if (err) {
76
114
  callback(err, address, family)
77
115
  } else {
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { compareVersions, isUnicastIpHostname, parseVersion } from './util.js'
3
+
4
+ describe(isUnicastIpHostname, () => {
5
+ describe('IPv4', () => {
6
+ it('should return true for unicast IP addresses', () => {
7
+ expect(isUnicastIpHostname('1.1.1.1')).toBe(true)
8
+ expect(isUnicastIpHostname('8.8.8.8')).toBe(true)
9
+ })
10
+
11
+ it('should return false for Multicast IP addresses', () => {
12
+ // https://en.wikipedia.org/wiki/Multicast_address
13
+
14
+ expect(isUnicastIpHostname('224.0.0.0')).toBe(false)
15
+ expect(isUnicastIpHostname('224.0.0.255')).toBe(false)
16
+ expect(isUnicastIpHostname('224.0.1.0')).toBe(false)
17
+ expect(isUnicastIpHostname('224.0.1.255')).toBe(false)
18
+ expect(isUnicastIpHostname('224.0.2.0')).toBe(false)
19
+ expect(isUnicastIpHostname('224.0.255.255')).toBe(false)
20
+ expect(isUnicastIpHostname('224.1.0.0')).toBe(false)
21
+ expect(isUnicastIpHostname('224.1.255.255')).toBe(false)
22
+ expect(isUnicastIpHostname('224.2.0.0')).toBe(false)
23
+ expect(isUnicastIpHostname('224.2.255.255')).toBe(false)
24
+ expect(isUnicastIpHostname('224.3.0.0')).toBe(false)
25
+ expect(isUnicastIpHostname('224.4.255.255')).toBe(false)
26
+ expect(isUnicastIpHostname('224.5.0.0')).toBe(false)
27
+ expect(isUnicastIpHostname('224.255.255.255')).toBe(false)
28
+ expect(isUnicastIpHostname('225.0.0.0')).toBe(false)
29
+ expect(isUnicastIpHostname('231.255.255.255')).toBe(false)
30
+ expect(isUnicastIpHostname('232.0.0.0')).toBe(false)
31
+ expect(isUnicastIpHostname('232.255.255.255')).toBe(false)
32
+ expect(isUnicastIpHostname('233.0.0.0')).toBe(false)
33
+ expect(isUnicastIpHostname('233.251.255.255')).toBe(false)
34
+ expect(isUnicastIpHostname('233.252.0.0')).toBe(false)
35
+ expect(isUnicastIpHostname('233.255.255.255')).toBe(false)
36
+ expect(isUnicastIpHostname('234.0.0.0')).toBe(false)
37
+ expect(isUnicastIpHostname('234.255.255.255')).toBe(false)
38
+ expect(isUnicastIpHostname('235.0.0.0')).toBe(false)
39
+ expect(isUnicastIpHostname('238.255.255.255')).toBe(false)
40
+ expect(isUnicastIpHostname('239.0.0.0')).toBe(false)
41
+ expect(isUnicastIpHostname('239.255.255.255')).toBe(false)
42
+ })
43
+
44
+ it('should return false for loopback IP addresses', () => {
45
+ // https://en.wikipedia.org/wiki/Loopback
46
+
47
+ expect(isUnicastIpHostname('127.0.0.0')).toBe(false)
48
+ expect(isUnicastIpHostname('127.0.0.1')).toBe(false)
49
+ expect(isUnicastIpHostname('127.0.34.54')).toBe(false)
50
+ expect(isUnicastIpHostname('127.255.255.255')).toBe(false)
51
+ })
52
+
53
+ it('should return false for private IP addresses', () => {
54
+ // https://en.wikipedia.org/wiki/Private_network#Private_IPv4_address_spaces
55
+
56
+ expect(isUnicastIpHostname('10.0.0.0')).toBe(false)
57
+ expect(isUnicastIpHostname('10.255.255.255')).toBe(false)
58
+ expect(isUnicastIpHostname('172.16.0.0')).toBe(false)
59
+ expect(isUnicastIpHostname('172.16.0.1')).toBe(false)
60
+ expect(isUnicastIpHostname('172.31.255.255')).toBe(false)
61
+ expect(isUnicastIpHostname('192.168.0.0')).toBe(false)
62
+ expect(isUnicastIpHostname('192.168.1.1')).toBe(false)
63
+ expect(isUnicastIpHostname('192.168.255.255')).toBe(false)
64
+ })
65
+ })
66
+
67
+ it('should return undefined for non-IP hostnames', () => {
68
+ expect(isUnicastIpHostname('example.com')).toBeUndefined()
69
+ expect(isUnicastIpHostname('localhost')).toBeUndefined()
70
+ })
71
+ })
72
+
73
+ describe(parseVersion, () => {
74
+ it('should parse valid version strings', () => {
75
+ expect(parseVersion('1.2.3')).toEqual([1, 2, 3])
76
+ expect(parseVersion('0.0.1')).toEqual([0, 0, 1])
77
+ expect(parseVersion('10.20.30')).toEqual([10, 20, 30])
78
+ })
79
+
80
+ it('should return undefined for invalid version strings', () => {
81
+ expect(parseVersion('1.2')).toBeUndefined()
82
+ expect(parseVersion('1.2.3.4')).toBeUndefined()
83
+ expect(parseVersion('abc')).toBeUndefined()
84
+ })
85
+
86
+ it('should return undefined for empty or undefined input', () => {
87
+ expect(parseVersion('')).toBeUndefined()
88
+ expect(parseVersion(undefined)).toBeUndefined()
89
+ })
90
+ })
91
+
92
+ describe(compareVersions, () => {
93
+ it('should return 0 for equal versions', () => {
94
+ expect(compareVersions([1, 2, 3], [1, 2, 3])).toBe(0)
95
+ })
96
+
97
+ it('should return -1 for a < b', () => {
98
+ expect(compareVersions([1, 2, 3], [1, 2, 4])).toBe(-1)
99
+ expect(compareVersions([1, 2, 3], [1, 3, 0])).toBe(-1)
100
+ expect(compareVersions([1, 2, 3], [2, 0, 0])).toBe(-1)
101
+ })
102
+
103
+ it('should return 1 for a > b', () => {
104
+ expect(compareVersions([1, 2, 4], [1, 2, 3])).toBe(1)
105
+ expect(compareVersions([1, 3, 0], [1, 2, 3])).toBe(1)
106
+ expect(compareVersions([2, 0, 0], [1, 2, 3])).toBe(1)
107
+ })
108
+ })
package/src/util.ts CHANGED
@@ -16,7 +16,22 @@ function parseIpHostname(
16
16
  return undefined
17
17
  }
18
18
 
19
- export function isUnicastIp(hostname: string): boolean | undefined {
19
+ export function isUnicastIpHostname(hostname: string): boolean | undefined {
20
20
  const ip = parseIpHostname(hostname)
21
21
  return ip ? ip.range() === 'unicast' : undefined
22
22
  }
23
+
24
+ export type Version = [major: number, minor: number, patch: number]
25
+ export function parseVersion(version?: string): Version | undefined {
26
+ const match = version?.match(/^(\d+)\.(\d+)\.(\d+)$/)
27
+ if (!match) return undefined
28
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
29
+ }
30
+
31
+ export function compareVersions(a: Version, b: Version): number {
32
+ for (let i = 0; i < 3; i++) {
33
+ if (a[i] < b[i]) return -1
34
+ if (a[i] > b[i]) return 1
35
+ }
36
+ return 0
37
+ }
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "extends": ["../../../tsconfig/node.json"],
3
+ "include": ["./src"],
4
+ "exclude": ["**/*.test.ts"],
3
5
  "compilerOptions": {
4
6
  "outDir": "dist",
5
7
  "rootDir": "src",
6
8
  },
7
- "include": ["src"],
8
9
  }
package/tsconfig.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
2
  "include": [],
3
- "references": [{ "path": "./tsconfig.build.json" }],
3
+ "references": [
4
+ { "path": "./tsconfig.build.json" },
5
+ { "path": "./tsconfig.test.json" },
6
+ ],
4
7
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["../../../tsconfig/vitest.json"],
3
+ "include": ["./tests", "./src/**/*.test.ts"],
4
+ "compilerOptions": {
5
+ "noImplicitAny": true,
6
+ "rootDir": "./",
7
+ },
8
+ }
@@ -0,0 +1 @@
1
+ {"version":"7.0.0-dev.20260614.1","root":["./src/unicast.test.ts","./src/util.test.ts"]}
@@ -0,0 +1,5 @@
1
+ import { defineProject } from 'vitest/config'
2
+
3
+ export default defineProject({
4
+ test: {},
5
+ })