@atproto-labs/fetch-node 0.0.1 → 0.1.1
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 +18 -5
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/safe.d.ts +10 -4
- package/dist/safe.d.ts.map +1 -1
- package/dist/safe.js +44 -34
- package/dist/safe.js.map +1 -1
- package/dist/unicast.d.ts +14 -0
- package/dist/unicast.d.ts.map +1 -0
- package/dist/unicast.js +144 -0
- package/dist/unicast.js.map +1 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +23 -0
- package/dist/util.js.map +1 -0
- package/package.json +7 -5
- package/src/index.ts +4 -1
- package/src/safe.ts +62 -44
- package/src/unicast.ts +202 -0
- package/src/util.ts +22 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +2 -6
- package/dist/ssrf.d.ts +0 -6
- package/dist/ssrf.d.ts.map +0 -1
- package/dist/ssrf.js +0 -91
- package/dist/ssrf.js.map +0 -1
- package/src/ssrf.ts +0 -86
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
# @atproto-labs/fetch-node
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.1.1
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
- [`
|
|
7
|
+
- [#2770](https://github.com/bluesky-social/atproto/pull/2770) [`a07b21151`](https://github.com/bluesky-social/atproto/commit/a07b21151f1850340c4b7797ebb11521b1a6cdf3) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Prevent bypass of ssrf ip verification
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
- [#2770](https://github.com/bluesky-social/atproto/pull/2770) [`a07b21151`](https://github.com/bluesky-social/atproto/commit/a07b21151f1850340c4b7797ebb11521b1a6cdf3) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Expose IP filtering utilities
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [[`a07b21151`](https://github.com/bluesky-social/atproto/commit/a07b21151f1850340c4b7797ebb11521b1a6cdf3), [`a07b21151`](https://github.com/bluesky-social/atproto/commit/a07b21151f1850340c4b7797ebb11521b1a6cdf3), [`a07b21151`](https://github.com/bluesky-social/atproto/commit/a07b21151f1850340c4b7797ebb11521b1a6cdf3)]:
|
|
12
|
+
- @atproto-labs/fetch@0.1.1
|
|
13
|
+
|
|
14
|
+
## 0.1.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- [#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
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- Updated dependencies [[`a8d6c1123`](https://github.com/bluesky-social/atproto/commit/a8d6c112359f5c4c0cfbe2df63443ed275f2a646)]:
|
|
23
|
+
- @atproto-labs/fetch@0.1.0
|
|
24
|
+
- @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,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,8 @@ 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
|
-
__exportStar(require("./
|
|
19
|
+
__exportStar(require("./unicast.js"), exports);
|
|
20
|
+
__exportStar(require("./util.js"), exports);
|
|
19
21
|
//# 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,+CAA4B;AAC5B,4CAAyB"}
|
package/dist/safe.d.ts
CHANGED
|
@@ -3,14 +3,20 @@ export type SafeFetchWrapOptions = NonNullable<Parameters<typeof safeFetchWrap>[
|
|
|
3
3
|
/**
|
|
4
4
|
* Wrap a fetch function with safety checks so that it can be safely used
|
|
5
5
|
* with user provided input (URL).
|
|
6
|
+
*
|
|
7
|
+
* @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html}
|
|
6
8
|
*/
|
|
7
|
-
export declare
|
|
9
|
+
export declare function safeFetchWrap({ fetch, responseMaxSize, // 512kB
|
|
10
|
+
ssrfProtection, allowCustomPort, allowData, allowHttp, allowIpHost, allowPrivateIps, timeout, forbiddenDomainNames, }?: {
|
|
8
11
|
fetch?: Fetch | undefined;
|
|
9
12
|
responseMaxSize?: number | undefined;
|
|
10
|
-
allowHttp?: boolean | undefined;
|
|
11
|
-
allowData?: boolean | undefined;
|
|
12
13
|
ssrfProtection?: boolean | undefined;
|
|
14
|
+
allowCustomPort?: boolean | undefined;
|
|
15
|
+
allowData?: boolean | undefined;
|
|
16
|
+
allowHttp?: boolean | undefined;
|
|
17
|
+
allowIpHost?: boolean | undefined;
|
|
18
|
+
allowPrivateIps?: boolean | undefined;
|
|
13
19
|
timeout?: number | undefined;
|
|
14
20
|
forbiddenDomainNames?: Iterable<string> | undefined;
|
|
15
|
-
})
|
|
21
|
+
}): Fetch<unknown>;
|
|
16
22
|
//# 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,
|
|
1
|
+
{"version":3,"file":"safe.d.ts","sourceRoot":"","sources":["../src/safe.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,EAQN,MAAM,qBAAqB,CAAA;AAK5B,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"}
|
package/dist/safe.js
CHANGED
|
@@ -2,48 +2,58 @@
|
|
|
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
|
|
6
|
-
const
|
|
5
|
+
const pipe_1 = require("@atproto-labs/pipe");
|
|
6
|
+
const unicast_js_1 = require("./unicast.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
|
+
*
|
|
11
|
+
* @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html}
|
|
10
12
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)(
|
|
16
|
+
/**
|
|
17
|
+
* Disable HTTP redirects
|
|
18
|
+
*/
|
|
19
|
+
(0, fetch_1.redirectCheckRequestTransform)(),
|
|
20
|
+
/**
|
|
21
|
+
* Only requests that will be issued with a "Host" header are allowed.
|
|
22
|
+
*/
|
|
23
|
+
allowIpHost ? fetch_1.asRequest : (0, fetch_1.requireHostHeaderTransform)(),
|
|
24
|
+
/**
|
|
25
|
+
* Prevent using http:, file: or data: protocols.
|
|
26
|
+
*/
|
|
27
|
+
(0, fetch_1.protocolCheckRequestTransform)({
|
|
28
|
+
'about:': false,
|
|
29
|
+
'data:': allowData,
|
|
30
|
+
'file:': false,
|
|
31
|
+
'http:': allowHttp && { allowCustomPort },
|
|
32
|
+
'https:': { allowCustomPort },
|
|
33
|
+
}),
|
|
34
|
+
/**
|
|
35
|
+
* Disallow fetching from domains we know are not atproto/OIDC client
|
|
36
|
+
* implementation. Note that other domains can be blocked by providing a
|
|
37
|
+
* custom fetch function combined with another
|
|
38
|
+
* forbiddenDomainNameRequestTransform.
|
|
39
|
+
*/
|
|
40
|
+
(0, fetch_1.forbiddenDomainNameRequestTransform)(forbiddenDomainNames),
|
|
41
|
+
/**
|
|
42
|
+
* Since we will be fetching from the network based on user provided
|
|
43
|
+
* input, let's mitigate resource exhaustion attacks by setting a timeout.
|
|
44
|
+
*/
|
|
45
|
+
(0, fetch_1.timedFetch)(timeout,
|
|
36
46
|
/**
|
|
37
47
|
* Since we will be fetching from the network based on user provided
|
|
38
48
|
* input, we need to make sure that the request is not vulnerable to SSRF
|
|
39
49
|
* attacks.
|
|
40
50
|
*/
|
|
41
|
-
fetch:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
allowPrivateIps ? fetch : (0, unicast_js_1.unicastFetchWrap)({ fetch })),
|
|
52
|
+
/**
|
|
53
|
+
* Since we will be fetching user owned data, we need to make sure that an
|
|
54
|
+
* attacker cannot force us to download a large amounts of data.
|
|
55
|
+
*/
|
|
56
|
+
(0, fetch_1.fetchMaxSizeProcessor)(responseMaxSize)));
|
|
57
|
+
}
|
|
48
58
|
exports.safeFetchWrap = safeFetchWrap;
|
|
49
59
|
//# 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,+CAW4B;AAC5B,6CAAyC;AAEzC,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;AAjED,sCAiEC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import dns from 'node:dns';
|
|
4
|
+
import { LookupFunction } from 'node:net';
|
|
5
|
+
import { Fetch, FetchContext } from '@atproto-labs/fetch';
|
|
6
|
+
export type SsrfFetchWrapOptions<C = FetchContext> = {
|
|
7
|
+
fetch?: Fetch<C>;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
|
|
11
|
+
*/
|
|
12
|
+
export declare function unicastFetchWrap<C = FetchContext>({ fetch, }: SsrfFetchWrapOptions<C>): Fetch<C>;
|
|
13
|
+
export declare function unicastLookup(hostname: string, options: dns.LookupOptions, callback: Parameters<LookupFunction>[2]): void;
|
|
14
|
+
//# sourceMappingURL=unicast.d.ts.map
|
|
@@ -0,0 +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;AAEzC,OAAO,EAGL,KAAK,EACL,YAAY,EAEb,MAAM,qBAAqB,CAAA;AAS5B,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,CA8HpC;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,GAAG,CAAC,aAAa,EAC1B,QAAQ,EAAE,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,QA0BxC"}
|
package/dist/unicast.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.unicastLookup = exports.unicastFetchWrap = void 0;
|
|
7
|
+
const node_dns_1 = __importDefault(require("node:dns"));
|
|
8
|
+
const fetch_1 = require("@atproto-labs/fetch");
|
|
9
|
+
const ipaddr_js_1 = __importDefault(require("ipaddr.js"));
|
|
10
|
+
const psl_1 = require("psl");
|
|
11
|
+
const undici_1 = require("undici");
|
|
12
|
+
const util_js_1 = require("./util.js");
|
|
13
|
+
const { IPv4, IPv6 } = ipaddr_js_1.default;
|
|
14
|
+
/**
|
|
15
|
+
* @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
|
|
16
|
+
*/
|
|
17
|
+
function unicastFetchWrap({ fetch = globalThis.fetch, }) {
|
|
18
|
+
// In order to enforce the SSRF protection, we need to use a custom dispatcher
|
|
19
|
+
// that uses "unicastLookup" to resolve the hostname to a unicast IP address.
|
|
20
|
+
// In case a custom "fetch" function is passed here, we have no assurance that
|
|
21
|
+
// the dispatcher will be used to make the request. Because of this, in case a
|
|
22
|
+
// custom fetch method is passed, we will use a on-time use dispatcher that
|
|
23
|
+
// ensures that "unicastLookup" gets called to resolve the hostname to an IP
|
|
24
|
+
// address and ensure that it is a unicast address.
|
|
25
|
+
// Sadly, this means that we cannot use "keepAlive" connections, as the method
|
|
26
|
+
// used to ensure that "unicastLookup" gets called requires to create a new
|
|
27
|
+
// dispatcher for each request.
|
|
28
|
+
// @TODO: find a way to use a re-usable dispatcher with a custom fetch method.
|
|
29
|
+
if (fetch === globalThis.fetch) {
|
|
30
|
+
const dispatcher = new undici_1.Agent({
|
|
31
|
+
allowH2: true,
|
|
32
|
+
connect: { keepAlive: true, lookup: unicastLookup },
|
|
33
|
+
});
|
|
34
|
+
return async function (input, init) {
|
|
35
|
+
if (init?.dispatcher) {
|
|
36
|
+
throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 500, 'SSRF protection cannot be used with a custom request dispatcher');
|
|
37
|
+
}
|
|
38
|
+
const url = (0, fetch_1.extractUrl)(input);
|
|
39
|
+
if (url.hostname && (0, util_js_1.isUnicastIp)(url.hostname) === false) {
|
|
40
|
+
throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 400, 'Hostname is a non-unicast address');
|
|
41
|
+
}
|
|
42
|
+
// @ts-expect-error non-standard option
|
|
43
|
+
return fetch.call(this, input, { ...init, dispatcher });
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
return async function (input, init) {
|
|
48
|
+
if (init?.dispatcher) {
|
|
49
|
+
throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 500, 'SSRF protection cannot be used with a custom request dispatcher');
|
|
50
|
+
}
|
|
51
|
+
const url = (0, fetch_1.extractUrl)(input);
|
|
52
|
+
if (!url.hostname) {
|
|
53
|
+
return fetch.call(this, input, init);
|
|
54
|
+
}
|
|
55
|
+
switch ((0, util_js_1.isUnicastIp)(url.hostname)) {
|
|
56
|
+
case true: {
|
|
57
|
+
// hostname is a unicast address, safe to proceed.
|
|
58
|
+
return fetch.call(this, input, init);
|
|
59
|
+
}
|
|
60
|
+
case false: {
|
|
61
|
+
throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 400, 'Hostname is a non-unicast address');
|
|
62
|
+
}
|
|
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.
|
|
67
|
+
let didLookup = false;
|
|
68
|
+
const dispatcher = new undici_1.Client(url.origin, {
|
|
69
|
+
allowH2: true,
|
|
70
|
+
connect: {
|
|
71
|
+
keepAlive: false, // Client will be used once
|
|
72
|
+
lookup(...args) {
|
|
73
|
+
didLookup = true;
|
|
74
|
+
unicastLookup(...args);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const headers = new Headers(init?.headers);
|
|
79
|
+
headers.set('connection', 'close'); // Proactively close the connection
|
|
80
|
+
try {
|
|
81
|
+
return await fetch.call(this, input, {
|
|
82
|
+
...init,
|
|
83
|
+
headers,
|
|
84
|
+
// @ts-expect-error non-standard option
|
|
85
|
+
dispatcher,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
// Free resources (we cannot await here since the response was not
|
|
90
|
+
// consumed yet).
|
|
91
|
+
void dispatcher.close().catch((err) => {
|
|
92
|
+
// No biggie, but let's still log it
|
|
93
|
+
console.warn('Failed to close dispatcher', err);
|
|
94
|
+
});
|
|
95
|
+
if (!didLookup) {
|
|
96
|
+
// If you encounter this error, either upgrade to Node.js >=21 or
|
|
97
|
+
// make sure that the dispatcher passed through the requestInit
|
|
98
|
+
// object ends up being used to make the request.
|
|
99
|
+
// eslint-disable-next-line no-unsafe-finally
|
|
100
|
+
throw new fetch_1.FetchRequestError((0, fetch_1.asRequest)(input, init), 500, 'Unable to enforce SSRF protection');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
exports.unicastFetchWrap = unicastFetchWrap;
|
|
109
|
+
function unicastLookup(hostname, options, callback) {
|
|
110
|
+
if (!(0, psl_1.isValid)(hostname)) {
|
|
111
|
+
callback(new Error('Hostname is not a public domain'), '');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
node_dns_1.default.lookup(hostname, options, (err, address, family) => {
|
|
115
|
+
if (err) {
|
|
116
|
+
callback(err, address, family);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const ips = Array.isArray(address)
|
|
120
|
+
? address.map(parseLookupAddress)
|
|
121
|
+
: [parseLookupAddress({ address, family })];
|
|
122
|
+
if (ips.some(isNotUnicast)) {
|
|
123
|
+
callback(new Error('Hostname resolved to non-unicast address'), address, family);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
callback(null, address, family);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
exports.unicastLookup = unicastLookup;
|
|
132
|
+
function isNotUnicast(ip) {
|
|
133
|
+
return ip.range() !== 'unicast';
|
|
134
|
+
}
|
|
135
|
+
function parseLookupAddress({ address, family, }) {
|
|
136
|
+
const ip = family === 4 ? IPv4.parse(address) : IPv6.parse(address);
|
|
137
|
+
if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {
|
|
138
|
+
return ip.toIPv4Address();
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
return ip;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=unicast.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unicast.js","sourceRoot":"","sources":["../src/unicast.ts"],"names":[],"mappings":"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"}
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAkBA,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAGjE"}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isUnicastIp = void 0;
|
|
7
|
+
const ipaddr_js_1 = __importDefault(require("ipaddr.js"));
|
|
8
|
+
const { IPv4, IPv6 } = ipaddr_js_1.default;
|
|
9
|
+
function parseIpHostname(hostname) {
|
|
10
|
+
if (IPv4.isIPv4(hostname)) {
|
|
11
|
+
return IPv4.parse(hostname);
|
|
12
|
+
}
|
|
13
|
+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
14
|
+
return IPv6.parse(hostname.slice(1, -1));
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
function isUnicastIp(hostname) {
|
|
19
|
+
const ip = parseIpHostname(hostname);
|
|
20
|
+
return ip ? ip.range() === 'unicast' : undefined;
|
|
21
|
+
}
|
|
22
|
+
exports.isUnicastIp = isUnicastIp;
|
|
23
|
+
//# sourceMappingURL=util.js.map
|
package/dist/util.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":";;;;;;AAAA,0DAA8B;AAE9B,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,mBAAM,CAAA;AAE7B,SAAS,eAAe,CACtB,QAAgB;IAEhB,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;IAC7B,CAAC;IAED,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QACvD,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;IAC1C,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC;AAED,SAAgB,WAAW,CAAC,QAAgB;IAC1C,MAAM,EAAE,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAA;IACpC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;AAClD,CAAC;AAHD,kCAGC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atproto-labs/fetch-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
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.1",
|
|
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
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
+
asRequest,
|
|
2
3
|
DEFAULT_FORBIDDEN_DOMAIN_NAMES,
|
|
3
4
|
Fetch,
|
|
4
5
|
fetchMaxSizeProcessor,
|
|
5
6
|
forbiddenDomainNameRequestTransform,
|
|
6
7
|
protocolCheckRequestTransform,
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
redirectCheckRequestTransform,
|
|
9
|
+
requireHostHeaderTransform,
|
|
10
|
+
timedFetch,
|
|
11
|
+
toRequestTransformer,
|
|
9
12
|
} from '@atproto-labs/fetch'
|
|
10
|
-
import {
|
|
13
|
+
import { pipe } from '@atproto-labs/pipe'
|
|
11
14
|
|
|
12
|
-
import {
|
|
15
|
+
import { unicastFetchWrap } from './unicast.js'
|
|
13
16
|
|
|
14
17
|
export type SafeFetchWrapOptions = NonNullable<
|
|
15
18
|
Parameters<typeof safeFetchWrap>[0]
|
|
@@ -18,57 +21,72 @@ export type SafeFetchWrapOptions = NonNullable<
|
|
|
18
21
|
/**
|
|
19
22
|
* Wrap a fetch function with safety checks so that it can be safely used
|
|
20
23
|
* with user provided input (URL).
|
|
24
|
+
*
|
|
25
|
+
* @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html}
|
|
21
26
|
*/
|
|
22
|
-
export
|
|
27
|
+
export function safeFetchWrap({
|
|
23
28
|
fetch = globalThis.fetch as Fetch,
|
|
24
29
|
responseMaxSize = 512 * 1024, // 512kB
|
|
25
|
-
allowHttp = false,
|
|
26
|
-
allowData = false,
|
|
27
30
|
ssrfProtection = true,
|
|
28
|
-
|
|
31
|
+
allowCustomPort = !ssrfProtection,
|
|
32
|
+
allowData = false,
|
|
33
|
+
allowHttp = !ssrfProtection,
|
|
34
|
+
allowIpHost = true,
|
|
35
|
+
allowPrivateIps = !ssrfProtection,
|
|
36
|
+
timeout = 10e3,
|
|
29
37
|
forbiddenDomainNames = DEFAULT_FORBIDDEN_DOMAIN_NAMES as Iterable<string>,
|
|
30
|
-
} = {}): Fetch
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.concat(allowHttp ? ['http:'] : [])
|
|
38
|
-
.concat(allowData ? ['data:'] : []),
|
|
39
|
-
),
|
|
38
|
+
} = {}): Fetch<unknown> {
|
|
39
|
+
return toRequestTransformer(
|
|
40
|
+
pipe(
|
|
41
|
+
/**
|
|
42
|
+
* Disable HTTP redirects
|
|
43
|
+
*/
|
|
44
|
+
redirectCheckRequestTransform(),
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Only requests that will be issued with a "Host" header are allowed.
|
|
48
|
+
*/
|
|
49
|
+
allowIpHost ? asRequest : requireHostHeaderTransform(),
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Prevent using http:, file: or data: protocols.
|
|
53
|
+
*/
|
|
54
|
+
protocolCheckRequestTransform({
|
|
55
|
+
'about:': false,
|
|
56
|
+
'data:': allowData,
|
|
57
|
+
'file:': false,
|
|
58
|
+
'http:': allowHttp && { allowCustomPort },
|
|
59
|
+
'https:': { allowCustomPort },
|
|
60
|
+
}),
|
|
53
61
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Disallow fetching from domains we know are not atproto/OIDC client
|
|
64
|
+
* implementation. Note that other domains can be blocked by providing a
|
|
65
|
+
* custom fetch function combined with another
|
|
66
|
+
* forbiddenDomainNameRequestTransform.
|
|
67
|
+
*/
|
|
68
|
+
forbiddenDomainNameRequestTransform(forbiddenDomainNames),
|
|
60
69
|
|
|
61
70
|
/**
|
|
62
71
|
* Since we will be fetching from the network based on user provided
|
|
63
|
-
* input,
|
|
64
|
-
* attacks.
|
|
72
|
+
* input, let's mitigate resource exhaustion attacks by setting a timeout.
|
|
65
73
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
timedFetch(
|
|
75
|
+
timeout,
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Since we will be fetching from the network based on user provided
|
|
79
|
+
* input, we need to make sure that the request is not vulnerable to SSRF
|
|
80
|
+
* attacks.
|
|
81
|
+
*/
|
|
82
|
+
allowPrivateIps ? fetch : unicastFetchWrap({ fetch }),
|
|
83
|
+
),
|
|
68
84
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Since we will be fetching user owned data, we need to make sure that an
|
|
87
|
+
* attacker cannot force us to download a large amounts of data.
|
|
88
|
+
*/
|
|
89
|
+
fetchMaxSizeProcessor(responseMaxSize),
|
|
90
|
+
),
|
|
74
91
|
)
|
|
92
|
+
}
|
package/src/unicast.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import dns, { LookupAddress } from 'node:dns'
|
|
2
|
+
import { LookupFunction } from 'node:net'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
asRequest,
|
|
6
|
+
extractUrl,
|
|
7
|
+
Fetch,
|
|
8
|
+
FetchContext,
|
|
9
|
+
FetchRequestError,
|
|
10
|
+
} from '@atproto-labs/fetch'
|
|
11
|
+
import ipaddr from 'ipaddr.js'
|
|
12
|
+
import { isValid as isValidDomain } from 'psl'
|
|
13
|
+
import { Agent, Client } from 'undici'
|
|
14
|
+
|
|
15
|
+
import { isUnicastIp } from './util.js'
|
|
16
|
+
|
|
17
|
+
const { IPv4, IPv6 } = ipaddr
|
|
18
|
+
|
|
19
|
+
export type SsrfFetchWrapOptions<C = FetchContext> = {
|
|
20
|
+
fetch?: Fetch<C>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
|
|
25
|
+
*/
|
|
26
|
+
export function unicastFetchWrap<C = FetchContext>({
|
|
27
|
+
fetch = globalThis.fetch,
|
|
28
|
+
}: SsrfFetchWrapOptions<C>): Fetch<C> {
|
|
29
|
+
// In order to enforce the SSRF protection, we need to use a custom dispatcher
|
|
30
|
+
// that uses "unicastLookup" to resolve the hostname to a unicast IP address.
|
|
31
|
+
|
|
32
|
+
// In case a custom "fetch" function is passed here, we have no assurance that
|
|
33
|
+
// the dispatcher will be used to make the request. Because of this, in case a
|
|
34
|
+
// custom fetch method is passed, we will use a on-time use dispatcher that
|
|
35
|
+
// ensures that "unicastLookup" gets called to resolve the hostname to an IP
|
|
36
|
+
// address and ensure that it is a unicast address.
|
|
37
|
+
|
|
38
|
+
// Sadly, this means that we cannot use "keepAlive" connections, as the method
|
|
39
|
+
// used to ensure that "unicastLookup" gets called requires to create a new
|
|
40
|
+
// dispatcher for each request.
|
|
41
|
+
|
|
42
|
+
// @TODO: find a way to use a re-usable dispatcher with a custom fetch method.
|
|
43
|
+
|
|
44
|
+
if (fetch === globalThis.fetch) {
|
|
45
|
+
const dispatcher = new Agent({
|
|
46
|
+
allowH2: true,
|
|
47
|
+
connect: { keepAlive: true, lookup: unicastLookup },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return async function (input, init): Promise<Response> {
|
|
51
|
+
if (init?.dispatcher) {
|
|
52
|
+
throw new FetchRequestError(
|
|
53
|
+
asRequest(input, init),
|
|
54
|
+
500,
|
|
55
|
+
'SSRF protection cannot be used with a custom request dispatcher',
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const url = extractUrl(input)
|
|
60
|
+
|
|
61
|
+
if (url.hostname && isUnicastIp(url.hostname) === false) {
|
|
62
|
+
throw new FetchRequestError(
|
|
63
|
+
asRequest(input, init),
|
|
64
|
+
400,
|
|
65
|
+
'Hostname is a non-unicast address',
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// @ts-expect-error non-standard option
|
|
70
|
+
return fetch.call(this, input, { ...init, dispatcher })
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
return async function (input, init): Promise<Response> {
|
|
74
|
+
if (init?.dispatcher) {
|
|
75
|
+
throw new FetchRequestError(
|
|
76
|
+
asRequest(input, init),
|
|
77
|
+
500,
|
|
78
|
+
'SSRF protection cannot be used with a custom request dispatcher',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const url = extractUrl(input)
|
|
83
|
+
|
|
84
|
+
if (!url.hostname) {
|
|
85
|
+
return fetch.call(this, input, init)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
switch (isUnicastIp(url.hostname)) {
|
|
89
|
+
case true: {
|
|
90
|
+
// hostname is a unicast address, safe to proceed.
|
|
91
|
+
return fetch.call(this, input, init)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case false: {
|
|
95
|
+
throw new FetchRequestError(
|
|
96
|
+
asRequest(input, init),
|
|
97
|
+
400,
|
|
98
|
+
'Hostname is a non-unicast address',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case undefined: {
|
|
103
|
+
// hostname is a domain name, using the dispatcher defined above
|
|
104
|
+
// will result in the DNS lookup being performed, ensuring that the
|
|
105
|
+
// hostname resolves to a unicast address.
|
|
106
|
+
|
|
107
|
+
let didLookup = false
|
|
108
|
+
const dispatcher = new Client(url.origin, {
|
|
109
|
+
allowH2: true,
|
|
110
|
+
connect: {
|
|
111
|
+
keepAlive: false, // Client will be used once
|
|
112
|
+
lookup(...args) {
|
|
113
|
+
didLookup = true
|
|
114
|
+
unicastLookup(...args)
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const headers = new Headers(init?.headers)
|
|
120
|
+
headers.set('connection', 'close') // Proactively close the connection
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
return await fetch.call(this, input, {
|
|
124
|
+
...init,
|
|
125
|
+
headers,
|
|
126
|
+
// @ts-expect-error non-standard option
|
|
127
|
+
dispatcher,
|
|
128
|
+
})
|
|
129
|
+
} finally {
|
|
130
|
+
// Free resources (we cannot await here since the response was not
|
|
131
|
+
// consumed yet).
|
|
132
|
+
void dispatcher.close().catch((err) => {
|
|
133
|
+
// No biggie, but let's still log it
|
|
134
|
+
console.warn('Failed to close dispatcher', err)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if (!didLookup) {
|
|
138
|
+
// If you encounter this error, either upgrade to Node.js >=21 or
|
|
139
|
+
// make sure that the dispatcher passed through the requestInit
|
|
140
|
+
// object ends up being used to make the request.
|
|
141
|
+
|
|
142
|
+
// eslint-disable-next-line no-unsafe-finally
|
|
143
|
+
throw new FetchRequestError(
|
|
144
|
+
asRequest(input, init),
|
|
145
|
+
500,
|
|
146
|
+
'Unable to enforce SSRF protection',
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function unicastLookup(
|
|
157
|
+
hostname: string,
|
|
158
|
+
options: dns.LookupOptions,
|
|
159
|
+
callback: Parameters<LookupFunction>[2],
|
|
160
|
+
) {
|
|
161
|
+
if (!isValidDomain(hostname)) {
|
|
162
|
+
callback(new Error('Hostname is not a public domain'), '')
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
dns.lookup(hostname, options, (err, address, family) => {
|
|
167
|
+
if (err) {
|
|
168
|
+
callback(err, address, family)
|
|
169
|
+
} else {
|
|
170
|
+
const ips = Array.isArray(address)
|
|
171
|
+
? address.map(parseLookupAddress)
|
|
172
|
+
: [parseLookupAddress({ address, family })]
|
|
173
|
+
|
|
174
|
+
if (ips.some(isNotUnicast)) {
|
|
175
|
+
callback(
|
|
176
|
+
new Error('Hostname resolved to non-unicast address'),
|
|
177
|
+
address,
|
|
178
|
+
family,
|
|
179
|
+
)
|
|
180
|
+
} else {
|
|
181
|
+
callback(null, address, family)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isNotUnicast(ip: ipaddr.IPv4 | ipaddr.IPv6): boolean {
|
|
188
|
+
return ip.range() !== 'unicast'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseLookupAddress({
|
|
192
|
+
address,
|
|
193
|
+
family,
|
|
194
|
+
}: LookupAddress): ipaddr.IPv4 | ipaddr.IPv6 {
|
|
195
|
+
const ip = family === 4 ? IPv4.parse(address) : IPv6.parse(address)
|
|
196
|
+
|
|
197
|
+
if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {
|
|
198
|
+
return ip.toIPv4Address()
|
|
199
|
+
} else {
|
|
200
|
+
return ip
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import ipaddr from 'ipaddr.js'
|
|
2
|
+
|
|
3
|
+
const { IPv4, IPv6 } = ipaddr
|
|
4
|
+
|
|
5
|
+
function parseIpHostname(
|
|
6
|
+
hostname: string,
|
|
7
|
+
): ipaddr.IPv4 | ipaddr.IPv6 | undefined {
|
|
8
|
+
if (IPv4.isIPv4(hostname)) {
|
|
9
|
+
return IPv4.parse(hostname)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
13
|
+
return IPv6.parse(hostname.slice(1, -1))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isUnicastIp(hostname: string): boolean | undefined {
|
|
20
|
+
const ip = parseIpHostname(hostname)
|
|
21
|
+
return ip ? ip.range() === 'unicast' : undefined
|
|
22
|
+
}
|
package/tsconfig.json
CHANGED
package/dist/ssrf.d.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
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;
|
|
6
|
-
//# sourceMappingURL=ssrf.d.ts.map
|
package/dist/ssrf.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
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"}
|
package/dist/ssrf.js
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
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
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
-
};
|
|
28
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
-
exports.ssrfFetchWrap = void 0;
|
|
30
|
-
const node_dns_1 = __importStar(require("node:dns"));
|
|
31
|
-
const fetch_1 = require("@atproto-labs/fetch");
|
|
32
|
-
const ipaddr_js_1 = __importDefault(require("ipaddr.js"));
|
|
33
|
-
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:') {
|
|
38
|
-
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 });
|
|
41
|
-
}
|
|
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,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
}
|
|
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
|
-
};
|
|
71
|
-
exports.ssrfFetchWrap = ssrfFetchWrap;
|
|
72
|
-
async function hostnameLookup(hostname) {
|
|
73
|
-
if (IPv4.isIPv4(hostname)) {
|
|
74
|
-
return IPv4.parse(hostname);
|
|
75
|
-
}
|
|
76
|
-
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
77
|
-
return IPv6.parse(hostname.slice(1, -1));
|
|
78
|
-
}
|
|
79
|
-
return domainLookup(hostname);
|
|
80
|
-
}
|
|
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,
|
|
84
|
-
});
|
|
85
|
-
const ip = addr.family === 4 ? IPv4.parse(addr.address) : IPv6.parse(addr.address);
|
|
86
|
-
if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {
|
|
87
|
-
return ip.toIPv4Address();
|
|
88
|
-
}
|
|
89
|
-
return ip;
|
|
90
|
-
}
|
|
91
|
-
//# sourceMappingURL=ssrf.js.map
|
package/dist/ssrf.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ssrf.js","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"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"}
|
package/src/ssrf.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import dns, { promises as dnsPromises } from 'node:dns'
|
|
2
|
-
|
|
3
|
-
import { Fetch, FetchError } from '@atproto-labs/fetch'
|
|
4
|
-
import ipaddr from 'ipaddr.js'
|
|
5
|
-
|
|
6
|
-
const { IPv4, IPv6 } = ipaddr
|
|
7
|
-
|
|
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
|
-
|
|
17
|
-
if (protocol === 'http:' || protocol === 'https:') {
|
|
18
|
-
if (request.redirect === 'follow') {
|
|
19
|
-
// TODO: actually implement by calling ssrfSafeFetch recursively (?)
|
|
20
|
-
throw new FetchError(
|
|
21
|
-
500,
|
|
22
|
-
'Request redirect must be "error" or "manual" when SSRF is enabled',
|
|
23
|
-
{ request },
|
|
24
|
-
)
|
|
25
|
-
}
|
|
26
|
-
|
|
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}`, {
|
|
41
|
-
request,
|
|
42
|
-
})
|
|
43
|
-
}
|
|
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
|
-
|
|
51
|
-
return fetch(request)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return ssrfSafeFetch
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function hostnameLookup(
|
|
58
|
-
hostname: string,
|
|
59
|
-
): Promise<ipaddr.IPv4 | ipaddr.IPv6> {
|
|
60
|
-
if (IPv4.isIPv4(hostname)) {
|
|
61
|
-
return IPv4.parse(hostname)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
65
|
-
return IPv6.parse(hostname.slice(1, -1))
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return domainLookup(hostname)
|
|
69
|
-
}
|
|
70
|
-
|
|
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,
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
const ip =
|
|
79
|
-
addr.family === 4 ? IPv4.parse(addr.address) : IPv6.parse(addr.address)
|
|
80
|
-
|
|
81
|
-
if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {
|
|
82
|
-
return ip.toIPv4Address()
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return ip
|
|
86
|
-
}
|