@atproto-labs/fetch-node 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @atproto-labs/fetch-node
2
2
 
3
+ ## 0.1.10
4
+
5
+ ### Patch Changes
6
+
7
+ - [#4108](https://github.com/bluesky-social/atproto/pull/4108) [`f9dc9aa4c`](https://github.com/bluesky-social/atproto/commit/f9dc9aa4c9eaf2f82d140fbf011a9015e7f1a00d) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Allow forcing the use of keep-alive agent on older NodeJs version when unicast protection is active
8
+
9
+ - [#4108](https://github.com/bluesky-social/atproto/pull/4108) [`f9dc9aa4c`](https://github.com/bluesky-social/atproto/commit/f9dc9aa4c9eaf2f82d140fbf011a9015e7f1a00d) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Use keep-alive connection when unicast protection is enabled on NodeJS >= 20
10
+
11
+ ## 0.1.9
12
+
13
+ ### Patch Changes
14
+
15
+ - [#3821](https://github.com/bluesky-social/atproto/pull/3821) [`5050b6550`](https://github.com/bluesky-social/atproto/commit/5050b6550e07e71b0a524eda0b71b837583294d4) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Allow explicit `follow` mode in safe fetch wrap.
16
+
17
+ - [#3819](https://github.com/bluesky-social/atproto/pull/3819) [`36dbd4155`](https://github.com/bluesky-social/atproto/commit/36dbd41551f74052a3f584719a1a7edd86eca201) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Fix potential memory leak
18
+
19
+ - [#3821](https://github.com/bluesky-social/atproto/pull/3821) [`5050b6550`](https://github.com/bluesky-social/atproto/commit/5050b6550e07e71b0a524eda0b71b837583294d4) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Allow disabling the need for an explicit `redirect` mode
20
+
21
+ - [#3818](https://github.com/bluesky-social/atproto/pull/3818) [`43861a452`](https://github.com/bluesky-social/atproto/commit/43861a452b70268e738ef12033297cddacbe25d4) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Remove dependency on the Public Suffix List
22
+
23
+ - Updated dependencies [[`5050b6550`](https://github.com/bluesky-social/atproto/commit/5050b6550e07e71b0a524eda0b71b837583294d4), [`5050b6550`](https://github.com/bluesky-social/atproto/commit/5050b6550e07e71b0a524eda0b71b837583294d4)]:
24
+ - @atproto-labs/pipe@0.1.1
25
+ - @atproto-labs/fetch@0.2.3
26
+
3
27
  ## 0.1.8
4
28
 
5
29
  ### Patch Changes
package/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  Dual MIT/Apache-2.0 License
2
2
 
3
- Copyright (c) 2022-2025 Bluesky PBC, and Contributors
3
+ Copyright (c) 2022-2025 Bluesky Social PBC, and Contributors
4
4
 
5
5
  Except as otherwise noted in individual files, this software is licensed under the MIT license (<http://opensource.org/licenses/MIT>), or the Apache License, Version 2.0 (<http://www.apache.org/licenses/LICENSE-2.0>).
6
6
 
package/dist/safe.d.ts CHANGED
@@ -1,22 +1,41 @@
1
- import { Fetch } from '@atproto-labs/fetch';
2
- export type SafeFetchWrapOptions = NonNullable<Parameters<typeof safeFetchWrap>[0]>;
1
+ import { UnicastFetchWrapOptions } from './unicast.js';
2
+ export type SafeFetchWrapOptions<C> = UnicastFetchWrapOptions<C> & {
3
+ responseMaxSize?: number;
4
+ ssrfProtection?: boolean;
5
+ allowCustomPort?: boolean;
6
+ allowData?: boolean;
7
+ allowHttp?: boolean;
8
+ allowIpHost?: boolean;
9
+ allowPrivateIps?: boolean;
10
+ timeout?: number;
11
+ forbiddenDomainNames?: Iterable<string>;
12
+ /**
13
+ * When `false`, a {@link RequestInit['redirect']} value must be explicitly
14
+ * provided as second argument to the returned function or requests will fail.
15
+ *
16
+ * @default false
17
+ */
18
+ allowImplicitRedirect?: boolean;
19
+ };
3
20
  /**
4
21
  * Wrap a fetch function with safety checks so that it can be safely used
5
22
  * with user provided input (URL).
6
23
  *
7
24
  * @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html}
25
+ *
26
+ * @note When {@link SafeFetchWrapOptions.allowImplicitRedirect} is `false`
27
+ * (default), then the returned function **must** be called setting the second
28
+ * argument's `redirect` property to one of the allowed values. Otherwise, if
29
+ * the returned fetch function is called with a `Request` object (and no
30
+ * explicit `redirect` init object), then the verification code will not be able
31
+ * to determine if the `redirect` property was explicitly set or based on the
32
+ * default value (`follow`), causing it to preventively block the request (throw
33
+ * an error). For this reason, unless you set
34
+ * {@link SafeFetchWrapOptions.allowImplicitRedirect} to `true`, you should
35
+ * **not** wrap the returned function into another function that creates a
36
+ * {@link Request} object before passing it to the function (as a e.g. a logging
37
+ * function would).
8
38
  */
9
- export declare function safeFetchWrap({ fetch, responseMaxSize, // 512kB
10
- ssrfProtection, allowCustomPort, allowData, allowHttp, allowIpHost, allowPrivateIps, timeout, forbiddenDomainNames, }?: {
11
- fetch?: Fetch | undefined;
12
- responseMaxSize?: number | undefined;
13
- ssrfProtection?: boolean | undefined;
14
- allowCustomPort?: boolean | undefined;
15
- allowData?: boolean | undefined;
16
- allowHttp?: boolean | undefined;
17
- allowIpHost?: boolean | undefined;
18
- allowPrivateIps?: boolean | undefined;
19
- timeout?: number | undefined;
20
- forbiddenDomainNames?: Iterable<string> | undefined;
21
- }): Fetch<unknown>;
39
+ export declare function safeFetchWrap<C>({ fetch, dangerouslyForceKeepAliveAgent, responseMaxSize, // 512kB
40
+ ssrfProtection, allowCustomPort, allowData, allowHttp, allowIpHost, allowPrivateIps, timeout, forbiddenDomainNames, allowImplicitRedirect, }?: SafeFetchWrapOptions<C>): (input: string | URL | Request, init?: RequestInit | undefined) => Promise<Response>;
22
41
  //# sourceMappingURL=safe.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"safe.d.ts","sourceRoot":"","sources":["../src/safe.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,EASN,MAAM,qBAAqB,CAAA;AAI5B,MAAM,MAAM,oBAAoB,GAAG,WAAW,CAC5C,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC,CACpC,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,EAC5B,KAAiC,EACjC,eAA4B,EAAE,QAAQ;AACtC,cAAqB,EACrB,eAAiC,EACjC,SAAiB,EACjB,SAA2B,EAC3B,WAAkB,EAClB,eAAiC,EACjC,OAAc,EACd,oBAAyE,GAC1E;;;;;;;;;;;CAAK,GAAG,KAAK,CAAC,OAAO,CAAC,CAsDtB"}
1
+ {"version":3,"file":"safe.d.ts","sourceRoot":"","sources":["../src/safe.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,uBAAuB,EAAoB,MAAM,cAAc,CAAA;AAExE,MAAM,MAAM,oBAAoB,CAAC,CAAC,IAAI,uBAAuB,CAAC,CAAC,CAAC,GAAG;IACjE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,oBAAoB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACvC;;;;;OAKG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC,CAAA;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,EAC/B,KAAoC,EACpC,8BAAsC,EACtC,eAA4B,EAAE,QAAQ;AACtC,cAAqB,EACrB,eAAiC,EACjC,SAAiB,EACjB,SAA2B,EAC3B,WAAkB,EAClB,eAAiC,EACjC,OAAc,EACd,oBAAyE,EACzE,qBAA6B,GAC9B,GAAE,oBAAoB,CAAC,CAAC,CAAM,wFAsD9B"}
package/dist/safe.js CHANGED
@@ -9,14 +9,27 @@ const unicast_js_1 = require("./unicast.js");
9
9
  * with user provided input (URL).
10
10
  *
11
11
  * @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html}
12
+ *
13
+ * @note When {@link SafeFetchWrapOptions.allowImplicitRedirect} is `false`
14
+ * (default), then the returned function **must** be called setting the second
15
+ * argument's `redirect` property to one of the allowed values. Otherwise, if
16
+ * the returned fetch function is called with a `Request` object (and no
17
+ * explicit `redirect` init object), then the verification code will not be able
18
+ * to determine if the `redirect` property was explicitly set or based on the
19
+ * default value (`follow`), causing it to preventively block the request (throw
20
+ * an error). For this reason, unless you set
21
+ * {@link SafeFetchWrapOptions.allowImplicitRedirect} to `true`, you should
22
+ * **not** wrap the returned function into another function that creates a
23
+ * {@link Request} object before passing it to the function (as a e.g. a logging
24
+ * function would).
12
25
  */
13
- function safeFetchWrap({ fetch = globalThis.fetch, responseMaxSize = 512 * 1024, // 512kB
14
- ssrfProtection = true, allowCustomPort = !ssrfProtection, allowData = false, allowHttp = !ssrfProtection, allowIpHost = true, allowPrivateIps = !ssrfProtection, timeout = 10e3, forbiddenDomainNames = fetch_1.DEFAULT_FORBIDDEN_DOMAIN_NAMES, } = {}) {
15
- return (0, fetch_1.toRequestTransformer)((0, pipe_1.pipe)(
26
+ function safeFetchWrap({ fetch = globalThis.fetch, dangerouslyForceKeepAliveAgent = false, responseMaxSize = 512 * 1024, // 512kB
27
+ ssrfProtection = true, allowCustomPort = !ssrfProtection, allowData = false, allowHttp = !ssrfProtection, allowIpHost = true, allowPrivateIps = !ssrfProtection, timeout = 10e3, forbiddenDomainNames = fetch_1.DEFAULT_FORBIDDEN_DOMAIN_NAMES, allowImplicitRedirect = false, } = {}) {
28
+ return (0, pipe_1.pipe)(
16
29
  /**
17
- * Disable HTTP redirects
30
+ * Require explicit {@link RequestInit['redirect']} mode
18
31
  */
19
- (0, fetch_1.redirectCheckRequestTransform)(),
32
+ allowImplicitRedirect ? fetch_1.asRequest : (0, fetch_1.explicitRedirectCheckRequestTransform)(),
20
33
  /**
21
34
  * Only requests that will be issued with a "Host" header are allowed.
22
35
  */
@@ -48,11 +61,13 @@ ssrfProtection = true, allowCustomPort = !ssrfProtection, allowData = false, all
48
61
  * input, we need to make sure that the request is not vulnerable to SSRF
49
62
  * attacks.
50
63
  */
51
- allowPrivateIps ? fetch : (0, unicast_js_1.unicastFetchWrap)({ fetch })),
64
+ allowPrivateIps
65
+ ? fetch
66
+ : (0, unicast_js_1.unicastFetchWrap)({ fetch, dangerouslyForceKeepAliveAgent })),
52
67
  /**
53
68
  * Since we will be fetching user owned data, we need to make sure that an
54
69
  * attacker cannot force us to download a large amounts of data.
55
70
  */
56
- (0, fetch_1.fetchMaxSizeProcessor)(responseMaxSize)));
71
+ (0, fetch_1.fetchMaxSizeProcessor)(responseMaxSize));
57
72
  }
58
73
  //# sourceMappingURL=safe.js.map
package/dist/safe.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"safe.js","sourceRoot":"","sources":["../src/safe.ts"],"names":[],"mappings":";;AAyBA,sCAiEC;AA1FD,+CAW4B;AAC5B,6CAAyC;AACzC,6CAA+C;AAM/C;;;;;GAKG;AACH,SAAgB,aAAa,CAAC,EAC5B,KAAK,GAAG,UAAU,CAAC,KAAc,EACjC,eAAe,GAAG,GAAG,GAAG,IAAI,EAAE,QAAQ;AACtC,cAAc,GAAG,IAAI,EACrB,eAAe,GAAG,CAAC,cAAc,EACjC,SAAS,GAAG,KAAK,EACjB,SAAS,GAAG,CAAC,cAAc,EAC3B,WAAW,GAAG,IAAI,EAClB,eAAe,GAAG,CAAC,cAAc,EACjC,OAAO,GAAG,IAAI,EACd,oBAAoB,GAAG,sCAAkD,MACvE,EAAE;IACJ,OAAO,IAAA,4BAAoB,EACzB,IAAA,WAAI;IACF;;OAEG;IACH,IAAA,qCAA6B,GAAE;IAE/B;;OAEG;IACH,WAAW,CAAC,CAAC,CAAC,iBAAS,CAAC,CAAC,CAAC,IAAA,kCAA0B,GAAE;IAEtD;;OAEG;IACH,IAAA,qCAA6B,EAAC;QAC5B,QAAQ,EAAE,KAAK;QACf,OAAO,EAAE,SAAS;QAClB,OAAO,EAAE,KAAK;QACd,OAAO,EAAE,SAAS,IAAI,EAAE,eAAe,EAAE;QACzC,QAAQ,EAAE,EAAE,eAAe,EAAE;KAC9B,CAAC;IAEF;;;;;OAKG;IACH,IAAA,2CAAmC,EAAC,oBAAoB,CAAC;IAEzD;;;OAGG;IACH,IAAA,kBAAU,EACR,OAAO;IAEP;;;;OAIG;IACH,eAAe,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAA,6BAAgB,EAAC,EAAE,KAAK,EAAE,CAAC,CACtD;IAED;;;OAGG;IACH,IAAA,6BAAqB,EAAC,eAAe,CAAC,CACvC,CACF,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"safe.js","sourceRoot":"","sources":["../src/safe.ts"],"names":[],"mappings":";;AAoDA,sCAmEC;AAvHD,+CAU4B;AAC5B,6CAAyC;AACzC,6CAAwE;AAqBxE;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,aAAa,CAAI,EAC/B,KAAK,GAAG,UAAU,CAAC,KAAiB,EACpC,8BAA8B,GAAG,KAAK,EACtC,eAAe,GAAG,GAAG,GAAG,IAAI,EAAE,QAAQ;AACtC,cAAc,GAAG,IAAI,EACrB,eAAe,GAAG,CAAC,cAAc,EACjC,SAAS,GAAG,KAAK,EACjB,SAAS,GAAG,CAAC,cAAc,EAC3B,WAAW,GAAG,IAAI,EAClB,eAAe,GAAG,CAAC,cAAc,EACjC,OAAO,GAAG,IAAI,EACd,oBAAoB,GAAG,sCAAkD,EACzE,qBAAqB,GAAG,KAAK,MACF,EAAE;IAC7B,OAAO,IAAA,WAAI;IACT;;OAEG;IACH,qBAAqB,CAAC,CAAC,CAAC,iBAAS,CAAC,CAAC,CAAC,IAAA,6CAAqC,GAAE;IAE3E;;OAEG;IACH,WAAW,CAAC,CAAC,CAAC,iBAAS,CAAC,CAAC,CAAC,IAAA,kCAA0B,GAAE;IAEtD;;OAEG;IACH,IAAA,qCAA6B,EAAC;QAC5B,QAAQ,EAAE,KAAK;QACf,OAAO,EAAE,SAAS;QAClB,OAAO,EAAE,KAAK;QACd,OAAO,EAAE,SAAS,IAAI,EAAE,eAAe,EAAE;QACzC,QAAQ,EAAE,EAAE,eAAe,EAAE;KAC9B,CAAC;IAEF;;;;;OAKG;IACH,IAAA,2CAAmC,EAAC,oBAAoB,CAAC;IAEzD;;;OAGG;IACH,IAAA,kBAAU,EACR,OAAO;IAEP;;;;OAIG;IACH,eAAe;QACb,CAAC,CAAC,KAAK;QACP,CAAC,CAAC,IAAA,6BAAgB,EAAC,EAAE,KAAK,EAAE,8BAA8B,EAAE,CAAC,CAChE;IAED;;;OAGG;IACH,IAAA,6BAAqB,EAAC,eAAe,CAAC,CACd,CAAA;AAC5B,CAAC"}
package/dist/unicast.d.ts CHANGED
@@ -1,12 +1,46 @@
1
1
  import dns from 'node:dns';
2
2
  import { LookupFunction } from 'node:net';
3
3
  import { Fetch, FetchContext } from '@atproto-labs/fetch';
4
- export type SsrfFetchWrapOptions<C = FetchContext> = {
4
+ export type UnicastFetchWrapOptions<C = FetchContext> = {
5
5
  fetch?: Fetch<C>;
6
+ /**
7
+ * ## ‼️ important security feature use with care
8
+ *
9
+ * On older NodeJS version, the `dispatcher` init option is ignored when
10
+ * creating a new Request instance. It can only be passed through the fetch
11
+ * function directly.
12
+ *
13
+ * Since this is a security feature, we need to ensure that the unicastLookup
14
+ * function is called to resolve the hostname to a unicast IP address.
15
+ *
16
+ * However, in the case a custom "fetch" function is passed here (fetch !==
17
+ * globalThis.fetch), we have no guarantee that the dispatcher will be used to
18
+ * make the request. Because of this, in such a case, we will use a one-time
19
+ * use dispatcher that checks that the provided fetch function indeed made use
20
+ * of the "unicastLookup" when a custom dispatch init function is used.
21
+ *
22
+ * Sadly, this means that we cannot use "keepAlive" connections, as the method
23
+ * used to ensure that "unicastLookup" gets called requires to create a new
24
+ * dispatcher for each request.
25
+ *
26
+ * If you can guarantee that the provided fetch function will make use of the
27
+ * "dispatcher" init option, you can set this flag to true, which will enable
28
+ * the use of a single agent (with keep-alive) for all requests.
29
+ *
30
+ * @default false
31
+ * @note This option has no effect on Node.js versions >= 20
32
+ */
33
+ dangerouslyForceKeepAliveAgent?: boolean;
6
34
  };
7
35
  /**
8
36
  * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
9
37
  */
10
- export declare function unicastFetchWrap<C = FetchContext>({ fetch, }: SsrfFetchWrapOptions<C>): Fetch<C>;
38
+ export declare function unicastFetchWrap<C = FetchContext>({ fetch, dangerouslyForceKeepAliveAgent, }: UnicastFetchWrapOptions<C>): Fetch<C>;
11
39
  export declare function unicastLookup(hostname: string, options: dns.LookupOptions, callback: Parameters<LookupFunction>[2]): void;
40
+ /**
41
+ * @param hostname - a syntactically valid hostname
42
+ * @returns whether the hostname is a name typically used for on locale area networks.
43
+ * @note **DO NOT** use for security reasons. Only as heuristic.
44
+ */
45
+ export declare function isLocalHostname(hostname: string): boolean;
12
46
  //# 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;AAIzC,OAAO,EACL,KAAK,EACL,YAAY,EAIb,MAAM,qBAAqB,CAAA;AAK5B,MAAM,MAAM,oBAAoB,CAAC,CAAC,GAAG,YAAY,IAAI;IACnD,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAA;CACjB,CAAA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,GAAG,YAAY,EAAE,EACjD,KAAwB,GACzB,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CA+HpC;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,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;IAEhB;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,8BAA8B,CAAC,EAAE,OAAO,CAAA;CACzC,CAAA;AAMD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,GAAG,YAAY,EAAE,EACjD,KAAwB,EACxB,8BAAsC,GACvC,EAAE,uBAAuB,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAuIvC;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,GAAG,CAAC,aAAa,EAC1B,QAAQ,EAAE,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,QA0BxC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAYzD"}
package/dist/unicast.js CHANGED
@@ -5,29 +5,22 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.unicastFetchWrap = unicastFetchWrap;
7
7
  exports.unicastLookup = unicastLookup;
8
+ exports.isLocalHostname = isLocalHostname;
8
9
  const node_dns_1 = __importDefault(require("node:dns"));
9
10
  const ipaddr_js_1 = __importDefault(require("ipaddr.js"));
10
- const psl_1 = require("psl");
11
11
  const undici_1 = require("undici");
12
12
  const fetch_1 = require("@atproto-labs/fetch");
13
13
  const util_js_1 = require("./util.js");
14
14
  const { IPv4, IPv6 } = ipaddr_js_1.default;
15
+ // @TODO support other runtimes ?
16
+ const SUPPORTS_REQUEST_INIT_DISPATCHER = Number(process.versions.node.split('.')[0]) >= 20;
15
17
  /**
16
18
  * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
17
19
  */
18
- function unicastFetchWrap({ fetch = globalThis.fetch, }) {
19
- // In order to enforce the SSRF protection, we need to use a custom dispatcher
20
- // that uses "unicastLookup" to resolve the hostname to a unicast IP address.
21
- // In case a custom "fetch" function is passed here, we have no assurance that
22
- // the dispatcher will be used to make the request. Because of this, in case a
23
- // custom fetch method is passed, we will use a on-time use dispatcher that
24
- // ensures that "unicastLookup" gets called to resolve the hostname to an IP
25
- // address and ensure that it is a unicast address.
26
- // Sadly, this means that we cannot use "keepAlive" connections, as the method
27
- // used to ensure that "unicastLookup" gets called requires to create a new
28
- // dispatcher for each request.
29
- // @TODO: find a way to use a re-usable dispatcher with a custom fetch method.
30
- if (fetch === globalThis.fetch) {
20
+ function unicastFetchWrap({ fetch = globalThis.fetch, dangerouslyForceKeepAliveAgent = false, }) {
21
+ if (SUPPORTS_REQUEST_INIT_DISPATCHER ||
22
+ dangerouslyForceKeepAliveAgent ||
23
+ fetch === globalThis.fetch) {
31
24
  const dispatcher = new undici_1.Agent({
32
25
  connect: { lookup: unicastLookup },
33
26
  });
@@ -39,8 +32,15 @@ function unicastFetchWrap({ fetch = globalThis.fetch, }) {
39
32
  if (url.hostname && (0, util_js_1.isUnicastIp)(url.hostname) === false) {
40
33
  throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 400, 'Hostname is a non-unicast address');
41
34
  }
42
- // @ts-expect-error non-standard option
43
- return fetch.call(this, input, { ...init, dispatcher });
35
+ if (SUPPORTS_REQUEST_INIT_DISPATCHER) {
36
+ // @ts-expect-error non-standard option
37
+ const request = new Request(input, { ...init, dispatcher });
38
+ return fetch.call(this, request);
39
+ }
40
+ else {
41
+ // @ts-expect-error non-standard option
42
+ return fetch.call(this, input, { ...init, dispatcher });
43
+ }
44
44
  };
45
45
  }
46
46
  else {
@@ -61,13 +61,15 @@ function unicastFetchWrap({ fetch = globalThis.fetch, }) {
61
61
  throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 400, 'Hostname is a non-unicast address');
62
62
  }
63
63
  case undefined: {
64
- // hostname is a domain name, using the dispatcher defined above
65
- // will result in the DNS lookup being performed, ensuring that the
66
- // hostname resolves to a unicast address.
64
+ // hostname is a domain name, let's create a new dispatcher that
65
+ // will 1) use the unicastLookup function to resolve the hostname
66
+ // and 2) allow us to check that the lookup function was indeed
67
+ // called.
67
68
  let didLookup = false;
68
69
  const dispatcher = new undici_1.Client(url.origin, {
69
- // Do *not* enable H2 here, as it will cause an error (the client
70
- // will terminate the connection before the response is consumed).
70
+ // Do *not* enable H2 here, as it will cause an error (the
71
+ // client will terminate the connection before the response is
72
+ // consumed).
71
73
  // https://github.com/nodejs/undici/issues/3671
72
74
  connect: {
73
75
  keepAlive: false, // Client will be used once
@@ -77,15 +79,31 @@ function unicastFetchWrap({ fetch = globalThis.fetch, }) {
77
79
  },
78
80
  },
79
81
  });
80
- const headers = new Headers(init?.headers);
81
- headers.set('connection', 'close'); // Proactively close the connection
82
82
  try {
83
- return await fetch.call(this, input, {
83
+ const headers = new Headers(init?.headers);
84
+ headers.set('connection', 'close'); // Proactively close the connection
85
+ const response = await fetch.call(this, input, {
84
86
  ...init,
85
87
  headers,
86
88
  // @ts-expect-error non-standard option
87
89
  dispatcher,
88
90
  });
91
+ if (!didLookup) {
92
+ // We need to ensure that the body is discarded. We can either
93
+ // consume the whole body (for await loop) in order to keep the
94
+ // socket alive, or cancel the request. Since we sent "connection:
95
+ // close", there is no point in consuming the whole response
96
+ // (which would cause un-necessary bandwidth).
97
+ //
98
+ // https://undici.nodejs.org/#/?id=garbage-collection
99
+ await response.body?.cancel();
100
+ // If you encounter this error, either upgrade to Node.js >=21 or
101
+ // make sure that the dispatcher passed through the requestInit
102
+ // object ends up being used to make the request.
103
+ // eslint-disable-next-line no-unsafe-finally
104
+ throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 500, 'Unable to enforce SSRF protection');
105
+ }
106
+ return response;
89
107
  }
90
108
  finally {
91
109
  // Free resources (we cannot await here since the response was not
@@ -94,13 +112,6 @@ function unicastFetchWrap({ fetch = globalThis.fetch, }) {
94
112
  // No biggie, but let's still log it
95
113
  console.warn('Failed to close dispatcher', err);
96
114
  });
97
- if (!didLookup) {
98
- // If you encounter this error, either upgrade to Node.js >=21 or
99
- // make sure that the dispatcher passed through the requestInit
100
- // object ends up being used to make the request.
101
- // eslint-disable-next-line no-unsafe-finally
102
- throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 500, 'Unable to enforce SSRF protection');
103
- }
104
115
  }
105
116
  }
106
117
  }
@@ -108,8 +119,8 @@ function unicastFetchWrap({ fetch = globalThis.fetch, }) {
108
119
  }
109
120
  }
110
121
  function unicastLookup(hostname, options, callback) {
111
- if (!isValidDomain(hostname)) {
112
- callback(new Error('Hostname is not a public domain'), '');
122
+ if (isLocalHostname(hostname)) {
123
+ callback(new Error('Hostname is not a public domain'), []);
113
124
  return;
114
125
  }
115
126
  node_dns_1.default.lookup(hostname, options, (err, address, family) => {
@@ -129,12 +140,21 @@ function unicastLookup(hostname, options, callback) {
129
140
  }
130
141
  });
131
142
  }
132
- // see lupomontero/psl#258 for context on psl usage.
133
- // in short, this ensures a structurally valid domain
134
- // plus a "listed" tld.
135
- function isValidDomain(domain) {
136
- const parsed = (0, psl_1.parse)(domain);
137
- return !parsed.error && parsed.listed;
143
+ /**
144
+ * @param hostname - a syntactically valid hostname
145
+ * @returns whether the hostname is a name typically used for on locale area networks.
146
+ * @note **DO NOT** use for security reasons. Only as heuristic.
147
+ */
148
+ function isLocalHostname(hostname) {
149
+ const parts = hostname.split('.');
150
+ if (parts.length < 2)
151
+ return true;
152
+ const tld = parts.at(-1).toLowerCase();
153
+ return (tld === 'test' ||
154
+ tld === 'local' ||
155
+ tld === 'localhost' ||
156
+ tld === 'invalid' ||
157
+ tld === 'example');
138
158
  }
139
159
  function isNotUnicast(ip) {
140
160
  return ip.range() !== 'unicast';
@@ -1 +1 @@
1
- {"version":3,"file":"unicast.js","sourceRoot":"","sources":["../src/unicast.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"unicast.js","sourceRoot":"","sources":["../src/unicast.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto-labs/fetch-node",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "license": "MIT",
5
5
  "description": "SSRF protection for fetch() in Node.js",
6
6
  "keywords": [
@@ -28,13 +28,11 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "ipaddr.js": "^2.1.0",
31
- "psl": "^1.9.0",
32
31
  "undici": "^6.14.1",
33
- "@atproto-labs/fetch": "0.2.2",
34
- "@atproto-labs/pipe": "0.1.0"
32
+ "@atproto-labs/fetch": "0.2.3",
33
+ "@atproto-labs/pipe": "0.1.1"
35
34
  },
36
35
  "devDependencies": {
37
- "@types/psl": "1.1.3",
38
36
  "typescript": "^5.6.3"
39
37
  },
40
38
  "scripts": {
package/src/safe.ts CHANGED
@@ -2,29 +2,57 @@ import {
2
2
  DEFAULT_FORBIDDEN_DOMAIN_NAMES,
3
3
  Fetch,
4
4
  asRequest,
5
+ explicitRedirectCheckRequestTransform,
5
6
  fetchMaxSizeProcessor,
6
7
  forbiddenDomainNameRequestTransform,
7
8
  protocolCheckRequestTransform,
8
- redirectCheckRequestTransform,
9
9
  requireHostHeaderTransform,
10
10
  timedFetch,
11
- toRequestTransformer,
12
11
  } from '@atproto-labs/fetch'
13
12
  import { pipe } from '@atproto-labs/pipe'
14
- import { unicastFetchWrap } from './unicast.js'
13
+ import { UnicastFetchWrapOptions, unicastFetchWrap } from './unicast.js'
15
14
 
16
- export type SafeFetchWrapOptions = NonNullable<
17
- Parameters<typeof safeFetchWrap>[0]
18
- >
15
+ export type SafeFetchWrapOptions<C> = UnicastFetchWrapOptions<C> & {
16
+ responseMaxSize?: number
17
+ ssrfProtection?: boolean
18
+ allowCustomPort?: boolean
19
+ allowData?: boolean
20
+ allowHttp?: boolean
21
+ allowIpHost?: boolean
22
+ allowPrivateIps?: boolean
23
+ timeout?: number
24
+ forbiddenDomainNames?: Iterable<string>
25
+ /**
26
+ * When `false`, a {@link RequestInit['redirect']} value must be explicitly
27
+ * provided as second argument to the returned function or requests will fail.
28
+ *
29
+ * @default false
30
+ */
31
+ allowImplicitRedirect?: boolean
32
+ }
19
33
 
20
34
  /**
21
35
  * Wrap a fetch function with safety checks so that it can be safely used
22
36
  * with user provided input (URL).
23
37
  *
24
38
  * @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html}
39
+ *
40
+ * @note When {@link SafeFetchWrapOptions.allowImplicitRedirect} is `false`
41
+ * (default), then the returned function **must** be called setting the second
42
+ * argument's `redirect` property to one of the allowed values. Otherwise, if
43
+ * the returned fetch function is called with a `Request` object (and no
44
+ * explicit `redirect` init object), then the verification code will not be able
45
+ * to determine if the `redirect` property was explicitly set or based on the
46
+ * default value (`follow`), causing it to preventively block the request (throw
47
+ * an error). For this reason, unless you set
48
+ * {@link SafeFetchWrapOptions.allowImplicitRedirect} to `true`, you should
49
+ * **not** wrap the returned function into another function that creates a
50
+ * {@link Request} object before passing it to the function (as a e.g. a logging
51
+ * function would).
25
52
  */
26
- export function safeFetchWrap({
27
- fetch = globalThis.fetch as Fetch,
53
+ export function safeFetchWrap<C>({
54
+ fetch = globalThis.fetch as Fetch<C>,
55
+ dangerouslyForceKeepAliveAgent = false,
28
56
  responseMaxSize = 512 * 1024, // 512kB
29
57
  ssrfProtection = true,
30
58
  allowCustomPort = !ssrfProtection,
@@ -34,58 +62,59 @@ export function safeFetchWrap({
34
62
  allowPrivateIps = !ssrfProtection,
35
63
  timeout = 10e3,
36
64
  forbiddenDomainNames = DEFAULT_FORBIDDEN_DOMAIN_NAMES as Iterable<string>,
37
- } = {}): Fetch<unknown> {
38
- return toRequestTransformer(
39
- pipe(
40
- /**
41
- * Disable HTTP redirects
42
- */
43
- redirectCheckRequestTransform(),
65
+ allowImplicitRedirect = false,
66
+ }: SafeFetchWrapOptions<C> = {}) {
67
+ return pipe(
68
+ /**
69
+ * Require explicit {@link RequestInit['redirect']} mode
70
+ */
71
+ allowImplicitRedirect ? asRequest : explicitRedirectCheckRequestTransform(),
44
72
 
45
- /**
46
- * Only requests that will be issued with a "Host" header are allowed.
47
- */
48
- allowIpHost ? asRequest : requireHostHeaderTransform(),
49
-
50
- /**
51
- * Prevent using http:, file: or data: protocols.
52
- */
53
- protocolCheckRequestTransform({
54
- 'about:': false,
55
- 'data:': allowData,
56
- 'file:': false,
57
- 'http:': allowHttp && { allowCustomPort },
58
- 'https:': { allowCustomPort },
59
- }),
73
+ /**
74
+ * Only requests that will be issued with a "Host" header are allowed.
75
+ */
76
+ allowIpHost ? asRequest : requireHostHeaderTransform(),
60
77
 
61
- /**
62
- * Disallow fetching from domains we know are not atproto/OIDC client
63
- * implementation. Note that other domains can be blocked by providing a
64
- * custom fetch function combined with another
65
- * forbiddenDomainNameRequestTransform.
66
- */
67
- forbiddenDomainNameRequestTransform(forbiddenDomainNames),
78
+ /**
79
+ * Prevent using http:, file: or data: protocols.
80
+ */
81
+ protocolCheckRequestTransform({
82
+ 'about:': false,
83
+ 'data:': allowData,
84
+ 'file:': false,
85
+ 'http:': allowHttp && { allowCustomPort },
86
+ 'https:': { allowCustomPort },
87
+ }),
68
88
 
69
- /**
70
- * Since we will be fetching from the network based on user provided
71
- * input, let's mitigate resource exhaustion attacks by setting a timeout.
72
- */
73
- timedFetch(
74
- timeout,
89
+ /**
90
+ * Disallow fetching from domains we know are not atproto/OIDC client
91
+ * implementation. Note that other domains can be blocked by providing a
92
+ * custom fetch function combined with another
93
+ * forbiddenDomainNameRequestTransform.
94
+ */
95
+ forbiddenDomainNameRequestTransform(forbiddenDomainNames),
75
96
 
76
- /**
77
- * Since we will be fetching from the network based on user provided
78
- * input, we need to make sure that the request is not vulnerable to SSRF
79
- * attacks.
80
- */
81
- allowPrivateIps ? fetch : unicastFetchWrap({ fetch }),
82
- ),
97
+ /**
98
+ * Since we will be fetching from the network based on user provided
99
+ * input, let's mitigate resource exhaustion attacks by setting a timeout.
100
+ */
101
+ timedFetch(
102
+ timeout,
83
103
 
84
104
  /**
85
- * Since we will be fetching user owned data, we need to make sure that an
86
- * attacker cannot force us to download a large amounts of data.
105
+ * Since we will be fetching from the network based on user provided
106
+ * input, we need to make sure that the request is not vulnerable to SSRF
107
+ * attacks.
87
108
  */
88
- fetchMaxSizeProcessor(responseMaxSize),
109
+ allowPrivateIps
110
+ ? fetch
111
+ : unicastFetchWrap({ fetch, dangerouslyForceKeepAliveAgent }),
89
112
  ),
90
- )
113
+
114
+ /**
115
+ * Since we will be fetching user owned data, we need to make sure that an
116
+ * attacker cannot force us to download a large amounts of data.
117
+ */
118
+ fetchMaxSizeProcessor(responseMaxSize),
119
+ ) satisfies Fetch<unknown>
91
120
  }
package/src/unicast.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import dns, { LookupAddress } from 'node:dns'
2
2
  import { LookupFunction } from 'node:net'
3
3
  import ipaddr from 'ipaddr.js'
4
- import { parse as pslParse } from 'psl'
5
4
  import { Agent, Client } from 'undici'
6
5
  import {
7
6
  Fetch,
@@ -14,32 +13,55 @@ import { isUnicastIp } from './util.js'
14
13
 
15
14
  const { IPv4, IPv6 } = ipaddr
16
15
 
17
- export type SsrfFetchWrapOptions<C = FetchContext> = {
16
+ export type UnicastFetchWrapOptions<C = FetchContext> = {
18
17
  fetch?: Fetch<C>
18
+
19
+ /**
20
+ * ## ‼️ important security feature use with care
21
+ *
22
+ * On older NodeJS version, the `dispatcher` init option is ignored when
23
+ * creating a new Request instance. It can only be passed through the fetch
24
+ * function directly.
25
+ *
26
+ * Since this is a security feature, we need to ensure that the unicastLookup
27
+ * function is called to resolve the hostname to a unicast IP address.
28
+ *
29
+ * However, in the case a custom "fetch" function is passed here (fetch !==
30
+ * globalThis.fetch), we have no guarantee that the dispatcher will be used to
31
+ * make the request. Because of this, in such a case, we will use a one-time
32
+ * use dispatcher that checks that the provided fetch function indeed made use
33
+ * of the "unicastLookup" when a custom dispatch init function is used.
34
+ *
35
+ * Sadly, this means that we cannot use "keepAlive" connections, as the method
36
+ * used to ensure that "unicastLookup" gets called requires to create a new
37
+ * dispatcher for each request.
38
+ *
39
+ * If you can guarantee that the provided fetch function will make use of the
40
+ * "dispatcher" init option, you can set this flag to true, which will enable
41
+ * the use of a single agent (with keep-alive) for all requests.
42
+ *
43
+ * @default false
44
+ * @note This option has no effect on Node.js versions >= 20
45
+ */
46
+ dangerouslyForceKeepAliveAgent?: boolean
19
47
  }
20
48
 
49
+ // @TODO support other runtimes ?
50
+ const SUPPORTS_REQUEST_INIT_DISPATCHER =
51
+ Number(process.versions.node.split('.')[0]) >= 20
52
+
21
53
  /**
22
54
  * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
23
55
  */
24
56
  export function unicastFetchWrap<C = FetchContext>({
25
57
  fetch = globalThis.fetch,
26
- }: SsrfFetchWrapOptions<C>): Fetch<C> {
27
- // In order to enforce the SSRF protection, we need to use a custom dispatcher
28
- // that uses "unicastLookup" to resolve the hostname to a unicast IP address.
29
-
30
- // In case a custom "fetch" function is passed here, we have no assurance that
31
- // the dispatcher will be used to make the request. Because of this, in case a
32
- // custom fetch method is passed, we will use a on-time use dispatcher that
33
- // ensures that "unicastLookup" gets called to resolve the hostname to an IP
34
- // address and ensure that it is a unicast address.
35
-
36
- // Sadly, this means that we cannot use "keepAlive" connections, as the method
37
- // used to ensure that "unicastLookup" gets called requires to create a new
38
- // dispatcher for each request.
39
-
40
- // @TODO: find a way to use a re-usable dispatcher with a custom fetch method.
41
-
42
- if (fetch === globalThis.fetch) {
58
+ dangerouslyForceKeepAliveAgent = false,
59
+ }: UnicastFetchWrapOptions<C>): Fetch<C> {
60
+ if (
61
+ SUPPORTS_REQUEST_INIT_DISPATCHER ||
62
+ dangerouslyForceKeepAliveAgent ||
63
+ fetch === globalThis.fetch
64
+ ) {
43
65
  const dispatcher = new Agent({
44
66
  connect: { lookup: unicastLookup },
45
67
  })
@@ -63,8 +85,14 @@ export function unicastFetchWrap<C = FetchContext>({
63
85
  )
64
86
  }
65
87
 
66
- // @ts-expect-error non-standard option
67
- return fetch.call(this, input, { ...init, dispatcher })
88
+ if (SUPPORTS_REQUEST_INIT_DISPATCHER) {
89
+ // @ts-expect-error non-standard option
90
+ const request = new Request(input, { ...init, dispatcher })
91
+ return fetch.call(this, request)
92
+ } else {
93
+ // @ts-expect-error non-standard option
94
+ return fetch.call(this, input, { ...init, dispatcher })
95
+ }
68
96
  }
69
97
  } else {
70
98
  return async function (input, init): Promise<Response> {
@@ -97,14 +125,16 @@ export function unicastFetchWrap<C = FetchContext>({
97
125
  }
98
126
 
99
127
  case undefined: {
100
- // hostname is a domain name, using the dispatcher defined above
101
- // will result in the DNS lookup being performed, ensuring that the
102
- // hostname resolves to a unicast address.
128
+ // hostname is a domain name, let's create a new dispatcher that
129
+ // will 1) use the unicastLookup function to resolve the hostname
130
+ // and 2) allow us to check that the lookup function was indeed
131
+ // called.
103
132
 
104
133
  let didLookup = false
105
134
  const dispatcher = new Client(url.origin, {
106
- // Do *not* enable H2 here, as it will cause an error (the client
107
- // will terminate the connection before the response is consumed).
135
+ // Do *not* enable H2 here, as it will cause an error (the
136
+ // client will terminate the connection before the response is
137
+ // consumed).
108
138
  // https://github.com/nodejs/undici/issues/3671
109
139
  connect: {
110
140
  keepAlive: false, // Client will be used once
@@ -115,25 +145,27 @@ export function unicastFetchWrap<C = FetchContext>({
115
145
  },
116
146
  })
117
147
 
118
- const headers = new Headers(init?.headers)
119
- headers.set('connection', 'close') // Proactively close the connection
120
-
121
148
  try {
122
- return await fetch.call(this, input, {
149
+ const headers = new Headers(init?.headers)
150
+ headers.set('connection', 'close') // Proactively close the connection
151
+
152
+ const response = await fetch.call(this, input, {
123
153
  ...init,
124
154
  headers,
125
155
  // @ts-expect-error non-standard option
126
156
  dispatcher,
127
157
  })
128
- } finally {
129
- // Free resources (we cannot await here since the response was not
130
- // consumed yet).
131
- void dispatcher.close().catch((err) => {
132
- // No biggie, but let's still log it
133
- console.warn('Failed to close dispatcher', err)
134
- })
135
158
 
136
159
  if (!didLookup) {
160
+ // We need to ensure that the body is discarded. We can either
161
+ // consume the whole body (for await loop) in order to keep the
162
+ // socket alive, or cancel the request. Since we sent "connection:
163
+ // close", there is no point in consuming the whole response
164
+ // (which would cause un-necessary bandwidth).
165
+ //
166
+ // https://undici.nodejs.org/#/?id=garbage-collection
167
+ await response.body?.cancel()
168
+
137
169
  // If you encounter this error, either upgrade to Node.js >=21 or
138
170
  // make sure that the dispatcher passed through the requestInit
139
171
  // object ends up being used to make the request.
@@ -145,6 +177,15 @@ export function unicastFetchWrap<C = FetchContext>({
145
177
  'Unable to enforce SSRF protection',
146
178
  )
147
179
  }
180
+
181
+ return response
182
+ } finally {
183
+ // Free resources (we cannot await here since the response was not
184
+ // consumed yet).
185
+ void dispatcher.close().catch((err) => {
186
+ // No biggie, but let's still log it
187
+ console.warn('Failed to close dispatcher', err)
188
+ })
148
189
  }
149
190
  }
150
191
  }
@@ -157,8 +198,8 @@ export function unicastLookup(
157
198
  options: dns.LookupOptions,
158
199
  callback: Parameters<LookupFunction>[2],
159
200
  ) {
160
- if (!isValidDomain(hostname)) {
161
- callback(new Error('Hostname is not a public domain'), '')
201
+ if (isLocalHostname(hostname)) {
202
+ callback(new Error('Hostname is not a public domain'), [])
162
203
  return
163
204
  }
164
205
 
@@ -183,12 +224,23 @@ export function unicastLookup(
183
224
  })
184
225
  }
185
226
 
186
- // see lupomontero/psl#258 for context on psl usage.
187
- // in short, this ensures a structurally valid domain
188
- // plus a "listed" tld.
189
- function isValidDomain(domain: string) {
190
- const parsed = pslParse(domain)
191
- return !parsed.error && parsed.listed
227
+ /**
228
+ * @param hostname - a syntactically valid hostname
229
+ * @returns whether the hostname is a name typically used for on locale area networks.
230
+ * @note **DO NOT** use for security reasons. Only as heuristic.
231
+ */
232
+ export function isLocalHostname(hostname: string): boolean {
233
+ const parts = hostname.split('.')
234
+ if (parts.length < 2) return true
235
+
236
+ const tld = parts.at(-1)!.toLowerCase()
237
+ return (
238
+ tld === 'test' ||
239
+ tld === 'local' ||
240
+ tld === 'localhost' ||
241
+ tld === 'invalid' ||
242
+ tld === 'example'
243
+ )
192
244
  }
193
245
 
194
246
  function isNotUnicast(ip: ipaddr.IPv4 | ipaddr.IPv6): boolean {
@@ -1 +1 @@
1
- {"root":["./src/index.ts","./src/safe.ts","./src/unicast.ts","./src/util.ts"],"version":"5.6.3"}
1
+ {"root":["./src/index.ts","./src/safe.ts","./src/unicast.ts","./src/util.ts"],"version":"5.8.2"}