@x12i/api-hub-safe-fetch 0.0.0-stage → 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/README.md +10 -2
- package/dist/address.d.ts +2 -0
- package/dist/address.js +79 -0
- package/dist/address.js.map +1 -0
- package/dist/fake.d.ts +18 -0
- package/dist/fake.js +65 -0
- package/dist/fake.js.map +1 -0
- package/dist/fetch.d.ts +22 -0
- package/dist/fetch.js +259 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/robots.d.ts +35 -0
- package/dist/robots.js +104 -0
- package/dist/robots.js.map +1 -0
- package/package.json +35 -4
package/README.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @x12i/api-hub-safe-fetch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SSRF-safe HTTP fetch for the x12i API hub
|
|
4
|
+
|
|
5
|
+
ESM package for Node.js 24 or newer. Import the public name:
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import * as safeFetch from '@x12i/api-hub-safe-fetch';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The whole set is also available from [`@x12i/api-hub`](https://www.npmjs.com/package/@x12i/api-hub).
|
package/dist/address.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { BlockList, isIP } from 'node:net';
|
|
2
|
+
const blocked = new BlockList();
|
|
3
|
+
const V4 = [
|
|
4
|
+
['0.0.0.0', 8], // "this" network
|
|
5
|
+
['10.0.0.0', 8],
|
|
6
|
+
['100.64.0.0', 10], // CGNAT
|
|
7
|
+
['127.0.0.0', 8],
|
|
8
|
+
['169.254.0.0', 16], // link-local, cloud metadata
|
|
9
|
+
['172.16.0.0', 12],
|
|
10
|
+
['192.0.0.0', 24], // IETF protocol assignments
|
|
11
|
+
['192.0.2.0', 24], // TEST-NET-1
|
|
12
|
+
['192.88.99.0', 24], // 6to4 relay anycast
|
|
13
|
+
['192.168.0.0', 16],
|
|
14
|
+
['198.18.0.0', 15], // benchmarking
|
|
15
|
+
['198.51.100.0', 24], // TEST-NET-2
|
|
16
|
+
['203.0.113.0', 24], // TEST-NET-3
|
|
17
|
+
['224.0.0.0', 4], // multicast
|
|
18
|
+
['240.0.0.0', 4], // reserved, broadcast
|
|
19
|
+
];
|
|
20
|
+
const V6 = [
|
|
21
|
+
['::', 128], // unspecified
|
|
22
|
+
['::1', 128], // loopback
|
|
23
|
+
['::', 96], // IPv4-compatible (deprecated)
|
|
24
|
+
['64:ff9b::', 96], // NAT64
|
|
25
|
+
['64:ff9b:1::', 48], // local-use NAT64
|
|
26
|
+
['100::', 64], // discard
|
|
27
|
+
['2001::', 32], // Teredo
|
|
28
|
+
['2001:2::', 48], // benchmarking
|
|
29
|
+
['2001:db8::', 32], // documentation
|
|
30
|
+
['2001:10::', 28], // ORCHID
|
|
31
|
+
['2002::', 16], // 6to4 (can embed any IPv4 address)
|
|
32
|
+
['3fff::', 20], // documentation
|
|
33
|
+
['5f00::', 16], // SRv6 SIDs
|
|
34
|
+
['fc00::', 7], // unique local
|
|
35
|
+
['fe80::', 10], // link-local
|
|
36
|
+
['fec0::', 10], // site-local (deprecated)
|
|
37
|
+
['ff00::', 8], // multicast
|
|
38
|
+
];
|
|
39
|
+
for (const [net, prefix] of V4)
|
|
40
|
+
blocked.addSubnet(net, prefix, 'ipv4');
|
|
41
|
+
for (const [net, prefix] of V6)
|
|
42
|
+
blocked.addSubnet(net, prefix, 'ipv6');
|
|
43
|
+
/** Eight 16-bit groups for any valid IPv6 literal, including embedded dotted IPv4 and zone ids. */
|
|
44
|
+
function expandV6(ip) {
|
|
45
|
+
let text = ip.toLowerCase().replace(/%.*$/, '');
|
|
46
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
47
|
+
if (dotted) {
|
|
48
|
+
const [a, b, c, d] = dotted[1].split('.').map(Number);
|
|
49
|
+
text = `${text.slice(0, -dotted[1].length)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
50
|
+
}
|
|
51
|
+
const [head, tail] = text.split('::');
|
|
52
|
+
const left = head ? head.split(':') : [];
|
|
53
|
+
const right = tail ? tail.split(':') : [];
|
|
54
|
+
const fill = tail === undefined ? [] : Array(8 - left.length - right.length).fill('0');
|
|
55
|
+
return [...left, ...fill, ...right].map((h) => parseInt(h || '0', 16));
|
|
56
|
+
}
|
|
57
|
+
function embeddedV4(ip) {
|
|
58
|
+
const g = expandV6(ip);
|
|
59
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
|
|
60
|
+
if (!mapped)
|
|
61
|
+
return undefined;
|
|
62
|
+
const hi = g[6];
|
|
63
|
+
const lo = g[7];
|
|
64
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
65
|
+
}
|
|
66
|
+
/** True only for globally routable unicast addresses. Anything that is not an IP literal is not public. */
|
|
67
|
+
export function isPublicAddress(ip) {
|
|
68
|
+
const family = isIP(ip);
|
|
69
|
+
if (family === 4)
|
|
70
|
+
return !blocked.check(ip, 'ipv4');
|
|
71
|
+
if (family === 6) {
|
|
72
|
+
const v4 = embeddedV4(ip);
|
|
73
|
+
if (v4 !== undefined)
|
|
74
|
+
return isIP(v4) === 4 && !blocked.check(v4, 'ipv4');
|
|
75
|
+
return !blocked.check(ip, 'ipv6');
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=address.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"address.js","sourceRoot":"","sources":["../../../../../packages/safe-fetch/src/address.ts"],"names":[],"mappings":"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","sourcesContent":["import { BlockList, isIP } from 'node:net';\n\nconst blocked = new BlockList();\n\nconst V4: [string, number][] = [\n ['0.0.0.0', 8], // \"this\" network\n ['10.0.0.0', 8],\n ['100.64.0.0', 10], // CGNAT\n ['127.0.0.0', 8],\n ['169.254.0.0', 16], // link-local, cloud metadata\n ['172.16.0.0', 12],\n ['192.0.0.0', 24], // IETF protocol assignments\n ['192.0.2.0', 24], // TEST-NET-1\n ['192.88.99.0', 24], // 6to4 relay anycast\n ['192.168.0.0', 16],\n ['198.18.0.0', 15], // benchmarking\n ['198.51.100.0', 24], // TEST-NET-2\n ['203.0.113.0', 24], // TEST-NET-3\n ['224.0.0.0', 4], // multicast\n ['240.0.0.0', 4], // reserved, broadcast\n];\n\nconst V6: [string, number][] = [\n ['::', 128], // unspecified\n ['::1', 128], // loopback\n ['::', 96], // IPv4-compatible (deprecated)\n ['64:ff9b::', 96], // NAT64\n ['64:ff9b:1::', 48], // local-use NAT64\n ['100::', 64], // discard\n ['2001::', 32], // Teredo\n ['2001:2::', 48], // benchmarking\n ['2001:db8::', 32], // documentation\n ['2001:10::', 28], // ORCHID\n ['2002::', 16], // 6to4 (can embed any IPv4 address)\n ['3fff::', 20], // documentation\n ['5f00::', 16], // SRv6 SIDs\n ['fc00::', 7], // unique local\n ['fe80::', 10], // link-local\n ['fec0::', 10], // site-local (deprecated)\n ['ff00::', 8], // multicast\n];\n\nfor (const [net, prefix] of V4) blocked.addSubnet(net, prefix, 'ipv4');\nfor (const [net, prefix] of V6) blocked.addSubnet(net, prefix, 'ipv6');\n\n/** Eight 16-bit groups for any valid IPv6 literal, including embedded dotted IPv4 and zone ids. */\nfunction expandV6(ip: string): number[] {\n let text = ip.toLowerCase().replace(/%.*$/, '');\n const dotted = /(\\d+\\.\\d+\\.\\d+\\.\\d+)$/.exec(text);\n if (dotted) {\n const [a, b, c, d] = dotted[1]!.split('.').map(Number) as [number, number, number, number];\n text = `${text.slice(0, -dotted[1]!.length)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;\n }\n const [head, tail] = text.split('::') as [string, string | undefined];\n const left = head ? head.split(':') : [];\n const right = tail ? tail.split(':') : [];\n const fill = tail === undefined ? [] : Array<string>(8 - left.length - right.length).fill('0');\n return [...left, ...fill, ...right].map((h) => parseInt(h || '0', 16));\n}\n\nfunction embeddedV4(ip: string): string | undefined {\n const g = expandV6(ip);\n const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;\n if (!mapped) return undefined;\n const hi = g[6]!;\n const lo = g[7]!;\n return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;\n}\n\n/** True only for globally routable unicast addresses. Anything that is not an IP literal is not public. */\nexport function isPublicAddress(ip: string): boolean {\n const family = isIP(ip);\n if (family === 4) return !blocked.check(ip, 'ipv4');\n if (family === 6) {\n const v4 = embeddedV4(ip);\n if (v4 !== undefined) return isIP(v4) === 4 && !blocked.check(v4, 'ipv4');\n return !blocked.check(ip, 'ipv6');\n }\n return false;\n}\n"]}
|
package/dist/fake.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { HubError, type SafeFetch, type SafeFetchOptions } from '@x12i/api-hub-core';
|
|
2
|
+
export interface FakeRoute {
|
|
3
|
+
status?: number;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
body?: string | Uint8Array;
|
|
6
|
+
/** Shorthand for a JSON body with `content-type: application/json`. */
|
|
7
|
+
json?: unknown;
|
|
8
|
+
/** Simulated failure instead of a response. */
|
|
9
|
+
error?: HubError;
|
|
10
|
+
}
|
|
11
|
+
export type FakeRoutes = Record<string, FakeRoute | ((options: SafeFetchOptions) => FakeRoute)>;
|
|
12
|
+
/** Recorded-response fetcher for capability tests. Follows redirects like the real one; unknown URLs fail. */
|
|
13
|
+
export declare function fakeFetch(routes: FakeRoutes): SafeFetch & {
|
|
14
|
+
calls: {
|
|
15
|
+
url: string;
|
|
16
|
+
options: SafeFetchOptions;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
package/dist/fake.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HubError } from '@x12i/api-hub-core';
|
|
2
|
+
import { withoutCredentials } from "./fetch.js";
|
|
3
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
4
|
+
/** Recorded-response fetcher for capability tests. Follows redirects like the real one; unknown URLs fail. */
|
|
5
|
+
export function fakeFetch(routes) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const fn = async (rawUrl, options = {}) => {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(rawUrl).toString();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new HubError('invalid_input', 'Not a valid URL');
|
|
14
|
+
}
|
|
15
|
+
const maxRedirects = options.maxRedirects ?? 5;
|
|
16
|
+
const redirects = [];
|
|
17
|
+
for (;;) {
|
|
18
|
+
calls.push({ url, options });
|
|
19
|
+
const entry = routes[url];
|
|
20
|
+
if (!entry)
|
|
21
|
+
throw new HubError('upstream_unavailable', 'Could not fetch the URL', { reason: 'ENOTFOUND' });
|
|
22
|
+
const route = typeof entry === 'function' ? entry(options) : entry;
|
|
23
|
+
if (route.error)
|
|
24
|
+
throw route.error;
|
|
25
|
+
const status = route.status ?? 200;
|
|
26
|
+
const headers = Object.fromEntries(Object.entries(route.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]));
|
|
27
|
+
if (route.json !== undefined)
|
|
28
|
+
headers['content-type'] ??= 'application/json';
|
|
29
|
+
if (REDIRECTS.has(status) && headers.location && maxRedirects > 0) {
|
|
30
|
+
if (redirects.length >= maxRedirects)
|
|
31
|
+
throw new HubError('limit_exceeded', `More than ${maxRedirects} redirects`);
|
|
32
|
+
redirects.push(url);
|
|
33
|
+
const next = new URL(headers.location, url);
|
|
34
|
+
if (next.origin !== new URL(url).origin)
|
|
35
|
+
options = { ...options, headers: withoutCredentials(options.headers) };
|
|
36
|
+
url = next.toString();
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const raw = route.json !== undefined ? JSON.stringify(route.json) : route.body;
|
|
40
|
+
let bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : (raw ?? new Uint8Array());
|
|
41
|
+
if (options.method === 'HEAD')
|
|
42
|
+
bytes = new Uint8Array();
|
|
43
|
+
const maxBytes = options.maxBytes ?? 5 * 1024 * 1024;
|
|
44
|
+
const truncated = bytes.length > maxBytes;
|
|
45
|
+
if (truncated && !options.truncate)
|
|
46
|
+
throw new HubError('limit_exceeded', `Response is larger than ${maxBytes} bytes`);
|
|
47
|
+
if (truncated)
|
|
48
|
+
bytes = bytes.slice(0, maxBytes);
|
|
49
|
+
return {
|
|
50
|
+
requestedUrl: rawUrl,
|
|
51
|
+
url,
|
|
52
|
+
status,
|
|
53
|
+
headers,
|
|
54
|
+
redirects,
|
|
55
|
+
remoteAddress: '203.0.113.10',
|
|
56
|
+
body: bytes,
|
|
57
|
+
truncated,
|
|
58
|
+
text: () => Buffer.from(bytes).toString('utf8'),
|
|
59
|
+
json: () => JSON.parse(Buffer.from(bytes).toString('utf8')),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return Object.assign(fn, { calls });
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=fake.js.map
|
package/dist/fake.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake.js","sourceRoot":"","sources":["../../../../../packages/safe-fetch/src/fake.ts"],"names":[],"mappings":"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","sourcesContent":["import { HubError, type SafeFetch, type SafeFetchOptions, type SafeResponse } from '@x12i/api-hub-core';\nimport { withoutCredentials } from './fetch.ts';\n\nexport interface FakeRoute {\n status?: number;\n headers?: Record<string, string>;\n body?: string | Uint8Array;\n /** Shorthand for a JSON body with `content-type: application/json`. */\n json?: unknown;\n /** Simulated failure instead of a response. */\n error?: HubError;\n}\n\nexport type FakeRoutes = Record<string, FakeRoute | ((options: SafeFetchOptions) => FakeRoute)>;\n\nconst REDIRECTS = new Set([301, 302, 303, 307, 308]);\n\n/** Recorded-response fetcher for capability tests. Follows redirects like the real one; unknown URLs fail. */\nexport function fakeFetch(routes: FakeRoutes): SafeFetch & { calls: { url: string; options: SafeFetchOptions }[] } {\n const calls: { url: string; options: SafeFetchOptions }[] = [];\n const fn = async (rawUrl: string, options: SafeFetchOptions = {}): Promise<SafeResponse> => {\n let url: string;\n try {\n url = new URL(rawUrl).toString();\n } catch {\n throw new HubError('invalid_input', 'Not a valid URL');\n }\n const maxRedirects = options.maxRedirects ?? 5;\n const redirects: string[] = [];\n for (;;) {\n calls.push({ url, options });\n const entry = routes[url];\n if (!entry) throw new HubError('upstream_unavailable', 'Could not fetch the URL', { reason: 'ENOTFOUND' });\n const route = typeof entry === 'function' ? entry(options) : entry;\n if (route.error) throw route.error;\n const status = route.status ?? 200;\n const headers = Object.fromEntries(Object.entries(route.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]));\n if (route.json !== undefined) headers['content-type'] ??= 'application/json';\n if (REDIRECTS.has(status) && headers.location && maxRedirects > 0) {\n if (redirects.length >= maxRedirects) throw new HubError('limit_exceeded', `More than ${maxRedirects} redirects`);\n redirects.push(url);\n const next = new URL(headers.location, url);\n if (next.origin !== new URL(url).origin) options = { ...options, headers: withoutCredentials(options.headers) };\n url = next.toString();\n continue;\n }\n const raw = route.json !== undefined ? JSON.stringify(route.json) : route.body;\n let bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : (raw ?? new Uint8Array());\n if (options.method === 'HEAD') bytes = new Uint8Array();\n const maxBytes = options.maxBytes ?? 5 * 1024 * 1024;\n const truncated = bytes.length > maxBytes;\n if (truncated && !options.truncate) throw new HubError('limit_exceeded', `Response is larger than ${maxBytes} bytes`);\n if (truncated) bytes = bytes.slice(0, maxBytes);\n return {\n requestedUrl: rawUrl,\n url,\n status,\n headers,\n redirects,\n remoteAddress: '203.0.113.10',\n body: bytes,\n truncated,\n text: () => Buffer.from(bytes).toString('utf8'),\n json: <T>() => JSON.parse(Buffer.from(bytes).toString('utf8')) as T,\n };\n }\n };\n return Object.assign(fn, { calls });\n}\n"]}
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type SafeFetch } from '@x12i/api-hub-core';
|
|
2
|
+
export interface ResolvedAddress {
|
|
3
|
+
address: string;
|
|
4
|
+
family: 4 | 6;
|
|
5
|
+
}
|
|
6
|
+
export type Resolver = (hostname: string) => Promise<ResolvedAddress[]>;
|
|
7
|
+
export interface SafeFetchConfig {
|
|
8
|
+
/** Default byte cap on the decoded body. */
|
|
9
|
+
maxBytes?: number;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
maxRedirects?: number;
|
|
12
|
+
allowedPorts?: number[];
|
|
13
|
+
userAgent?: string;
|
|
14
|
+
/** Product token matched against robots.txt groups. */
|
|
15
|
+
robotsToken?: string;
|
|
16
|
+
resolve?: Resolver;
|
|
17
|
+
/** Decides whether an address may be contacted. Defaults to public unicast only. */
|
|
18
|
+
isAllowedAddress?: (address: string, hostname: string) => boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Credentials are never forwarded to a different origin on redirect. */
|
|
21
|
+
export declare function withoutCredentials(headers: Record<string, string> | undefined): Record<string, string> | undefined;
|
|
22
|
+
export declare function createSafeFetch(config?: SafeFetchConfig): SafeFetch;
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
|
|
5
|
+
import { Agent, request } from 'undici';
|
|
6
|
+
import { HubError } from '@x12i/api-hub-core';
|
|
7
|
+
import { isPublicAddress } from "./address.js";
|
|
8
|
+
import { ALLOW_ALL, DISALLOW_ALL, parseRobots } from "./robots.js";
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
maxBytes: 5 * 1024 * 1024,
|
|
11
|
+
timeoutMs: 15_000,
|
|
12
|
+
maxRedirects: 5,
|
|
13
|
+
allowedPorts: [80, 443, 8080, 8443],
|
|
14
|
+
userAgent: 'x12i-hub/0.1 (+https://x12i.com/bot)',
|
|
15
|
+
robotsToken: 'x12i-hub',
|
|
16
|
+
};
|
|
17
|
+
const defaultResolve = async (hostname) => (await dnsLookup(hostname, { all: true, verbatim: true })).map((a) => ({ address: a.address, family: a.family }));
|
|
18
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
19
|
+
function invalid(message, details) {
|
|
20
|
+
throw new HubError('invalid_input', message, details);
|
|
21
|
+
}
|
|
22
|
+
function parseTarget(raw, allowedPorts) {
|
|
23
|
+
let url;
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(raw);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return invalid('Not a valid URL');
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
31
|
+
invalid('Only http and https URLs are allowed');
|
|
32
|
+
if (url.username || url.password)
|
|
33
|
+
invalid('URLs with credentials are not allowed');
|
|
34
|
+
const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80;
|
|
35
|
+
if (!allowedPorts.includes(port))
|
|
36
|
+
invalid(`Port ${port} is not allowed`);
|
|
37
|
+
url.hash = '';
|
|
38
|
+
return url;
|
|
39
|
+
}
|
|
40
|
+
const CREDENTIAL_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
|
41
|
+
/** Credentials are never forwarded to a different origin on redirect. */
|
|
42
|
+
export function withoutCredentials(headers) {
|
|
43
|
+
if (!headers)
|
|
44
|
+
return headers;
|
|
45
|
+
return Object.fromEntries(Object.entries(headers).filter(([k]) => !CREDENTIAL_HEADERS.has(k.toLowerCase())));
|
|
46
|
+
}
|
|
47
|
+
function headerRecord(headers) {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
50
|
+
if (v === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
const key = k.toLowerCase();
|
|
53
|
+
// Set-Cookie values contain commas (Expires dates), so repeated cookies are newline-separated.
|
|
54
|
+
out[key] = Array.isArray(v) ? v.join(key === 'set-cookie' ? '\n' : ', ') : v;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function decoder(encoding) {
|
|
59
|
+
switch ((encoding ?? '').trim().toLowerCase()) {
|
|
60
|
+
case 'gzip':
|
|
61
|
+
case 'x-gzip':
|
|
62
|
+
return createGunzip();
|
|
63
|
+
case 'deflate':
|
|
64
|
+
return createInflate();
|
|
65
|
+
case 'br':
|
|
66
|
+
return createBrotliDecompress();
|
|
67
|
+
default:
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function readCapped(body, maxBytes, signal) {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let size = 0;
|
|
74
|
+
for await (const chunk of body) {
|
|
75
|
+
if (signal.aborted)
|
|
76
|
+
throw signal.reason ?? new Error('aborted');
|
|
77
|
+
const buf = chunk;
|
|
78
|
+
if (size + buf.length > maxBytes) {
|
|
79
|
+
chunks.push(buf.subarray(0, maxBytes - size));
|
|
80
|
+
body.destroy();
|
|
81
|
+
return { bytes: Buffer.concat(chunks), truncated: true };
|
|
82
|
+
}
|
|
83
|
+
chunks.push(buf);
|
|
84
|
+
size += buf.length;
|
|
85
|
+
}
|
|
86
|
+
return { bytes: Buffer.concat(chunks), truncated: false };
|
|
87
|
+
}
|
|
88
|
+
export function createSafeFetch(config = {}) {
|
|
89
|
+
const cfg = { ...DEFAULTS, ...config };
|
|
90
|
+
const resolve = config.resolve ?? defaultResolve;
|
|
91
|
+
const allowed = config.isAllowedAddress ?? ((address) => isPublicAddress(address));
|
|
92
|
+
const robotsCache = new Map();
|
|
93
|
+
async function pin(url) {
|
|
94
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
95
|
+
const family = isIP(hostname);
|
|
96
|
+
let addresses;
|
|
97
|
+
if (family)
|
|
98
|
+
addresses = [{ address: hostname, family: family }];
|
|
99
|
+
else {
|
|
100
|
+
try {
|
|
101
|
+
addresses = await resolve(hostname);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return invalid(`Could not resolve host ${hostname}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (addresses.length === 0)
|
|
108
|
+
invalid(`Could not resolve host ${hostname}`);
|
|
109
|
+
for (const a of addresses) {
|
|
110
|
+
if (!allowed(a.address, hostname))
|
|
111
|
+
invalid('The URL points to a non-public address', { host: hostname });
|
|
112
|
+
}
|
|
113
|
+
return addresses[0];
|
|
114
|
+
}
|
|
115
|
+
async function once(url, options, signal) {
|
|
116
|
+
const target = await pin(url);
|
|
117
|
+
const agent = new Agent({
|
|
118
|
+
connect: {
|
|
119
|
+
timeout: Math.min(cfg.timeoutMs, 10_000),
|
|
120
|
+
lookup: (_hostname, opts, cb) => {
|
|
121
|
+
if (opts.all)
|
|
122
|
+
cb(null, [{ address: target.address, family: target.family }]);
|
|
123
|
+
else
|
|
124
|
+
cb(null, target.address, target.family);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
try {
|
|
129
|
+
const res = await request(url, {
|
|
130
|
+
dispatcher: agent,
|
|
131
|
+
method: options.method ?? 'GET',
|
|
132
|
+
headers: {
|
|
133
|
+
'user-agent': options.userAgent ?? cfg.userAgent,
|
|
134
|
+
accept: '*/*',
|
|
135
|
+
'accept-encoding': 'gzip, deflate, br',
|
|
136
|
+
...(options.headers ?? {}),
|
|
137
|
+
},
|
|
138
|
+
body: options.body,
|
|
139
|
+
signal,
|
|
140
|
+
});
|
|
141
|
+
return { res, target, agent };
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
await agent.close().catch(() => undefined);
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function robotsFor(origin, signal, userAgent) {
|
|
149
|
+
const key = origin.origin;
|
|
150
|
+
const cached = robotsCache.get(key);
|
|
151
|
+
if (cached && cached.expires > Date.now())
|
|
152
|
+
return cached.policy;
|
|
153
|
+
let policy = ALLOW_ALL;
|
|
154
|
+
try {
|
|
155
|
+
const { res, agent } = await once(new URL('/robots.txt', origin), { userAgent }, signal);
|
|
156
|
+
try {
|
|
157
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
158
|
+
const { bytes } = await readCapped(res.body, 500 * 1024, signal);
|
|
159
|
+
policy = parseRobots(Buffer.from(bytes).toString('utf8'), cfg.robotsToken);
|
|
160
|
+
}
|
|
161
|
+
else if (res.statusCode >= 500) {
|
|
162
|
+
policy = DISALLOW_ALL;
|
|
163
|
+
await res.body.dump();
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
await res.body.dump();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await agent.close().catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
if (err instanceof HubError)
|
|
175
|
+
throw err;
|
|
176
|
+
policy = DISALLOW_ALL;
|
|
177
|
+
}
|
|
178
|
+
robotsCache.set(key, { policy, expires: Date.now() + 60 * 60 * 1000 });
|
|
179
|
+
return policy;
|
|
180
|
+
}
|
|
181
|
+
return async function safeFetch(rawUrl, options = {}) {
|
|
182
|
+
const maxBytes = options.maxBytes ?? cfg.maxBytes;
|
|
183
|
+
const maxRedirects = options.maxRedirects ?? cfg.maxRedirects;
|
|
184
|
+
const timeout = AbortSignal.timeout(options.timeoutMs ?? cfg.timeoutMs);
|
|
185
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
186
|
+
const redirects = [];
|
|
187
|
+
let url = parseTarget(rawUrl, cfg.allowedPorts);
|
|
188
|
+
let method = options.method ?? 'GET';
|
|
189
|
+
let body = options.body;
|
|
190
|
+
let requestHeaders = options.headers;
|
|
191
|
+
try {
|
|
192
|
+
for (;;) {
|
|
193
|
+
if (options.respectRobots) {
|
|
194
|
+
const policy = await robotsFor(url, signal, options.userAgent ?? cfg.userAgent);
|
|
195
|
+
if (!policy.isAllowed(`${url.pathname}${url.search}`)) {
|
|
196
|
+
throw new HubError('not_permitted', 'robots.txt does not allow fetching this URL');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const { res, target, agent } = await once(url, { ...options, method, body, headers: requestHeaders }, signal);
|
|
200
|
+
try {
|
|
201
|
+
const headers = headerRecord(res.headers);
|
|
202
|
+
if (REDIRECTS.has(res.statusCode) && headers.location && maxRedirects > 0) {
|
|
203
|
+
await res.body.dump();
|
|
204
|
+
if (redirects.length >= maxRedirects) {
|
|
205
|
+
throw new HubError('limit_exceeded', `More than ${maxRedirects} redirects`);
|
|
206
|
+
}
|
|
207
|
+
redirects.push(url.toString());
|
|
208
|
+
const previousOrigin = url.origin;
|
|
209
|
+
url = parseTarget(new URL(headers.location, url).toString(), cfg.allowedPorts);
|
|
210
|
+
if (url.origin !== previousOrigin)
|
|
211
|
+
requestHeaders = withoutCredentials(requestHeaders);
|
|
212
|
+
if (res.statusCode === 303 || ((res.statusCode === 301 || res.statusCode === 302) && method === 'POST')) {
|
|
213
|
+
method = 'GET';
|
|
214
|
+
body = undefined;
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const raw = res.body;
|
|
219
|
+
const decode = method === 'HEAD' ? undefined : decoder(headers['content-encoding']);
|
|
220
|
+
const stream = decode ? raw.pipe(decode) : raw;
|
|
221
|
+
const { bytes, truncated } = await readCapped(stream, maxBytes, signal);
|
|
222
|
+
if (truncated)
|
|
223
|
+
raw.destroy();
|
|
224
|
+
if (truncated && !options.truncate) {
|
|
225
|
+
throw new HubError('limit_exceeded', `Response is larger than ${maxBytes} bytes`);
|
|
226
|
+
}
|
|
227
|
+
const finalUrl = url.toString();
|
|
228
|
+
return {
|
|
229
|
+
requestedUrl: rawUrl,
|
|
230
|
+
url: finalUrl,
|
|
231
|
+
status: res.statusCode,
|
|
232
|
+
headers,
|
|
233
|
+
redirects,
|
|
234
|
+
remoteAddress: target.address,
|
|
235
|
+
body: bytes,
|
|
236
|
+
truncated,
|
|
237
|
+
text: () => Buffer.from(bytes).toString('utf8'),
|
|
238
|
+
json: () => JSON.parse(Buffer.from(bytes).toString('utf8')),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
// Each request has its own pinned agent; destroying it drops any unread body instead of downloading it.
|
|
243
|
+
await agent.destroy().catch(() => undefined);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
if (err instanceof HubError)
|
|
249
|
+
throw err;
|
|
250
|
+
if (timeout.aborted)
|
|
251
|
+
throw new HubError('timeout', 'The remote server took too long');
|
|
252
|
+
if (options.signal?.aborted)
|
|
253
|
+
throw new HubError('timeout', 'The request was cancelled');
|
|
254
|
+
const code = err.code;
|
|
255
|
+
throw new HubError('upstream_unavailable', 'Could not fetch the URL', code ? { reason: code } : undefined);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=fetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch.js","sourceRoot":"","sources":["../../../../../packages/safe-fetch/src/fetch.ts"],"names":[],"mappings":"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","sourcesContent":["import { lookup as dnsLookup } from 'node:dns/promises';\nimport { isIP } from 'node:net';\nimport { Readable } from 'node:stream';\nimport { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';\nimport { Agent, request } from 'undici';\nimport { HubError, type SafeFetch, type SafeFetchOptions, type SafeResponse } from '@x12i/api-hub-core';\nimport { isPublicAddress } from './address.ts';\nimport { ALLOW_ALL, DISALLOW_ALL, parseRobots, type RobotsPolicy } from './robots.ts';\n\nexport interface ResolvedAddress {\n address: string;\n family: 4 | 6;\n}\n\nexport type Resolver = (hostname: string) => Promise<ResolvedAddress[]>;\n\nexport interface SafeFetchConfig {\n /** Default byte cap on the decoded body. */\n maxBytes?: number;\n timeoutMs?: number;\n maxRedirects?: number;\n allowedPorts?: number[];\n userAgent?: string;\n /** Product token matched against robots.txt groups. */\n robotsToken?: string;\n resolve?: Resolver;\n /** Decides whether an address may be contacted. Defaults to public unicast only. */\n isAllowedAddress?: (address: string, hostname: string) => boolean;\n}\n\nconst DEFAULTS = {\n maxBytes: 5 * 1024 * 1024,\n timeoutMs: 15_000,\n maxRedirects: 5,\n allowedPorts: [80, 443, 8080, 8443],\n userAgent: 'x12i-hub/0.1 (+https://x12i.com/bot)',\n robotsToken: 'x12i-hub',\n};\n\nconst defaultResolve: Resolver = async (hostname) =>\n (await dnsLookup(hostname, { all: true, verbatim: true })).map((a) => ({ address: a.address, family: a.family as 4 | 6 }));\n\nconst REDIRECTS = new Set([301, 302, 303, 307, 308]);\n\nfunction invalid(message: string, details?: unknown): never {\n throw new HubError('invalid_input', message, details);\n}\n\nfunction parseTarget(raw: string, allowedPorts: number[]): URL {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n return invalid('Not a valid URL');\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') invalid('Only http and https URLs are allowed');\n if (url.username || url.password) invalid('URLs with credentials are not allowed');\n const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80;\n if (!allowedPorts.includes(port)) invalid(`Port ${port} is not allowed`);\n url.hash = '';\n return url;\n}\n\nconst CREDENTIAL_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization']);\n\n/** Credentials are never forwarded to a different origin on redirect. */\nexport function withoutCredentials(headers: Record<string, string> | undefined): Record<string, string> | undefined {\n if (!headers) return headers;\n return Object.fromEntries(Object.entries(headers).filter(([k]) => !CREDENTIAL_HEADERS.has(k.toLowerCase())));\n}\n\nfunction headerRecord(headers: Record<string, string | string[] | undefined>): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [k, v] of Object.entries(headers)) {\n if (v === undefined) continue;\n const key = k.toLowerCase();\n // Set-Cookie values contain commas (Expires dates), so repeated cookies are newline-separated.\n out[key] = Array.isArray(v) ? v.join(key === 'set-cookie' ? '\\n' : ', ') : v;\n }\n return out;\n}\n\nfunction decoder(encoding: string | undefined) {\n switch ((encoding ?? '').trim().toLowerCase()) {\n case 'gzip':\n case 'x-gzip':\n return createGunzip();\n case 'deflate':\n return createInflate();\n case 'br':\n return createBrotliDecompress();\n default:\n return undefined;\n }\n}\n\nasync function readCapped(body: Readable, maxBytes: number, signal: AbortSignal): Promise<{ bytes: Uint8Array; truncated: boolean }> {\n const chunks: Buffer[] = [];\n let size = 0;\n for await (const chunk of body) {\n if (signal.aborted) throw signal.reason ?? new Error('aborted');\n const buf = chunk as Buffer;\n if (size + buf.length > maxBytes) {\n chunks.push(buf.subarray(0, maxBytes - size));\n body.destroy();\n return { bytes: Buffer.concat(chunks), truncated: true };\n }\n chunks.push(buf);\n size += buf.length;\n }\n return { bytes: Buffer.concat(chunks), truncated: false };\n}\n\nexport function createSafeFetch(config: SafeFetchConfig = {}): SafeFetch {\n const cfg = { ...DEFAULTS, ...config };\n const resolve = config.resolve ?? defaultResolve;\n const allowed = config.isAllowedAddress ?? ((address: string) => isPublicAddress(address));\n const robotsCache = new Map<string, { policy: RobotsPolicy; expires: number }>();\n\n async function pin(url: URL): Promise<ResolvedAddress> {\n const hostname = url.hostname.replace(/^\\[|\\]$/g, '');\n const family = isIP(hostname);\n let addresses: ResolvedAddress[];\n if (family) addresses = [{ address: hostname, family: family as 4 | 6 }];\n else {\n try {\n addresses = await resolve(hostname);\n } catch {\n return invalid(`Could not resolve host ${hostname}`);\n }\n }\n if (addresses.length === 0) invalid(`Could not resolve host ${hostname}`);\n for (const a of addresses) {\n if (!allowed(a.address, hostname)) invalid('The URL points to a non-public address', { host: hostname });\n }\n return addresses[0]!;\n }\n\n async function once(url: URL, options: SafeFetchOptions, signal: AbortSignal) {\n const target = await pin(url);\n const agent = new Agent({\n connect: {\n timeout: Math.min(cfg.timeoutMs, 10_000),\n lookup: (_hostname, opts, cb) => {\n if ((opts as { all?: boolean }).all) cb(null, [{ address: target.address, family: target.family }]);\n else (cb as (e: Error | null, a: string, f: number) => void)(null, target.address, target.family);\n },\n },\n });\n try {\n const res = await request(url, {\n dispatcher: agent,\n method: options.method ?? 'GET',\n headers: {\n 'user-agent': options.userAgent ?? cfg.userAgent,\n accept: '*/*',\n 'accept-encoding': 'gzip, deflate, br',\n ...(options.headers ?? {}),\n },\n body: options.body,\n signal,\n });\n return { res, target, agent };\n } catch (err) {\n await agent.close().catch(() => undefined);\n throw err;\n }\n }\n\n async function robotsFor(origin: URL, signal: AbortSignal, userAgent: string): Promise<RobotsPolicy> {\n const key = origin.origin;\n const cached = robotsCache.get(key);\n if (cached && cached.expires > Date.now()) return cached.policy;\n let policy: RobotsPolicy = ALLOW_ALL;\n try {\n const { res, agent } = await once(new URL('/robots.txt', origin), { userAgent }, signal);\n try {\n if (res.statusCode >= 200 && res.statusCode < 300) {\n const { bytes } = await readCapped(res.body as unknown as Readable, 500 * 1024, signal);\n policy = parseRobots(Buffer.from(bytes).toString('utf8'), cfg.robotsToken);\n } else if (res.statusCode >= 500) {\n policy = DISALLOW_ALL;\n await res.body.dump();\n } else {\n await res.body.dump();\n }\n } finally {\n await agent.close().catch(() => undefined);\n }\n } catch (err) {\n if (err instanceof HubError) throw err;\n policy = DISALLOW_ALL;\n }\n robotsCache.set(key, { policy, expires: Date.now() + 60 * 60 * 1000 });\n return policy;\n }\n\n return async function safeFetch(rawUrl: string, options: SafeFetchOptions = {}): Promise<SafeResponse> {\n const maxBytes = options.maxBytes ?? cfg.maxBytes;\n const maxRedirects = options.maxRedirects ?? cfg.maxRedirects;\n const timeout = AbortSignal.timeout(options.timeoutMs ?? cfg.timeoutMs);\n const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;\n const redirects: string[] = [];\n let url = parseTarget(rawUrl, cfg.allowedPorts);\n let method = options.method ?? 'GET';\n let body = options.body;\n let requestHeaders = options.headers;\n\n try {\n for (;;) {\n if (options.respectRobots) {\n const policy = await robotsFor(url, signal, options.userAgent ?? cfg.userAgent);\n if (!policy.isAllowed(`${url.pathname}${url.search}`)) {\n throw new HubError('not_permitted', 'robots.txt does not allow fetching this URL');\n }\n }\n const { res, target, agent } = await once(url, { ...options, method, body, headers: requestHeaders }, signal);\n try {\n const headers = headerRecord(res.headers);\n if (REDIRECTS.has(res.statusCode) && headers.location && maxRedirects > 0) {\n await res.body.dump();\n if (redirects.length >= maxRedirects) {\n throw new HubError('limit_exceeded', `More than ${maxRedirects} redirects`);\n }\n redirects.push(url.toString());\n const previousOrigin = url.origin;\n url = parseTarget(new URL(headers.location, url).toString(), cfg.allowedPorts);\n if (url.origin !== previousOrigin) requestHeaders = withoutCredentials(requestHeaders);\n if (res.statusCode === 303 || ((res.statusCode === 301 || res.statusCode === 302) && method === 'POST')) {\n method = 'GET';\n body = undefined;\n }\n continue;\n }\n\n const raw = res.body as unknown as Readable;\n const decode = method === 'HEAD' ? undefined : decoder(headers['content-encoding']);\n const stream = decode ? raw.pipe(decode) : raw;\n const { bytes, truncated } = await readCapped(stream, maxBytes, signal);\n if (truncated) raw.destroy();\n if (truncated && !options.truncate) {\n throw new HubError('limit_exceeded', `Response is larger than ${maxBytes} bytes`);\n }\n const finalUrl = url.toString();\n return {\n requestedUrl: rawUrl,\n url: finalUrl,\n status: res.statusCode,\n headers,\n redirects,\n remoteAddress: target.address,\n body: bytes,\n truncated,\n text: () => Buffer.from(bytes).toString('utf8'),\n json: <T>() => JSON.parse(Buffer.from(bytes).toString('utf8')) as T,\n };\n } finally {\n // Each request has its own pinned agent; destroying it drops any unread body instead of downloading it.\n await agent.destroy().catch(() => undefined);\n }\n }\n } catch (err) {\n if (err instanceof HubError) throw err;\n if (timeout.aborted) throw new HubError('timeout', 'The remote server took too long');\n if (options.signal?.aborted) throw new HubError('timeout', 'The request was cancelled');\n const code = (err as { code?: string }).code;\n throw new HubError('upstream_unavailable', 'Could not fetch the URL', code ? { reason: code } : undefined);\n }\n };\n}\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { isPublicAddress } from './address.js';
|
|
2
|
+
export { matchesPattern, parseRobots, parseRobotsFile, policyFor, type RobotsFile, type RobotsGroup, type RobotsMatch, type RobotsPolicy, type RobotsRule, } from './robots.js';
|
|
3
|
+
export { fakeFetch, type FakeRoute, type FakeRoutes } from './fake.js';
|
|
4
|
+
export { createSafeFetch, type Resolver, type ResolvedAddress, type SafeFetchConfig } from './fetch.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/safe-fetch/src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EACL,cAAc,EACd,WAAW,EACX,eAAe,EACf,SAAS,GAMV,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,SAAS,EAAmC,MAAM,WAAW,CAAC;AACvE,OAAO,EAAE,eAAe,EAA6D,MAAM,YAAY,CAAC","sourcesContent":["export { isPublicAddress } from './address.ts';\nexport {\n matchesPattern,\n parseRobots,\n parseRobotsFile,\n policyFor,\n type RobotsFile,\n type RobotsGroup,\n type RobotsMatch,\n type RobotsPolicy,\n type RobotsRule,\n} from './robots.ts';\nexport { fakeFetch, type FakeRoute, type FakeRoutes } from './fake.ts';\nexport { createSafeFetch, type Resolver, type ResolvedAddress, type SafeFetchConfig } from './fetch.ts';\n"]}
|
package/dist/robots.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface RobotsRule {
|
|
2
|
+
allow: boolean;
|
|
3
|
+
pattern: string;
|
|
4
|
+
}
|
|
5
|
+
export interface RobotsGroup {
|
|
6
|
+
agents: string[];
|
|
7
|
+
rules: RobotsRule[];
|
|
8
|
+
crawlDelay: number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface RobotsFile {
|
|
11
|
+
groups: RobotsGroup[];
|
|
12
|
+
sitemaps: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface RobotsMatch {
|
|
15
|
+
allowed: boolean;
|
|
16
|
+
/** The rule that decided, or null when no rule matched (allowed by default). */
|
|
17
|
+
rule: RobotsRule | null;
|
|
18
|
+
}
|
|
19
|
+
export interface RobotsPolicy {
|
|
20
|
+
isAllowed(pathWithQuery: string): boolean;
|
|
21
|
+
match(pathWithQuery: string): RobotsMatch;
|
|
22
|
+
/** User-agent lines of the group(s) that apply; empty when none does. */
|
|
23
|
+
agents: string[];
|
|
24
|
+
crawlDelay: number | null;
|
|
25
|
+
}
|
|
26
|
+
export declare const ALLOW_ALL: RobotsPolicy;
|
|
27
|
+
export declare const DISALLOW_ALL: RobotsPolicy;
|
|
28
|
+
export declare function parseRobotsFile(text: string): RobotsFile;
|
|
29
|
+
/**
|
|
30
|
+
* Wildcard match without regular expressions, so hostile patterns cannot cause backtracking.
|
|
31
|
+
* `*` matches any run of characters; a trailing `$` anchors the end.
|
|
32
|
+
*/
|
|
33
|
+
export declare function matchesPattern(pattern: string, path: string): boolean;
|
|
34
|
+
export declare function policyFor(file: RobotsFile, userAgentToken: string): RobotsPolicy;
|
|
35
|
+
export declare function parseRobots(text: string, userAgentToken: string): RobotsPolicy;
|
package/dist/robots.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export const ALLOW_ALL = {
|
|
2
|
+
isAllowed: () => true,
|
|
3
|
+
match: () => ({ allowed: true, rule: null }),
|
|
4
|
+
agents: [],
|
|
5
|
+
crawlDelay: null,
|
|
6
|
+
};
|
|
7
|
+
export const DISALLOW_ALL = {
|
|
8
|
+
isAllowed: () => false,
|
|
9
|
+
match: () => ({ allowed: false, rule: { allow: false, pattern: '/' } }),
|
|
10
|
+
agents: [],
|
|
11
|
+
crawlDelay: null,
|
|
12
|
+
};
|
|
13
|
+
const MAX_LINES = 20_000;
|
|
14
|
+
const MAX_PATTERN = 512;
|
|
15
|
+
export function parseRobotsFile(text) {
|
|
16
|
+
const groups = [];
|
|
17
|
+
const sitemaps = [];
|
|
18
|
+
let current;
|
|
19
|
+
let lastWasAgent = false;
|
|
20
|
+
for (const rawLine of text.split(/\r?\n/, MAX_LINES)) {
|
|
21
|
+
const line = rawLine.replace(/#.*$/, '').trim();
|
|
22
|
+
const sep = line.indexOf(':');
|
|
23
|
+
if (sep <= 0)
|
|
24
|
+
continue;
|
|
25
|
+
const key = line.slice(0, sep).trim().toLowerCase();
|
|
26
|
+
const value = line.slice(sep + 1).trim();
|
|
27
|
+
if (key === 'user-agent') {
|
|
28
|
+
if (!current || !lastWasAgent) {
|
|
29
|
+
current = { agents: [], rules: [], crawlDelay: null };
|
|
30
|
+
groups.push(current);
|
|
31
|
+
}
|
|
32
|
+
current.agents.push(value.toLowerCase());
|
|
33
|
+
lastWasAgent = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
lastWasAgent = false;
|
|
37
|
+
if (key === 'sitemap') {
|
|
38
|
+
if (value)
|
|
39
|
+
sitemaps.push(value);
|
|
40
|
+
}
|
|
41
|
+
else if ((key === 'allow' || key === 'disallow') && current) {
|
|
42
|
+
if (key === 'disallow' && value === '')
|
|
43
|
+
continue;
|
|
44
|
+
current.rules.push({ allow: key === 'allow', pattern: value.slice(0, MAX_PATTERN) });
|
|
45
|
+
}
|
|
46
|
+
else if (key === 'crawl-delay' && current) {
|
|
47
|
+
const delay = Number(value);
|
|
48
|
+
if (Number.isFinite(delay) && delay >= 0)
|
|
49
|
+
current.crawlDelay = delay;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { groups, sitemaps };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Wildcard match without regular expressions, so hostile patterns cannot cause backtracking.
|
|
56
|
+
* `*` matches any run of characters; a trailing `$` anchors the end.
|
|
57
|
+
*/
|
|
58
|
+
export function matchesPattern(pattern, path) {
|
|
59
|
+
const anchored = pattern.endsWith('$');
|
|
60
|
+
const parts = (anchored ? pattern.slice(0, -1) : pattern).split('*');
|
|
61
|
+
if (!path.startsWith(parts[0]))
|
|
62
|
+
return false;
|
|
63
|
+
let pos = parts[0].length;
|
|
64
|
+
for (let i = 1; i < parts.length; i++) {
|
|
65
|
+
const part = parts[i];
|
|
66
|
+
if (i === parts.length - 1 && anchored)
|
|
67
|
+
return path.length - part.length >= pos && path.endsWith(part);
|
|
68
|
+
const found = path.indexOf(part, pos);
|
|
69
|
+
if (found === -1)
|
|
70
|
+
return false;
|
|
71
|
+
pos = found + part.length;
|
|
72
|
+
}
|
|
73
|
+
return anchored ? pos === path.length : true;
|
|
74
|
+
}
|
|
75
|
+
export function policyFor(file, userAgentToken) {
|
|
76
|
+
// RFC 9309 matches the product token (the name before any "/version"), not a substring of it.
|
|
77
|
+
const token = (/^[a-z0-9_-]+/i.exec(userAgentToken.trim())?.[0] ?? '').toLowerCase();
|
|
78
|
+
const specific = file.groups.filter((g) => g.agents.some((a) => a !== '*' && a === token));
|
|
79
|
+
const chosen = specific.length > 0 ? specific : file.groups.filter((g) => g.agents.includes('*'));
|
|
80
|
+
const rules = chosen.flatMap((g) => g.rules);
|
|
81
|
+
const delays = chosen.map((g) => g.crawlDelay).filter((d) => d !== null);
|
|
82
|
+
function match(pathWithQuery) {
|
|
83
|
+
let best = null;
|
|
84
|
+
for (const rule of rules) {
|
|
85
|
+
if (!matchesPattern(rule.pattern, pathWithQuery))
|
|
86
|
+
continue;
|
|
87
|
+
// RFC 9309: the most specific (longest) rule wins; ties go to allow.
|
|
88
|
+
if (!best || rule.pattern.length > best.pattern.length || (rule.pattern.length === best.pattern.length && rule.allow)) {
|
|
89
|
+
best = rule;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { allowed: best?.allow ?? true, rule: best };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
match,
|
|
96
|
+
isAllowed: (path) => match(path).allowed,
|
|
97
|
+
agents: [...new Set(chosen.flatMap((g) => g.agents))],
|
|
98
|
+
crawlDelay: delays.length ? Math.max(...delays) : null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function parseRobots(text, userAgentToken) {
|
|
102
|
+
return policyFor(parseRobotsFile(text), userAgentToken);
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=robots.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"robots.js","sourceRoot":"","sources":["../../../../../packages/safe-fetch/src/robots.ts"],"names":[],"mappings":"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","sourcesContent":["export interface RobotsRule {\n allow: boolean;\n pattern: string;\n}\n\nexport interface RobotsGroup {\n agents: string[];\n rules: RobotsRule[];\n crawlDelay: number | null;\n}\n\nexport interface RobotsFile {\n groups: RobotsGroup[];\n sitemaps: string[];\n}\n\nexport interface RobotsMatch {\n allowed: boolean;\n /** The rule that decided, or null when no rule matched (allowed by default). */\n rule: RobotsRule | null;\n}\n\nexport interface RobotsPolicy {\n isAllowed(pathWithQuery: string): boolean;\n match(pathWithQuery: string): RobotsMatch;\n /** User-agent lines of the group(s) that apply; empty when none does. */\n agents: string[];\n crawlDelay: number | null;\n}\n\nexport const ALLOW_ALL: RobotsPolicy = {\n isAllowed: () => true,\n match: () => ({ allowed: true, rule: null }),\n agents: [],\n crawlDelay: null,\n};\nexport const DISALLOW_ALL: RobotsPolicy = {\n isAllowed: () => false,\n match: () => ({ allowed: false, rule: { allow: false, pattern: '/' } }),\n agents: [],\n crawlDelay: null,\n};\n\nconst MAX_LINES = 20_000;\nconst MAX_PATTERN = 512;\n\nexport function parseRobotsFile(text: string): RobotsFile {\n const groups: RobotsGroup[] = [];\n const sitemaps: string[] = [];\n let current: RobotsGroup | undefined;\n let lastWasAgent = false;\n for (const rawLine of text.split(/\\r?\\n/, MAX_LINES)) {\n const line = rawLine.replace(/#.*$/, '').trim();\n const sep = line.indexOf(':');\n if (sep <= 0) continue;\n const key = line.slice(0, sep).trim().toLowerCase();\n const value = line.slice(sep + 1).trim();\n if (key === 'user-agent') {\n if (!current || !lastWasAgent) {\n current = { agents: [], rules: [], crawlDelay: null };\n groups.push(current);\n }\n current.agents.push(value.toLowerCase());\n lastWasAgent = true;\n continue;\n }\n lastWasAgent = false;\n if (key === 'sitemap') {\n if (value) sitemaps.push(value);\n } else if ((key === 'allow' || key === 'disallow') && current) {\n if (key === 'disallow' && value === '') continue;\n current.rules.push({ allow: key === 'allow', pattern: value.slice(0, MAX_PATTERN) });\n } else if (key === 'crawl-delay' && current) {\n const delay = Number(value);\n if (Number.isFinite(delay) && delay >= 0) current.crawlDelay = delay;\n }\n }\n return { groups, sitemaps };\n}\n\n/**\n * Wildcard match without regular expressions, so hostile patterns cannot cause backtracking.\n * `*` matches any run of characters; a trailing `$` anchors the end.\n */\nexport function matchesPattern(pattern: string, path: string): boolean {\n const anchored = pattern.endsWith('$');\n const parts = (anchored ? pattern.slice(0, -1) : pattern).split('*');\n if (!path.startsWith(parts[0]!)) return false;\n let pos = parts[0]!.length;\n for (let i = 1; i < parts.length; i++) {\n const part = parts[i]!;\n if (i === parts.length - 1 && anchored) return path.length - part.length >= pos && path.endsWith(part);\n const found = path.indexOf(part, pos);\n if (found === -1) return false;\n pos = found + part.length;\n }\n return anchored ? pos === path.length : true;\n}\n\nexport function policyFor(file: RobotsFile, userAgentToken: string): RobotsPolicy {\n // RFC 9309 matches the product token (the name before any \"/version\"), not a substring of it.\n const token = (/^[a-z0-9_-]+/i.exec(userAgentToken.trim())?.[0] ?? '').toLowerCase();\n const specific = file.groups.filter((g) => g.agents.some((a) => a !== '*' && a === token));\n const chosen = specific.length > 0 ? specific : file.groups.filter((g) => g.agents.includes('*'));\n const rules = chosen.flatMap((g) => g.rules);\n const delays = chosen.map((g) => g.crawlDelay).filter((d): d is number => d !== null);\n\n function match(pathWithQuery: string): RobotsMatch {\n let best: RobotsRule | null = null;\n for (const rule of rules) {\n if (!matchesPattern(rule.pattern, pathWithQuery)) continue;\n // RFC 9309: the most specific (longest) rule wins; ties go to allow.\n if (!best || rule.pattern.length > best.pattern.length || (rule.pattern.length === best.pattern.length && rule.allow)) {\n best = rule;\n }\n }\n return { allowed: best?.allow ?? true, rule: best };\n }\n\n return {\n match,\n isAllowed: (path) => match(path).allowed,\n agents: [...new Set(chosen.flatMap((g) => g.agents))],\n crawlDelay: delays.length ? Math.max(...delays) : null,\n };\n}\n\nexport function parseRobots(text: string, userAgentToken: string): RobotsPolicy {\n return policyFor(parseRobotsFile(text), userAgentToken);\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x12i/api-hub-safe-fetch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "SSRF-safe HTTP fetch for the x12i API hub",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/x12i/apis-hub.git",
|
|
24
|
+
"directory": "packages/safe-fetch"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/x12i/apis-hub/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/x12i/apis-hub",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@x12i/api-hub-core": "0.1.1",
|
|
35
|
+
"undici": "8.12.3"
|
|
36
|
+
}
|
|
37
|
+
}
|