@atproto-labs/fetch-node 0.0.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 +11 -0
- package/LICENSE.txt +7 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/safe.d.ts +16 -0
- package/dist/safe.d.ts.map +1 -0
- package/dist/safe.js +49 -0
- package/dist/safe.js.map +1 -0
- package/dist/ssrf.d.ts +6 -0
- package/dist/ssrf.d.ts.map +1 -0
- package/dist/ssrf.js +91 -0
- package/dist/ssrf.js.map +1 -0
- package/package.json +38 -0
- package/src/index.ts +2 -0
- package/src/safe.ts +74 -0
- package/src/ssrf.ts +86 -0
- package/tsconfig.json +8 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @atproto-labs/fetch-node
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
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
|
|
8
|
+
|
|
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
|
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Dual MIT/Apache-2.0 License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2024 Bluesky PBC, and Contributors
|
|
4
|
+
|
|
5
|
+
Except as otherwise noted in individual files, this software is licensed under the MIT license (<http://opensource.org/licenses/MIT>), or the Apache License, Version 2.0 (<http://www.apache.org/licenses/LICENSE-2.0>).
|
|
6
|
+
|
|
7
|
+
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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 __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./safe.js"), exports);
|
|
18
|
+
__exportStar(require("./ssrf.js"), exports);
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4CAAyB;AACzB,4CAAyB"}
|
package/dist/safe.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Fetch } from '@atproto-labs/fetch';
|
|
2
|
+
export type SafeFetchWrapOptions = NonNullable<Parameters<typeof safeFetchWrap>[0]>;
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a fetch function with safety checks so that it can be safely used
|
|
5
|
+
* with user provided input (URL).
|
|
6
|
+
*/
|
|
7
|
+
export declare const safeFetchWrap: ({ fetch, responseMaxSize, allowHttp, allowData, ssrfProtection, timeout, forbiddenDomainNames, }?: {
|
|
8
|
+
fetch?: Fetch | undefined;
|
|
9
|
+
responseMaxSize?: number | undefined;
|
|
10
|
+
allowHttp?: boolean | undefined;
|
|
11
|
+
allowData?: boolean | undefined;
|
|
12
|
+
ssrfProtection?: boolean | undefined;
|
|
13
|
+
timeout?: number | undefined;
|
|
14
|
+
forbiddenDomainNames?: Iterable<string> | undefined;
|
|
15
|
+
}) => Fetch;
|
|
16
|
+
//# sourceMappingURL=safe.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/safe.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.safeFetchWrap = void 0;
|
|
4
|
+
const fetch_1 = require("@atproto-labs/fetch");
|
|
5
|
+
const transformer_1 = require("@atproto-labs/transformer");
|
|
6
|
+
const ssrf_js_1 = require("./ssrf.js");
|
|
7
|
+
/**
|
|
8
|
+
* Wrap a fetch function with safety checks so that it can be safely used
|
|
9
|
+
* with user provided input (URL).
|
|
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,
|
|
36
|
+
/**
|
|
37
|
+
* Since we will be fetching from the network based on user provided
|
|
38
|
+
* input, we need to make sure that the request is not vulnerable to SSRF
|
|
39
|
+
* attacks.
|
|
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));
|
|
48
|
+
exports.safeFetchWrap = safeFetchWrap;
|
|
49
|
+
//# sourceMappingURL=safe.js.map
|
package/dist/safe.js.map
ADDED
|
@@ -0,0 +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"}
|
package/dist/ssrf.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
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
|
|
@@ -0,0 +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"}
|
package/dist/ssrf.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrf.js","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atproto-labs/fetch-node",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "SSRF protection for fetch() in Node.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"atproto",
|
|
8
|
+
"fetch",
|
|
9
|
+
"node"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://atproto.com",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/bluesky-social/atproto",
|
|
15
|
+
"directory": "packages/fetch-node"
|
|
16
|
+
},
|
|
17
|
+
"type": "commonjs",
|
|
18
|
+
"main": "dist/index.js",
|
|
19
|
+
"types": "dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
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"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.3.3"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc --build tsconfig.json"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.ts
ADDED
package/src/safe.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_FORBIDDEN_DOMAIN_NAMES,
|
|
3
|
+
Fetch,
|
|
4
|
+
fetchMaxSizeProcessor,
|
|
5
|
+
forbiddenDomainNameRequestTransform,
|
|
6
|
+
protocolCheckRequestTransform,
|
|
7
|
+
requireHostHeaderTranform,
|
|
8
|
+
timeoutFetchWrap,
|
|
9
|
+
} from '@atproto-labs/fetch'
|
|
10
|
+
import { compose } from '@atproto-labs/transformer'
|
|
11
|
+
|
|
12
|
+
import { ssrfFetchWrap } from './ssrf.js'
|
|
13
|
+
|
|
14
|
+
export type SafeFetchWrapOptions = NonNullable<
|
|
15
|
+
Parameters<typeof safeFetchWrap>[0]
|
|
16
|
+
>
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wrap a fetch function with safety checks so that it can be safely used
|
|
20
|
+
* with user provided input (URL).
|
|
21
|
+
*/
|
|
22
|
+
export const safeFetchWrap = ({
|
|
23
|
+
fetch = globalThis.fetch as Fetch,
|
|
24
|
+
responseMaxSize = 512 * 1024, // 512kB
|
|
25
|
+
allowHttp = false,
|
|
26
|
+
allowData = false,
|
|
27
|
+
ssrfProtection = true,
|
|
28
|
+
timeout = 10e3 as number,
|
|
29
|
+
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(),
|
|
45
|
+
|
|
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),
|
|
53
|
+
|
|
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,
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 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.
|
|
65
|
+
*/
|
|
66
|
+
fetch: ssrfProtection ? ssrfFetchWrap({ fetch }) : fetch,
|
|
67
|
+
}),
|
|
68
|
+
|
|
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),
|
|
74
|
+
)
|
package/src/ssrf.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
}
|