@atproto-labs/fetch-node 0.0.1 → 0.1.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/CHANGELOG.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # @atproto-labs/fetch-node
2
2
 
3
- ## 0.0.1
3
+ ## 0.1.0
4
4
 
5
- ### Patch Changes
5
+ ### Minor Changes
6
+
7
+ - [#2482](https://github.com/bluesky-social/atproto/pull/2482) [`a8d6c1123`](https://github.com/bluesky-social/atproto/commit/a8d6c112359f5c4c0cfbe2df63443ed275f2a646) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Add OAuth provider capability & support for DPoP signed tokens
6
8
 
7
- - [`e134c79a0`](https://github.com/bluesky-social/atproto/commit/e134c79a0ffb000b2cb36437815673fa6bda664b) Thanks [@devinivy](https://github.com/devinivy)! - Initial publish of experimental oauth packages to @atproto-labs
9
+ ### Patch Changes
8
10
 
9
- - Updated dependencies [[`e134c79a0`](https://github.com/bluesky-social/atproto/commit/e134c79a0ffb000b2cb36437815673fa6bda664b)]:
10
- - @atproto-labs/transformer@0.0.1
11
- - @atproto-labs/fetch@0.0.1
11
+ - Updated dependencies [[`a8d6c1123`](https://github.com/bluesky-social/atproto/commit/a8d6c112359f5c4c0cfbe2df63443ed275f2a646)]:
12
+ - @atproto-labs/fetch@0.1.0
13
+ - @atproto-labs/pipe@0.1.0
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from '@atproto-labs/fetch';
1
2
  export * from './safe.js';
2
3
  export * from './ssrf.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAA;AACzB,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,WAAW,CAAA"}
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("@atproto-labs/fetch"), exports);
17
18
  __exportStar(require("./safe.js"), exports);
18
19
  __exportStar(require("./ssrf.js"), exports);
19
20
  //# 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,4CAAyB;AACzB,4CAAyB"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,sDAAmC;AAEnC,4CAAyB;AACzB,4CAAyB"}
package/dist/safe.d.ts CHANGED
@@ -4,7 +4,8 @@ export type SafeFetchWrapOptions = NonNullable<Parameters<typeof safeFetchWrap>[
4
4
  * Wrap a fetch function with safety checks so that it can be safely used
5
5
  * with user provided input (URL).
6
6
  */
7
- export declare const safeFetchWrap: ({ fetch, responseMaxSize, allowHttp, allowData, ssrfProtection, timeout, forbiddenDomainNames, }?: {
7
+ export declare function safeFetchWrap({ fetch, responseMaxSize, // 512kB
8
+ allowHttp, allowData, ssrfProtection, timeout, forbiddenDomainNames, }?: {
8
9
  fetch?: Fetch | undefined;
9
10
  responseMaxSize?: number | undefined;
10
11
  allowHttp?: boolean | undefined;
@@ -12,5 +13,5 @@ export declare const safeFetchWrap: ({ fetch, responseMaxSize, allowHttp, allowD
12
13
  ssrfProtection?: boolean | undefined;
13
14
  timeout?: number | undefined;
14
15
  forbiddenDomainNames?: Iterable<string> | undefined;
15
- }) => Fetch;
16
+ }): Fetch<unknown>;
16
17
  //# 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,EAMN,MAAM,qBAAqB,CAAA;AAK5B,MAAM,MAAM,oBAAoB,GAAG,WAAW,CAC5C,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC,CACpC,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,aAAa;;;;;;;;MAQjB,KA4CN,CAAA"}
1
+ {"version":3,"file":"safe.d.ts","sourceRoot":"","sources":["../src/safe.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,EAON,MAAM,qBAAqB,CAAA;AAK5B,MAAM,MAAM,oBAAoB,GAAG,WAAW,CAC5C,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC,CACpC,CAAA;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAC5B,KAAiC,EACjC,eAA4B,EAAE,QAAQ;AACtC,SAAiB,EACjB,SAAiB,EACjB,cAAqB,EACrB,OAAc,EACd,oBAAyE,GAC1E;;;;;;;;CAAK,GAAG,KAAK,CAAC,OAAO,CAAC,CA+CtB"}
package/dist/safe.js CHANGED
@@ -2,48 +2,48 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.safeFetchWrap = void 0;
4
4
  const fetch_1 = require("@atproto-labs/fetch");
5
- const transformer_1 = require("@atproto-labs/transformer");
5
+ const pipe_1 = require("@atproto-labs/pipe");
6
6
  const ssrf_js_1 = require("./ssrf.js");
7
7
  /**
8
8
  * Wrap a fetch function with safety checks so that it can be safely used
9
9
  * with user provided input (URL).
10
10
  */
11
- const safeFetchWrap = ({ fetch = globalThis.fetch, responseMaxSize = 512 * 1024, // 512kB
12
- allowHttp = false, allowData = false, ssrfProtection = true, timeout = 10e3, forbiddenDomainNames = fetch_1.DEFAULT_FORBIDDEN_DOMAIN_NAMES, } = {}) => (0, transformer_1.compose)(
13
- /**
14
- * Prevent using http:, file: or data: protocols.
15
- */
16
- (0, fetch_1.protocolCheckRequestTransform)(['https:']
17
- .concat(allowHttp ? ['http:'] : [])
18
- .concat(allowData ? ['data:'] : [])),
19
- /**
20
- * Only requests that will be issued with a "Host" header are allowed.
21
- */
22
- (0, fetch_1.requireHostHeaderTranform)(),
23
- /**
24
- * Disallow fetching from domains we know are not atproto/OIDC client
25
- * implementation. Note that other domains can be blocked by providing a
26
- * custom fetch function combined with another
27
- * forbiddenDomainNameRequestTransform.
28
- */
29
- (0, fetch_1.forbiddenDomainNameRequestTransform)(forbiddenDomainNames),
30
- /**
31
- * Since we will be fetching from the network based on user provided
32
- * input, let's mitigate resource exhaustion attacks by setting a timeout.
33
- */
34
- (0, fetch_1.timeoutFetchWrap)({
35
- timeout,
11
+ function safeFetchWrap({ fetch = globalThis.fetch, responseMaxSize = 512 * 1024, // 512kB
12
+ allowHttp = false, allowData = false, ssrfProtection = true, timeout = 10e3, forbiddenDomainNames = fetch_1.DEFAULT_FORBIDDEN_DOMAIN_NAMES, } = {}) {
13
+ return (0, fetch_1.toRequestTransformer)((0, pipe_1.pipe)(
14
+ /**
15
+ * Prevent using http:, file: or data: protocols.
16
+ */
17
+ (0, fetch_1.protocolCheckRequestTransform)(['https:']
18
+ .concat(allowHttp ? ['http:'] : [])
19
+ .concat(allowData ? ['data:'] : [])),
20
+ /**
21
+ * Only requests that will be issued with a "Host" header are allowed.
22
+ */
23
+ (0, fetch_1.requireHostHeaderTranform)(),
24
+ /**
25
+ * Disallow fetching from domains we know are not atproto/OIDC client
26
+ * implementation. Note that other domains can be blocked by providing a
27
+ * custom fetch function combined with another
28
+ * forbiddenDomainNameRequestTransform.
29
+ */
30
+ (0, fetch_1.forbiddenDomainNameRequestTransform)(forbiddenDomainNames),
31
+ /**
32
+ * Since we will be fetching from the network based on user provided
33
+ * input, let's mitigate resource exhaustion attacks by setting a timeout.
34
+ */
35
+ (0, fetch_1.timedFetch)(timeout,
36
36
  /**
37
37
  * Since we will be fetching from the network based on user provided
38
38
  * input, we need to make sure that the request is not vulnerable to SSRF
39
39
  * attacks.
40
40
  */
41
- fetch: ssrfProtection ? (0, ssrf_js_1.ssrfFetchWrap)({ fetch }) : fetch,
42
- }),
43
- /**
44
- * Since we will be fetching user owned data, we need to make sure that an
45
- * attacker cannot force us to download a large amounts of data.
46
- */
47
- (0, fetch_1.fetchMaxSizeProcessor)(responseMaxSize));
41
+ ssrfProtection ? (0, ssrf_js_1.ssrfFetchWrap)({ fetch }) : fetch),
42
+ /**
43
+ * Since we will be fetching user owned data, we need to make sure that an
44
+ * attacker cannot force us to download a large amounts of data.
45
+ */
46
+ (0, fetch_1.fetchMaxSizeProcessor)(responseMaxSize)));
47
+ }
48
48
  exports.safeFetchWrap = safeFetchWrap;
49
49
  //# 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":";;;AAAA,+CAQ4B;AAC5B,2DAAmD;AAEnD,uCAAyC;AAMzC;;;GAGG;AACI,MAAM,aAAa,GAAG,CAAC,EAC5B,KAAK,GAAG,UAAU,CAAC,KAAc,EACjC,eAAe,GAAG,GAAG,GAAG,IAAI,EAAE,QAAQ;AACtC,SAAS,GAAG,KAAK,EACjB,SAAS,GAAG,KAAK,EACjB,cAAc,GAAG,IAAI,EACrB,OAAO,GAAG,IAAc,EACxB,oBAAoB,GAAG,sCAAkD,MACvE,EAAE,EAAS,EAAE,CACf,IAAA,qBAAO;AACL;;GAEG;AACH,IAAA,qCAA6B,EAC3B,CAAC,QAAQ,CAAC;KACP,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KAClC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CACtC;AAED;;GAEG;AACH,IAAA,iCAAyB,GAAE;AAE3B;;;;;GAKG;AACH,IAAA,2CAAmC,EAAC,oBAAoB,CAAC;AAEzD;;;GAGG;AACH,IAAA,wBAAgB,EAAC;IACf,OAAO;IAEP;;;;OAIG;IACH,KAAK,EAAE,cAAc,CAAC,CAAC,CAAC,IAAA,uBAAa,EAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK;CACzD,CAAC;AAEF;;;GAGG;AACH,IAAA,6BAAqB,EAAC,eAAe,CAAC,CACvC,CAAA;AApDU,QAAA,aAAa,iBAoDvB"}
1
+ {"version":3,"file":"safe.js","sourceRoot":"","sources":["../src/safe.ts"],"names":[],"mappings":";;;AAAA,+CAS4B;AAC5B,6CAAyC;AAEzC,uCAAyC;AAMzC;;;GAGG;AACH,SAAgB,aAAa,CAAC,EAC5B,KAAK,GAAG,UAAU,CAAC,KAAc,EACjC,eAAe,GAAG,GAAG,GAAG,IAAI,EAAE,QAAQ;AACtC,SAAS,GAAG,KAAK,EACjB,SAAS,GAAG,KAAK,EACjB,cAAc,GAAG,IAAI,EACrB,OAAO,GAAG,IAAI,EACd,oBAAoB,GAAG,sCAAkD,MACvE,EAAE;IACJ,OAAO,IAAA,4BAAoB,EACzB,IAAA,WAAI;IACF;;OAEG;IACH,IAAA,qCAA6B,EAC3B,CAAC,QAAQ,CAAC;SACP,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;SAClC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CACtC;IAED;;OAEG;IACH,IAAA,iCAAyB,GAAE;IAE3B;;;;;OAKG;IACH,IAAA,2CAAmC,EAAC,oBAAoB,CAAC;IAEzD;;;OAGG;IACH,IAAA,kBAAU,EACR,OAAO;IAEP;;;;OAIG;IACH,cAAc,CAAC,CAAC,CAAC,IAAA,uBAAa,EAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAClD;IAED;;;OAGG;IACH,IAAA,6BAAqB,EAAC,eAAe,CAAC,CACvC,CACF,CAAA;AACH,CAAC;AAvDD,sCAuDC"}
package/dist/ssrf.d.ts CHANGED
@@ -1,6 +1,11 @@
1
- import { Fetch } from '@atproto-labs/fetch';
2
- export type SsrfSafeFetchWrapOptions = NonNullable<Parameters<typeof ssrfFetchWrap>[0]>;
3
- export declare const ssrfFetchWrap: ({ fetch, }?: {
4
- fetch?: Fetch | undefined;
5
- }) => Fetch;
1
+ import { Fetch, FetchContext } from '@atproto-labs/fetch';
2
+ export type SsrfFetchWrapOptions<C = FetchContext> = {
3
+ allowCustomPort?: boolean;
4
+ allowUnknownTld?: boolean;
5
+ fetch?: Fetch<C>;
6
+ };
7
+ /**
8
+ * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
9
+ */
10
+ export declare function ssrfFetchWrap<C = FetchContext>({ allowCustomPort, allowUnknownTld, fetch, }: SsrfFetchWrapOptions<C>): Fetch<C>;
6
11
  //# sourceMappingURL=ssrf.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ssrf.d.ts","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,EAAc,MAAM,qBAAqB,CAAA;AAKvD,MAAM,MAAM,wBAAwB,GAAG,WAAW,CAChD,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC,CACpC,CAAA;AACD,eAAO,MAAM,aAAa;;MAEjB,KA0CR,CAAA"}
1
+ {"version":3,"file":"ssrf.d.ts","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,EACL,YAAY,EAGb,MAAM,qBAAqB,CAAA;AAS5B,MAAM,MAAM,oBAAoB,CAAC,CAAC,GAAG,YAAY,IAAI;IACnD,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAA;CACjB,CAAA;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,CAAC,GAAG,YAAY,EAAE,EAC9C,eAAuB,EACvB,eAAuB,EACvB,KAAwB,GACzB,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAkIpC"}
package/dist/ssrf.js CHANGED
@@ -1,91 +1,138 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || function (mod) {
19
- if (mod && mod.__esModule) return mod;
20
- var result = {};
21
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
- __setModuleDefault(result, mod);
23
- return result;
24
- };
25
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
26
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
27
4
  };
28
5
  Object.defineProperty(exports, "__esModule", { value: true });
29
6
  exports.ssrfFetchWrap = void 0;
30
- const node_dns_1 = __importStar(require("node:dns"));
7
+ const node_dns_1 = __importDefault(require("node:dns"));
31
8
  const fetch_1 = require("@atproto-labs/fetch");
32
9
  const ipaddr_js_1 = __importDefault(require("ipaddr.js"));
10
+ const psl_1 = require("psl");
11
+ const undici_1 = require("undici");
33
12
  const { IPv4, IPv6 } = ipaddr_js_1.default;
34
- const ssrfFetchWrap = ({ fetch = globalThis.fetch, } = {}) => {
35
- const ssrfSafeFetch = async (request) => {
36
- const { protocol, hostname } = new URL(request.url);
37
- if (protocol === 'http:' || protocol === 'https:') {
13
+ const [NODE_VERSION] = process.versions.node.split('.').map(Number);
14
+ /**
15
+ * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
16
+ */
17
+ function ssrfFetchWrap({ allowCustomPort = false, allowUnknownTld = false, fetch = globalThis.fetch, }) {
18
+ const ssrfAgent = new undici_1.Agent({ connect: { lookup } });
19
+ return (0, fetch_1.toRequestTransformer)(async function (request) {
20
+ const url = new URL(request.url);
21
+ if (url.protocol === 'data:') {
22
+ // No SSRF issue
23
+ return fetch.call(this, request);
24
+ }
25
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
26
+ // @ts-expect-error non-standard option
27
+ if (request.dispatcher) {
28
+ throw new fetch_1.FetchRequestError(request, 500, 'SSRF protection cannot be used with a custom request dispatcher');
29
+ }
30
+ // Check port (OWASP)
31
+ if (url.port && !allowCustomPort) {
32
+ throw new fetch_1.FetchRequestError(request, 400, 'Request port must be omitted or standard when SSRF is enabled');
33
+ }
34
+ // Disable HTTP redirections (OWASP)
38
35
  if (request.redirect === 'follow') {
39
- // TODO: actually implement by calling ssrfSafeFetch recursively (?)
40
- throw new fetch_1.FetchError(500, 'Request redirect must be "error" or "manual" when SSRF is enabled', { request });
36
+ throw new fetch_1.FetchRequestError(request, 500, 'Request redirect must be "error" or "manual" when SSRF is enabled');
41
37
  }
42
- // Make sure the hostname is a unicast IP address
43
- const ip = await hostnameLookup(hostname).catch((cause) => {
44
- throw cause?.code === 'ENOTFOUND'
45
- ? new fetch_1.FetchError(400, `Invalid hostname ${hostname}`, {
46
- request,
47
- cause,
48
- })
49
- : new fetch_1.FetchError(500, `Unable resolve DNS for ${hostname}`, {
50
- request,
51
- cause,
52
- });
53
- });
54
- if (ip.range() !== 'unicast') {
55
- throw new fetch_1.FetchError(400, `Invalid hostname IP address ${ip}`, {
56
- request,
38
+ // If the hostname is an IP address, it must be a unicast address.
39
+ const ip = parseIpHostname(url.hostname);
40
+ if (ip) {
41
+ if (ip.range() !== 'unicast') {
42
+ throw new fetch_1.FetchRequestError(request, 400, 'Hostname resolved to non-unicast address');
43
+ }
44
+ // No additional check required
45
+ return fetch.call(this, request);
46
+ }
47
+ if (allowUnknownTld !== true && !(0, psl_1.isValid)(url.hostname)) {
48
+ throw new fetch_1.FetchRequestError(request, 400, 'Hostname is not a public domain');
49
+ }
50
+ // Else hostname is a domain name, use DNS lookup to check if it resolves
51
+ // to a unicast address
52
+ if (NODE_VERSION < 21) {
53
+ // Note: due to the issue nodejs/undici#2828 (fixed in undici >=6.7.0,
54
+ // Node >=21), the "dispatcher" property of the request object will not
55
+ // be used by fetch(). As a workaround, we pass the dispatcher as second
56
+ // argument to fetch() here, and make sure it is used (which might not be
57
+ // the case if a custom fetch() function is used).
58
+ if (fetch === globalThis.fetch) {
59
+ // If the global fetch function is used, we can pass the dispatcher
60
+ // singleton directly to the fetch function as we know it will be
61
+ // used.
62
+ // @ts-expect-error non-standard option
63
+ return fetch.call(this, request, { dispatcher: ssrfAgent });
64
+ }
65
+ let didLookup = false;
66
+ const dispatcher = new undici_1.Agent({
67
+ connect: {
68
+ lookup(...args) {
69
+ didLookup = true;
70
+ lookup(...args);
71
+ },
72
+ },
57
73
  });
74
+ try {
75
+ // @ts-expect-error non-standard option
76
+ return await fetch.call(this, request, { dispatcher });
77
+ }
78
+ finally {
79
+ // Free resources (we cannot await here since the response was not
80
+ // consumed yet).
81
+ void dispatcher.close().catch((err) => {
82
+ // No biggie, but let's still log it
83
+ console.warn('Failed to close dispatcher', err);
84
+ });
85
+ if (!didLookup) {
86
+ // If you encounter this error, either upgrade to Node.js >=21 or
87
+ // make sure that the requestInit object is passed as second
88
+ // argument to the global fetch function.
89
+ // eslint-disable-next-line no-unsafe-finally
90
+ throw new fetch_1.FetchRequestError(request, 500, 'Unable to enforce SSRF protection');
91
+ }
92
+ }
58
93
  }
94
+ // @ts-expect-error non-standard option
95
+ return fetch(new Request(request, { dispatcher: ssrfAgent }));
59
96
  }
60
- else if (protocol === 'data:') {
61
- // No SSRF issue
62
- }
63
- else {
64
- // blob: about: file: all should be rejected
65
- throw new fetch_1.FetchError(400, `Forbidden protocol ${protocol}`, { request });
66
- }
67
- return fetch(request);
68
- };
69
- return ssrfSafeFetch;
70
- };
97
+ // blob: about: file: all should be rejected
98
+ throw new fetch_1.FetchRequestError(request, 400, `Forbidden protocol "${url.protocol}"`);
99
+ });
100
+ }
71
101
  exports.ssrfFetchWrap = ssrfFetchWrap;
72
- async function hostnameLookup(hostname) {
102
+ function parseIpHostname(hostname) {
73
103
  if (IPv4.isIPv4(hostname)) {
74
104
  return IPv4.parse(hostname);
75
105
  }
76
106
  if (hostname.startsWith('[') && hostname.endsWith(']')) {
77
107
  return IPv6.parse(hostname.slice(1, -1));
78
108
  }
79
- return domainLookup(hostname);
109
+ return undefined;
80
110
  }
81
- async function domainLookup(domain) {
82
- const addr = await node_dns_1.promises.lookup(domain, {
83
- hints: node_dns_1.default.ADDRCONFIG | node_dns_1.default.V4MAPPED,
111
+ function lookup(hostname, options, callback) {
112
+ node_dns_1.default.lookup(hostname, options, (err, address, family) => {
113
+ if (err) {
114
+ callback(err, address, family);
115
+ }
116
+ else {
117
+ const ips = Array.isArray(address)
118
+ ? address.map(parseLookupAddress)
119
+ : [parseLookupAddress({ address, family })];
120
+ if (ips.some((ip) => ip.range() !== 'unicast')) {
121
+ callback(new Error('Hostname resolved to non-unicast address'), address, family);
122
+ }
123
+ else {
124
+ callback(null, address, family);
125
+ }
126
+ }
84
127
  });
85
- const ip = addr.family === 4 ? IPv4.parse(addr.address) : IPv6.parse(addr.address);
128
+ }
129
+ function parseLookupAddress({ address, family, }) {
130
+ const ip = family === 4 ? IPv4.parse(address) : IPv6.parse(address);
86
131
  if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {
87
132
  return ip.toIPv4Address();
88
133
  }
89
- return ip;
134
+ else {
135
+ return ip;
136
+ }
90
137
  }
91
138
  //# sourceMappingURL=ssrf.js.map
package/dist/ssrf.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ssrf.js","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ssrf.js","sourceRoot":"","sources":["../src/ssrf.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.0.1",
3
+ "version": "0.1.0",
4
4
  "license": "MIT",
5
5
  "description": "SSRF protection for fetch() in Node.js",
6
6
  "keywords": [
@@ -12,7 +12,7 @@
12
12
  "repository": {
13
13
  "type": "git",
14
14
  "url": "https://github.com/bluesky-social/atproto",
15
- "directory": "packages/fetch-node"
15
+ "directory": "packages/internal/fetch-node"
16
16
  },
17
17
  "type": "commonjs",
18
18
  "main": "dist/index.js",
@@ -25,11 +25,13 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "ipaddr.js": "^2.1.0",
28
- "tslib": "^2.6.2",
29
- "@atproto-labs/fetch": "0.0.1",
30
- "@atproto-labs/transformer": "0.0.1"
28
+ "psl": "^1.9.0",
29
+ "undici": "^6.14.1",
30
+ "@atproto-labs/fetch": "0.1.0",
31
+ "@atproto-labs/pipe": "0.1.0"
31
32
  },
32
33
  "devDependencies": {
34
+ "@types/psl": "1.1.3",
33
35
  "typescript": "^5.3.3"
34
36
  },
35
37
  "scripts": {
package/src/index.ts CHANGED
@@ -1,2 +1,4 @@
1
+ export * from '@atproto-labs/fetch'
2
+
1
3
  export * from './safe.js'
2
4
  export * from './ssrf.js'
package/src/safe.ts CHANGED
@@ -5,9 +5,10 @@ import {
5
5
  forbiddenDomainNameRequestTransform,
6
6
  protocolCheckRequestTransform,
7
7
  requireHostHeaderTranform,
8
- timeoutFetchWrap,
8
+ timedFetch,
9
+ toRequestTransformer,
9
10
  } from '@atproto-labs/fetch'
10
- import { compose } from '@atproto-labs/transformer'
11
+ import { pipe } from '@atproto-labs/pipe'
11
12
 
12
13
  import { ssrfFetchWrap } from './ssrf.js'
13
14
 
@@ -19,56 +20,59 @@ export type SafeFetchWrapOptions = NonNullable<
19
20
  * Wrap a fetch function with safety checks so that it can be safely used
20
21
  * with user provided input (URL).
21
22
  */
22
- export const safeFetchWrap = ({
23
+ export function safeFetchWrap({
23
24
  fetch = globalThis.fetch as Fetch,
24
25
  responseMaxSize = 512 * 1024, // 512kB
25
26
  allowHttp = false,
26
27
  allowData = false,
27
28
  ssrfProtection = true,
28
- timeout = 10e3 as number,
29
+ timeout = 10e3,
29
30
  forbiddenDomainNames = DEFAULT_FORBIDDEN_DOMAIN_NAMES as Iterable<string>,
30
- } = {}): Fetch =>
31
- compose(
32
- /**
33
- * Prevent using http:, file: or data: protocols.
34
- */
35
- protocolCheckRequestTransform(
36
- ['https:']
37
- .concat(allowHttp ? ['http:'] : [])
38
- .concat(allowData ? ['data:'] : []),
39
- ),
40
-
41
- /**
42
- * Only requests that will be issued with a "Host" header are allowed.
43
- */
44
- requireHostHeaderTranform(),
31
+ } = {}): Fetch<unknown> {
32
+ return toRequestTransformer(
33
+ pipe(
34
+ /**
35
+ * Prevent using http:, file: or data: protocols.
36
+ */
37
+ protocolCheckRequestTransform(
38
+ ['https:']
39
+ .concat(allowHttp ? ['http:'] : [])
40
+ .concat(allowData ? ['data:'] : []),
41
+ ),
45
42
 
46
- /**
47
- * Disallow fetching from domains we know are not atproto/OIDC client
48
- * implementation. Note that other domains can be blocked by providing a
49
- * custom fetch function combined with another
50
- * forbiddenDomainNameRequestTransform.
51
- */
52
- forbiddenDomainNameRequestTransform(forbiddenDomainNames),
43
+ /**
44
+ * Only requests that will be issued with a "Host" header are allowed.
45
+ */
46
+ requireHostHeaderTranform(),
53
47
 
54
- /**
55
- * Since we will be fetching from the network based on user provided
56
- * input, let's mitigate resource exhaustion attacks by setting a timeout.
57
- */
58
- timeoutFetchWrap({
59
- timeout,
48
+ /**
49
+ * Disallow fetching from domains we know are not atproto/OIDC client
50
+ * implementation. Note that other domains can be blocked by providing a
51
+ * custom fetch function combined with another
52
+ * forbiddenDomainNameRequestTransform.
53
+ */
54
+ forbiddenDomainNameRequestTransform(forbiddenDomainNames),
60
55
 
61
56
  /**
62
57
  * Since we will be fetching from the network based on user provided
63
- * input, we need to make sure that the request is not vulnerable to SSRF
64
- * attacks.
58
+ * input, let's mitigate resource exhaustion attacks by setting a timeout.
65
59
  */
66
- fetch: ssrfProtection ? ssrfFetchWrap({ fetch }) : fetch,
67
- }),
60
+ timedFetch(
61
+ timeout,
62
+
63
+ /**
64
+ * Since we will be fetching from the network based on user provided
65
+ * input, we need to make sure that the request is not vulnerable to SSRF
66
+ * attacks.
67
+ */
68
+ ssrfProtection ? ssrfFetchWrap({ fetch }) : fetch,
69
+ ),
68
70
 
69
- /**
70
- * Since we will be fetching user owned data, we need to make sure that an
71
- * attacker cannot force us to download a large amounts of data.
72
- */
73
- fetchMaxSizeProcessor(responseMaxSize),
71
+ /**
72
+ * Since we will be fetching user owned data, we need to make sure that an
73
+ * attacker cannot force us to download a large amounts of data.
74
+ */
75
+ fetchMaxSizeProcessor(responseMaxSize),
76
+ ),
74
77
  )
78
+ }
package/src/ssrf.ts CHANGED
@@ -1,62 +1,168 @@
1
- import dns, { promises as dnsPromises } from 'node:dns'
1
+ import dns, { LookupAddress } from 'node:dns'
2
+ import { LookupFunction } from 'node:net'
2
3
 
3
- import { Fetch, FetchError } from '@atproto-labs/fetch'
4
+ import {
5
+ Fetch,
6
+ FetchContext,
7
+ FetchRequestError,
8
+ toRequestTransformer,
9
+ } from '@atproto-labs/fetch'
4
10
  import ipaddr from 'ipaddr.js'
11
+ import { isValid as isValidDomain } from 'psl'
12
+ import { Agent } from 'undici'
5
13
 
6
14
  const { IPv4, IPv6 } = ipaddr
7
15
 
8
- export type SsrfSafeFetchWrapOptions = NonNullable<
9
- Parameters<typeof ssrfFetchWrap>[0]
10
- >
11
- export const ssrfFetchWrap = ({
12
- fetch = globalThis.fetch as Fetch,
13
- } = {}): Fetch => {
14
- const ssrfSafeFetch: Fetch = async (request) => {
15
- const { protocol, hostname } = new URL(request.url)
16
+ const [NODE_VERSION] = process.versions.node.split('.').map(Number)
16
17
 
17
- if (protocol === 'http:' || protocol === 'https:') {
18
+ export type SsrfFetchWrapOptions<C = FetchContext> = {
19
+ allowCustomPort?: boolean
20
+ allowUnknownTld?: boolean
21
+ fetch?: Fetch<C>
22
+ }
23
+
24
+ /**
25
+ * @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
26
+ */
27
+ export function ssrfFetchWrap<C = FetchContext>({
28
+ allowCustomPort = false,
29
+ allowUnknownTld = false,
30
+ fetch = globalThis.fetch,
31
+ }: SsrfFetchWrapOptions<C>): Fetch<C> {
32
+ const ssrfAgent = new Agent({ connect: { lookup } })
33
+
34
+ return toRequestTransformer(async function (
35
+ this: C,
36
+ request,
37
+ ): Promise<Response> {
38
+ const url = new URL(request.url)
39
+
40
+ if (url.protocol === 'data:') {
41
+ // No SSRF issue
42
+ return fetch.call(this, request)
43
+ }
44
+
45
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
46
+ // @ts-expect-error non-standard option
47
+ if (request.dispatcher) {
48
+ throw new FetchRequestError(
49
+ request,
50
+ 500,
51
+ 'SSRF protection cannot be used with a custom request dispatcher',
52
+ )
53
+ }
54
+
55
+ // Check port (OWASP)
56
+ if (url.port && !allowCustomPort) {
57
+ throw new FetchRequestError(
58
+ request,
59
+ 400,
60
+ 'Request port must be omitted or standard when SSRF is enabled',
61
+ )
62
+ }
63
+
64
+ // Disable HTTP redirections (OWASP)
18
65
  if (request.redirect === 'follow') {
19
- // TODO: actually implement by calling ssrfSafeFetch recursively (?)
20
- throw new FetchError(
66
+ throw new FetchRequestError(
67
+ request,
21
68
  500,
22
69
  'Request redirect must be "error" or "manual" when SSRF is enabled',
23
- { request },
24
70
  )
25
71
  }
26
72
 
27
- // Make sure the hostname is a unicast IP address
28
- const ip = await hostnameLookup(hostname).catch((cause) => {
29
- throw cause?.code === 'ENOTFOUND'
30
- ? new FetchError(400, `Invalid hostname ${hostname}`, {
31
- request,
32
- cause,
33
- })
34
- : new FetchError(500, `Unable resolve DNS for ${hostname}`, {
35
- request,
36
- cause,
37
- })
38
- })
39
- if (ip.range() !== 'unicast') {
40
- throw new FetchError(400, `Invalid hostname IP address ${ip}`, {
73
+ // If the hostname is an IP address, it must be a unicast address.
74
+ const ip = parseIpHostname(url.hostname)
75
+ if (ip) {
76
+ if (ip.range() !== 'unicast') {
77
+ throw new FetchRequestError(
78
+ request,
79
+ 400,
80
+ 'Hostname resolved to non-unicast address',
81
+ )
82
+ }
83
+ // No additional check required
84
+ return fetch.call(this, request)
85
+ }
86
+
87
+ if (allowUnknownTld !== true && !isValidDomain(url.hostname)) {
88
+ throw new FetchRequestError(
41
89
  request,
90
+ 400,
91
+ 'Hostname is not a public domain',
92
+ )
93
+ }
94
+
95
+ // Else hostname is a domain name, use DNS lookup to check if it resolves
96
+ // to a unicast address
97
+
98
+ if (NODE_VERSION < 21) {
99
+ // Note: due to the issue nodejs/undici#2828 (fixed in undici >=6.7.0,
100
+ // Node >=21), the "dispatcher" property of the request object will not
101
+ // be used by fetch(). As a workaround, we pass the dispatcher as second
102
+ // argument to fetch() here, and make sure it is used (which might not be
103
+ // the case if a custom fetch() function is used).
104
+
105
+ if (fetch === globalThis.fetch) {
106
+ // If the global fetch function is used, we can pass the dispatcher
107
+ // singleton directly to the fetch function as we know it will be
108
+ // used.
109
+
110
+ // @ts-expect-error non-standard option
111
+ return fetch.call(this, request, { dispatcher: ssrfAgent })
112
+ }
113
+
114
+ let didLookup = false
115
+ const dispatcher = new Agent({
116
+ connect: {
117
+ lookup(...args) {
118
+ didLookup = true
119
+ lookup(...args)
120
+ },
121
+ },
42
122
  })
123
+
124
+ try {
125
+ // @ts-expect-error non-standard option
126
+ return await fetch.call(this, request, { dispatcher })
127
+ } finally {
128
+ // Free resources (we cannot await here since the response was not
129
+ // consumed yet).
130
+ void dispatcher.close().catch((err) => {
131
+ // No biggie, but let's still log it
132
+ console.warn('Failed to close dispatcher', err)
133
+ })
134
+
135
+ if (!didLookup) {
136
+ // If you encounter this error, either upgrade to Node.js >=21 or
137
+ // make sure that the requestInit object is passed as second
138
+ // argument to the global fetch function.
139
+
140
+ // eslint-disable-next-line no-unsafe-finally
141
+ throw new FetchRequestError(
142
+ request,
143
+ 500,
144
+ 'Unable to enforce SSRF protection',
145
+ )
146
+ }
147
+ }
43
148
  }
44
- } else if (protocol === 'data:') {
45
- // No SSRF issue
46
- } else {
47
- // blob: about: file: all should be rejected
48
- throw new FetchError(400, `Forbidden protocol ${protocol}`, { request })
49
- }
50
149
 
51
- return fetch(request)
52
- }
150
+ // @ts-expect-error non-standard option
151
+ return fetch(new Request(request, { dispatcher: ssrfAgent }))
152
+ }
53
153
 
54
- return ssrfSafeFetch
154
+ // blob: about: file: all should be rejected
155
+ throw new FetchRequestError(
156
+ request,
157
+ 400,
158
+ `Forbidden protocol "${url.protocol}"`,
159
+ )
160
+ })
55
161
  }
56
162
 
57
- async function hostnameLookup(
163
+ function parseIpHostname(
58
164
  hostname: string,
59
- ): Promise<ipaddr.IPv4 | ipaddr.IPv6> {
165
+ ): ipaddr.IPv4 | ipaddr.IPv6 | undefined {
60
166
  if (IPv4.isIPv4(hostname)) {
61
167
  return IPv4.parse(hostname)
62
168
  }
@@ -65,22 +171,44 @@ async function hostnameLookup(
65
171
  return IPv6.parse(hostname.slice(1, -1))
66
172
  }
67
173
 
68
- return domainLookup(hostname)
174
+ return undefined
69
175
  }
70
176
 
71
- async function domainLookup(
72
- domain: string,
73
- ): Promise<ipaddr.IPv4 | ipaddr.IPv6> {
74
- const addr = await dnsPromises.lookup(domain, {
75
- hints: dns.ADDRCONFIG | dns.V4MAPPED,
177
+ function lookup(
178
+ hostname: string,
179
+ options: dns.LookupOptions,
180
+ callback: Parameters<LookupFunction>[2],
181
+ ) {
182
+ dns.lookup(hostname, options, (err, address, family) => {
183
+ if (err) {
184
+ callback(err, address, family)
185
+ } else {
186
+ const ips = Array.isArray(address)
187
+ ? address.map(parseLookupAddress)
188
+ : [parseLookupAddress({ address, family })]
189
+
190
+ if (ips.some((ip) => ip.range() !== 'unicast')) {
191
+ callback(
192
+ new Error('Hostname resolved to non-unicast address'),
193
+ address,
194
+ family,
195
+ )
196
+ } else {
197
+ callback(null, address, family)
198
+ }
199
+ }
76
200
  })
201
+ }
77
202
 
78
- const ip =
79
- addr.family === 4 ? IPv4.parse(addr.address) : IPv6.parse(addr.address)
203
+ function parseLookupAddress({
204
+ address,
205
+ family,
206
+ }: LookupAddress): ipaddr.IPv4 | ipaddr.IPv6 {
207
+ const ip = family === 4 ? IPv4.parse(address) : IPv6.parse(address)
80
208
 
81
209
  if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {
82
210
  return ip.toIPv4Address()
211
+ } else {
212
+ return ip
83
213
  }
84
-
85
- return ip
86
214
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["../../../tsconfig/node.json"],
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
package/tsconfig.json CHANGED
@@ -1,8 +1,4 @@
1
1
  {
2
- "extends": "../../tsconfig/node.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "rootDir": "src"
6
- },
7
- "include": ["src"]
2
+ "include": [],
3
+ "references": [{ "path": "./tsconfig.build.json" }]
8
4
  }