@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 +8 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/safe.d.ts +3 -2
- package/dist/safe.d.ts.map +1 -1
- package/dist/safe.js +33 -33
- package/dist/safe.js.map +1 -1
- package/dist/ssrf.d.ts +10 -5
- package/dist/ssrf.d.ts.map +1 -1
- package/dist/ssrf.js +110 -63
- package/dist/ssrf.js.map +1 -1
- package/package.json +7 -5
- package/src/index.ts +2 -0
- package/src/safe.ts +45 -41
- package/src/ssrf.ts +177 -49
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +2 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# @atproto-labs/fetch-node
|
|
2
2
|
|
|
3
|
-
## 0.0
|
|
3
|
+
## 0.1.0
|
|
4
4
|
|
|
5
|
-
###
|
|
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
|
-
|
|
9
|
+
### Patch Changes
|
|
8
10
|
|
|
9
|
-
- Updated dependencies [[`
|
|
10
|
-
- @atproto-labs/
|
|
11
|
-
- @atproto-labs/
|
|
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
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
-
})
|
|
16
|
+
}): Fetch<unknown>;
|
|
16
17
|
//# sourceMappingURL=safe.d.ts.map
|
package/dist/safe.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"safe.d.ts","sourceRoot":"","sources":["../src/safe.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,
|
|
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
|
|
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
|
-
|
|
12
|
-
allowHttp = false, allowData = false, ssrfProtection = true, timeout = 10e3, forbiddenDomainNames = fetch_1.DEFAULT_FORBIDDEN_DOMAIN_NAMES, } = {})
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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,+
|
|
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
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
package/dist/ssrf.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssrf.d.ts","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"
|
|
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 =
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
const ip =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
109
|
+
return undefined;
|
|
80
110
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":"
|
|
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
|
|
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
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"@atproto-labs/
|
|
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
package/src/safe.ts
CHANGED
|
@@ -5,9 +5,10 @@ import {
|
|
|
5
5
|
forbiddenDomainNameRequestTransform,
|
|
6
6
|
protocolCheckRequestTransform,
|
|
7
7
|
requireHostHeaderTranform,
|
|
8
|
-
|
|
8
|
+
timedFetch,
|
|
9
|
+
toRequestTransformer,
|
|
9
10
|
} from '@atproto-labs/fetch'
|
|
10
|
-
import {
|
|
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
|
|
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
|
|
29
|
+
timeout = 10e3,
|
|
29
30
|
forbiddenDomainNames = DEFAULT_FORBIDDEN_DOMAIN_NAMES as Iterable<string>,
|
|
30
|
-
} = {}): Fetch
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
64
|
-
* attacks.
|
|
58
|
+
* input, let's mitigate resource exhaustion attacks by setting a timeout.
|
|
65
59
|
*/
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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, {
|
|
1
|
+
import dns, { LookupAddress } from 'node:dns'
|
|
2
|
+
import { LookupFunction } from 'node:net'
|
|
2
3
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
//
|
|
28
|
-
const ip =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
52
|
-
|
|
150
|
+
// @ts-expect-error non-standard option
|
|
151
|
+
return fetch(new Request(request, { dispatcher: ssrfAgent }))
|
|
152
|
+
}
|
|
53
153
|
|
|
54
|
-
|
|
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
|
-
|
|
163
|
+
function parseIpHostname(
|
|
58
164
|
hostname: string,
|
|
59
|
-
):
|
|
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
|
|
174
|
+
return undefined
|
|
69
175
|
}
|
|
70
176
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
}
|