@startsimpli/integrations 0.2.35 → 0.2.36
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/package.json +11 -4
- package/src/__tests__/ingest.test.ts +127 -0
- package/src/__tests__/safe-fetch-pinning.test.ts +198 -0
- package/src/__tests__/safe-fetch.test.ts +186 -0
- package/src/ingest/index.ts +126 -0
- package/src/safe-fetch/index.ts +387 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.36",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"./types": "./src/types/index.ts",
|
|
10
10
|
"./api": "./src/api/index.ts",
|
|
11
11
|
"./corpus": "./src/corpus/index.ts",
|
|
12
|
-
"./corpus-llm": "./src/corpus/llm.ts"
|
|
12
|
+
"./corpus-llm": "./src/corpus/llm.ts",
|
|
13
|
+
"./safe-fetch": "./src/safe-fetch/index.ts",
|
|
14
|
+
"./ingest": "./src/ingest/index.ts"
|
|
13
15
|
},
|
|
14
16
|
"files": [
|
|
15
17
|
"src",
|
|
@@ -18,7 +20,12 @@
|
|
|
18
20
|
"publishConfig": {
|
|
19
21
|
"access": "public"
|
|
20
22
|
},
|
|
21
|
-
"dependencies": {
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@mozilla/readability": "^0.6.0",
|
|
25
|
+
"linkedom": "^0.18.13",
|
|
26
|
+
"undici": "^8.11.2",
|
|
27
|
+
"unpdf": "^1.8.1"
|
|
28
|
+
},
|
|
22
29
|
"peerDependencies": {
|
|
23
30
|
"react": ">=18.0.0",
|
|
24
31
|
"@tanstack/react-query": ">=5.0.0",
|
|
@@ -48,7 +55,7 @@
|
|
|
48
55
|
"react-dom": "^19.0.0",
|
|
49
56
|
"typescript": "^6.0.0",
|
|
50
57
|
"vitest": "^3.0.0",
|
|
51
|
-
"@startsimpli/llm": "0.4.
|
|
58
|
+
"@startsimpli/llm": "0.4.25"
|
|
52
59
|
},
|
|
53
60
|
"scripts": {
|
|
54
61
|
"type-check": "tsc --noEmit",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
/**
|
|
3
|
+
* Server-side ingest helpers (crochet_patterns-llmj, design D1/PK2): readable
|
|
4
|
+
* text from a fetched page, and the text layer of a stored PDF.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { extractPdfText, looksScanned, readableText, SCANNED_CHARS_PER_PAGE } from '../ingest';
|
|
9
|
+
|
|
10
|
+
describe('readableText', () => {
|
|
11
|
+
const article = (rounds: number) =>
|
|
12
|
+
'<html><head><title>Amigurumi Bunny | Hook Blog</title><style>.ad{color:red}</style></head><body>' +
|
|
13
|
+
'<header><nav><a href="/">Home</a> <a href="/shop">Shop</a></nav></header>' +
|
|
14
|
+
'<aside class="sidebar">Subscribe to our newsletter for weekly deals</aside>' +
|
|
15
|
+
'<article><h1>Amigurumi Bunny</h1><p>A small bunny worked in the round with worsted yarn and a 3.5 mm hook.</p><ul>' +
|
|
16
|
+
Array.from({ length: rounds }, (_, i) => `<li>Rnd ${i + 1}: sc in each st around, working in a spiral without joining (${6 * (i + 1)})</li>`).join('') +
|
|
17
|
+
'</ul><p>Fasten off and stuff firmly.</p></article>' +
|
|
18
|
+
'<footer>Copyright 2026 Hook Blog</footer><script>trackEverything()</script></body></html>';
|
|
19
|
+
|
|
20
|
+
it('keeps the article, one round per line, and drops scripts, styles, nav and footer', () => {
|
|
21
|
+
const { title, text } = readableText(article(12), 'https://blog.test/bunny');
|
|
22
|
+
expect(title).toContain('Amigurumi Bunny');
|
|
23
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
24
|
+
expect(lines).toContain('Rnd 1: sc in each st around, working in a spiral without joining (6)');
|
|
25
|
+
expect(lines).toContain('Rnd 12: sc in each st around, working in a spiral without joining (72)');
|
|
26
|
+
expect(text).toContain('Fasten off and stuff firmly.');
|
|
27
|
+
for (const junk of ['trackEverything', 'color:red', 'Copyright 2026', 'newsletter', 'Shop']) {
|
|
28
|
+
expect(text).not.toContain(junk);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('falls back to the page body when the page is too short for an article, still one round per line', () => {
|
|
33
|
+
const { text } = readableText(
|
|
34
|
+
'<html><head><title>Tiny</title><script>x()</script></head><body><p>Rnd 1: 6 sc in MR<br>Rnd 2: inc x6 (12)</p><div>Rnd 3: sc around (12)</div></body></html>',
|
|
35
|
+
);
|
|
36
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
37
|
+
expect(lines).toEqual(['Rnd 1: 6 sc in MR', 'Rnd 2: inc x6 (12)', 'Rnd 3: sc around (12)']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('reads a bare fragment', () => {
|
|
41
|
+
expect(readableText('Rnd 1: 6 sc<br>Rnd 2: inc').text).toBe('Rnd 1: 6 sc\nRnd 2: inc');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('collapses inline whitespace and decodes entities', () => {
|
|
45
|
+
const { text } = readableText('<body><p>Rnd 1: 6 sc\n in <b>MR</b> & join</p></body>');
|
|
46
|
+
expect(text).toBe('Rnd 1: 6 sc in MR & join');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** A minimal, valid PDF with one Helvetica text line per entry of `pages[i]`. */
|
|
51
|
+
function makePdf(pages: string[][]): Uint8Array {
|
|
52
|
+
const esc = (s: string) => s.replace(/[\\()]/g, (c) => `\\${c}`);
|
|
53
|
+
const objs: string[] = [];
|
|
54
|
+
const n = pages.length;
|
|
55
|
+
// 1 catalog, 2 pages, 3 font, then (page, content) pairs
|
|
56
|
+
objs.push('<< /Type /Catalog /Pages 2 0 R >>');
|
|
57
|
+
objs.push(`<< /Type /Pages /Count ${n} /Kids [${pages.map((_, i) => `${4 + 2 * i} 0 R`).join(' ')}] >>`);
|
|
58
|
+
objs.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>');
|
|
59
|
+
pages.forEach((lines, i) => {
|
|
60
|
+
const stream = lines.length
|
|
61
|
+
? `BT /F1 11 Tf 14 TL 72 720 Td ${lines.map((l) => `(${esc(l)}) Tj T*`).join(' ')} ET`
|
|
62
|
+
: '';
|
|
63
|
+
objs.push(
|
|
64
|
+
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + 2 * i} 0 R >>`,
|
|
65
|
+
);
|
|
66
|
+
objs.push(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
|
|
67
|
+
});
|
|
68
|
+
let out = '%PDF-1.4\n';
|
|
69
|
+
const offsets: number[] = [];
|
|
70
|
+
objs.forEach((body, i) => {
|
|
71
|
+
offsets.push(out.length);
|
|
72
|
+
out += `${i + 1} 0 obj\n${body}\nendobj\n`;
|
|
73
|
+
});
|
|
74
|
+
const xref = out.length;
|
|
75
|
+
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
|
|
76
|
+
for (const o of offsets) out += `${String(o).padStart(10, '0')} 00000 n \n`;
|
|
77
|
+
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
78
|
+
return new TextEncoder().encode(out);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ROUNDS = Array.from({ length: 12 }, (_, i) => `Rnd ${i + 1}: sc in each st around (${6 * (i + 1)})`);
|
|
82
|
+
|
|
83
|
+
describe('extractPdfText', () => {
|
|
84
|
+
it('returns the text layer page by page', async () => {
|
|
85
|
+
const bytes = makePdf([ROUNDS.slice(0, 6), ROUNDS.slice(6)]);
|
|
86
|
+
const r = await extractPdfText(bytes);
|
|
87
|
+
expect(r.pages).toHaveLength(2);
|
|
88
|
+
expect(r.pages[0]).toContain('Rnd 1: sc in each st around (6)');
|
|
89
|
+
expect(r.pages[0]).toContain('Rnd 6: sc in each st around (36)');
|
|
90
|
+
expect(r.pages[0]).not.toContain('Rnd 7:');
|
|
91
|
+
expect(r.pages[1]).toContain('Rnd 12: sc in each st around (72)');
|
|
92
|
+
expect(r.totalChars).toBe(r.pages[0]!.length + r.pages[1]!.length);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('a page with a full text layer does not look scanned', async () => {
|
|
96
|
+
const r = await extractPdfText(makePdf([ROUNDS]));
|
|
97
|
+
expect(r.totalChars).toBeGreaterThanOrEqual(SCANNED_CHARS_PER_PAGE);
|
|
98
|
+
expect(looksScanned(r)).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not detach or alter the caller’s buffer (the stored original)', async () => {
|
|
102
|
+
const bytes = makePdf([ROUNDS]);
|
|
103
|
+
const copy = bytes.slice();
|
|
104
|
+
await extractPdfText(bytes);
|
|
105
|
+
expect(bytes.byteLength).toBe(copy.byteLength);
|
|
106
|
+
expect(Buffer.from(bytes).equals(Buffer.from(copy))).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('a PDF with no text layer reads as empty and looks scanned', async () => {
|
|
110
|
+
const r = await extractPdfText(makePdf([[], []]));
|
|
111
|
+
expect(r.pages).toEqual(['', '']);
|
|
112
|
+
expect(r.totalChars).toBe(0);
|
|
113
|
+
expect(looksScanned(r)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rejects bytes that are not a PDF', async () => {
|
|
117
|
+
await expect(extractPdfText(new TextEncoder().encode('<html>not a pdf</html>'))).rejects.toThrow();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('looksScanned', () => {
|
|
122
|
+
it(`is true below ${SCANNED_CHARS_PER_PAGE} chars per page on average, false at or above`, () => {
|
|
123
|
+
expect(looksScanned({ pages: [], totalChars: 0 })).toBe(true);
|
|
124
|
+
expect(looksScanned({ pages: ['a', 'b'], totalChars: 2 * SCANNED_CHARS_PER_PAGE - 1 })).toBe(true);
|
|
125
|
+
expect(looksScanned({ pages: ['a', 'b'], totalChars: 2 * SCANNED_CHARS_PER_PAGE })).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
/**
|
|
3
|
+
* safeFetch DNS pinning (crochet_patterns-yfj8 #1, design D5) and the address
|
|
4
|
+
* blocklist. The connection must go to the address that was validated: a
|
|
5
|
+
* record that answers public at pre-flight and private at connect is refused at
|
|
6
|
+
* connect. Uses a loopback http server only — no external network.
|
|
7
|
+
*/
|
|
8
|
+
import { createServer, type Server } from 'node:http';
|
|
9
|
+
import type { AddressInfo } from 'node:net';
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { Agent, fetch as undiciFetch } from 'undici';
|
|
12
|
+
|
|
13
|
+
import { isBlockedAddress, pinnedLookup, safeFetch, SafeFetchError, type ResolvedAddress } from '../safe-fetch';
|
|
14
|
+
|
|
15
|
+
const PUBLIC = '93.184.216.34';
|
|
16
|
+
const asAddrs = (...addrs: string[]): ResolvedAddress[] =>
|
|
17
|
+
addrs.map((address) => ({ address, family: address.includes(':') ? 6 : 4 }));
|
|
18
|
+
|
|
19
|
+
describe('isBlockedAddress', () => {
|
|
20
|
+
it.each([
|
|
21
|
+
'127.0.0.1',
|
|
22
|
+
'127.255.0.9',
|
|
23
|
+
'0.0.0.0',
|
|
24
|
+
'0.1.2.3',
|
|
25
|
+
'10.9.8.7',
|
|
26
|
+
'100.64.0.1', // CGNAT
|
|
27
|
+
'100.127.255.255',
|
|
28
|
+
'169.254.169.254',
|
|
29
|
+
'172.16.0.1',
|
|
30
|
+
'172.31.255.255',
|
|
31
|
+
'192.0.0.8',
|
|
32
|
+
'192.168.0.1',
|
|
33
|
+
'198.18.0.1', // benchmarking
|
|
34
|
+
'198.19.255.255',
|
|
35
|
+
'224.0.0.1', // multicast
|
|
36
|
+
'239.255.255.250',
|
|
37
|
+
'240.0.0.1', // reserved
|
|
38
|
+
'255.255.255.255',
|
|
39
|
+
'::',
|
|
40
|
+
'::1',
|
|
41
|
+
'[::1]',
|
|
42
|
+
'fc00::1',
|
|
43
|
+
'fd12:3456::1',
|
|
44
|
+
'fe80::1',
|
|
45
|
+
'fe80::1%en0',
|
|
46
|
+
'fec0::1',
|
|
47
|
+
'ff02::1',
|
|
48
|
+
'::ffff:169.254.169.254',
|
|
49
|
+
'::ffff:a9fe:a9fe', // the form WHATWG URL rewrites the above to
|
|
50
|
+
'::ffff:127.0.0.1',
|
|
51
|
+
'::127.0.0.1', // v4-compatible
|
|
52
|
+
'64:ff9b::a9fe:a9fe', // NAT64 of the metadata address
|
|
53
|
+
'64:ff9b::10.0.0.1',
|
|
54
|
+
'64:ff9b:1::1', // local-use NAT64
|
|
55
|
+
'2002:7f00:0001::1', // 6to4 of 127.0.0.1
|
|
56
|
+
'2002:c0a8:0101::', // 6to4 of 192.168.1.1
|
|
57
|
+
'not-an-ip',
|
|
58
|
+
'',
|
|
59
|
+
])('blocks %s', (addr) => {
|
|
60
|
+
expect(isBlockedAddress(addr)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each([
|
|
64
|
+
PUBLIC,
|
|
65
|
+
'8.8.8.8',
|
|
66
|
+
'1.1.1.1',
|
|
67
|
+
'172.32.0.1',
|
|
68
|
+
'172.15.255.255',
|
|
69
|
+
'100.128.0.1',
|
|
70
|
+
'100.63.255.255',
|
|
71
|
+
'198.20.0.1',
|
|
72
|
+
'223.255.255.255',
|
|
73
|
+
'2606:4700:4700::1111',
|
|
74
|
+
'2001:4860:4860::8888',
|
|
75
|
+
'::ffff:8.8.8.8',
|
|
76
|
+
'64:ff9b::808:808', // NAT64 of 8.8.8.8
|
|
77
|
+
'2002:0808:0808::1', // 6to4 of 8.8.8.8
|
|
78
|
+
])('allows %s', (addr) => {
|
|
79
|
+
expect(isBlockedAddress(addr)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('safeFetch url hygiene', () => {
|
|
84
|
+
it.each(['https://user:pass@blog.test/', 'https://user@blog.test/'])('refuses credentials in %s', async (url) => {
|
|
85
|
+
const fetch = vi.fn();
|
|
86
|
+
const err = await safeFetch(url, { fetch, lookup: async () => asAddrs(PUBLIC) }).catch((e: unknown) => e);
|
|
87
|
+
expect(err).toBeInstanceOf(SafeFetchError);
|
|
88
|
+
expect((err as SafeFetchError).code).toBe('BAD_URL');
|
|
89
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
['decimal', 'http://2130706433/'],
|
|
94
|
+
['hex', 'http://0x7f.0.0.1/'],
|
|
95
|
+
['NAT64 metadata literal', 'http://[64:ff9b::169.254.169.254]/'],
|
|
96
|
+
['6to4 loopback literal', 'http://[2002:7f00:1::1]/'],
|
|
97
|
+
])('refuses the %s form of an internal IP literal without resolving it', async (_label, url) => {
|
|
98
|
+
const fetch = vi.fn();
|
|
99
|
+
const lookup = vi.fn(async () => asAddrs(PUBLIC));
|
|
100
|
+
const err = await safeFetch(url, { fetch, lookup }).catch((e: unknown) => e);
|
|
101
|
+
expect((err as SafeFetchError).code).toBe('BLOCKED_ADDRESS');
|
|
102
|
+
expect(lookup).not.toHaveBeenCalled();
|
|
103
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function callLookup(fn: ReturnType<typeof pinnedLookup>, host: string, options: object) {
|
|
108
|
+
return new Promise<{ err: NodeJS.ErrnoException | null; address: unknown; family?: number }>((resolve) => {
|
|
109
|
+
fn(host, options as never, (err, address, family) => resolve({ err, address, family }));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('pinnedLookup', () => {
|
|
114
|
+
it('hands back exactly the validated answer in the {all:true} form, resolving once', async () => {
|
|
115
|
+
const resolve = vi.fn(async () => asAddrs(PUBLIC, '2606:4700:4700::1111'));
|
|
116
|
+
const r = await callLookup(pinnedLookup(resolve), 'blog.test', { all: true });
|
|
117
|
+
expect(r.err).toBeNull();
|
|
118
|
+
expect(r.address).toEqual([
|
|
119
|
+
{ address: PUBLIC, family: 4 },
|
|
120
|
+
{ address: '2606:4700:4700::1111', family: 6 },
|
|
121
|
+
]);
|
|
122
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(resolve).toHaveBeenCalledWith('blog.test');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('uses the (address, family) form without all, and honours options.family', async () => {
|
|
127
|
+
const resolve = async () => asAddrs('2606:4700:4700::1111', PUBLIC);
|
|
128
|
+
const plain = await callLookup(pinnedLookup(resolve), 'blog.test', {});
|
|
129
|
+
expect(plain).toMatchObject({ err: null, address: '2606:4700:4700::1111', family: 6 });
|
|
130
|
+
const v4 = await callLookup(pinnedLookup(resolve), 'blog.test', { family: 4 });
|
|
131
|
+
expect(v4).toMatchObject({ err: null, address: PUBLIC, family: 4 });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('refuses the whole answer when any address is blocked', async () => {
|
|
135
|
+
const r = await callLookup(pinnedLookup(async () => asAddrs(PUBLIC, '169.254.169.254')), 'mixed.test', {
|
|
136
|
+
all: true,
|
|
137
|
+
});
|
|
138
|
+
expect(r.err).toBeInstanceOf(SafeFetchError);
|
|
139
|
+
expect((r.err as unknown as SafeFetchError).code).toBe('BLOCKED_ADDRESS');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('safeFetch connects only to the address it validated (no injected fetch)', () => {
|
|
144
|
+
let server: Server;
|
|
145
|
+
let port: number;
|
|
146
|
+
let connections = 0;
|
|
147
|
+
|
|
148
|
+
beforeAll(async () => {
|
|
149
|
+
server = createServer((_req, res) => {
|
|
150
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
151
|
+
res.end('<html><body>internal</body></html>');
|
|
152
|
+
});
|
|
153
|
+
server.on('connection', () => {
|
|
154
|
+
connections++;
|
|
155
|
+
});
|
|
156
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
|
157
|
+
port = (server.address() as AddressInfo).port;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterAll(async () => {
|
|
161
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('control: an undici Agent connect.lookup decides where the socket goes', async () => {
|
|
165
|
+
// Proves the loopback server is reachable through a custom lookup, so the
|
|
166
|
+
// zero-connection assertion below is caused by pinning, not by an unreachable host.
|
|
167
|
+
const before = connections;
|
|
168
|
+
const agent = new Agent({
|
|
169
|
+
connect: {
|
|
170
|
+
lookup: (_h, opts, cb) =>
|
|
171
|
+
(opts as { all?: boolean }).all
|
|
172
|
+
? (cb as (e: null, a: Array<{ address: string; family: number }>) => void)(null, [
|
|
173
|
+
{ address: '127.0.0.1', family: 4 },
|
|
174
|
+
])
|
|
175
|
+
: cb(null, '127.0.0.1', 4),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
const res = await undiciFetch(`http://rebind.test:${port}/`, { dispatcher: agent });
|
|
180
|
+
expect(await res.text()).toContain('internal');
|
|
181
|
+
} finally {
|
|
182
|
+
await agent.close();
|
|
183
|
+
}
|
|
184
|
+
expect(connections).toBe(before + 1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a record that answers public at pre-flight and private at connect is refused at connect', async () => {
|
|
188
|
+
const before = connections;
|
|
189
|
+
const answers = [asAddrs(PUBLIC), asAddrs('127.0.0.1')];
|
|
190
|
+
const lookup = vi.fn(async () => answers.shift() ?? asAddrs('127.0.0.1'));
|
|
191
|
+
const err = await safeFetch(`http://rebind.test:${port}/`, { lookup, timeoutMs: 5000 }).catch((e: unknown) => e);
|
|
192
|
+
expect(err).toBeInstanceOf(SafeFetchError);
|
|
193
|
+
expect((err as SafeFetchError).code).toBe('BLOCKED_ADDRESS');
|
|
194
|
+
// Once for the pre-flight, once inside connect.lookup — the socket used the second, checked answer.
|
|
195
|
+
expect(lookup).toHaveBeenCalledTimes(2);
|
|
196
|
+
expect(connections).toBe(before);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
/**
|
|
3
|
+
* safeFetch SSRF guards (crochet_patterns-llmj — "paste a link to a pattern").
|
|
4
|
+
* DNS and fetch are injected fakes: no real network.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { safeFetch, SafeFetchError, type ResolvedAddress } from '../safe-fetch';
|
|
9
|
+
|
|
10
|
+
const PUBLIC = '93.184.216.34';
|
|
11
|
+
|
|
12
|
+
function fakeDns(map: Record<string, string[]> = {}) {
|
|
13
|
+
return vi.fn(async (host: string): Promise<ResolvedAddress[]> => {
|
|
14
|
+
const addrs = map[host] ?? [PUBLIC];
|
|
15
|
+
return addrs.map((address) => ({ address, family: address.includes(':') ? 6 : 4 }));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const html = (body = '<html><body>Rnd 1: 6 sc in magic ring</body></html>', type = 'text/html; charset=utf-8') =>
|
|
20
|
+
new Response(body, { status: 200, headers: { 'content-type': type } });
|
|
21
|
+
const redirect = (location: string) => new Response(null, { status: 302, headers: { location } });
|
|
22
|
+
|
|
23
|
+
function fakeFetch(...responses: Array<Response | ((init?: RequestInit) => Promise<Response>)>) {
|
|
24
|
+
return vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
25
|
+
const next = responses.shift();
|
|
26
|
+
if (!next) throw new Error('fake fetch: no more responses');
|
|
27
|
+
return typeof next === 'function' ? next(init) : next;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function refusal(p: Promise<unknown>): Promise<SafeFetchError> {
|
|
32
|
+
const err = await p.then(
|
|
33
|
+
() => undefined,
|
|
34
|
+
(e: unknown) => e,
|
|
35
|
+
);
|
|
36
|
+
expect(err).toBeInstanceOf(SafeFetchError);
|
|
37
|
+
return err as SafeFetchError;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('safeFetch refuses internal addresses', () => {
|
|
41
|
+
it.each([
|
|
42
|
+
['loopback v4', 'http://127.0.0.1/pattern'],
|
|
43
|
+
['loopback v6', 'http://[::1]/pattern'],
|
|
44
|
+
['cloud metadata', 'http://169.254.169.254/latest/meta-data/'],
|
|
45
|
+
['10/8', 'http://10.0.0.5/'],
|
|
46
|
+
['172.16/12 low end', 'http://172.16.0.1/'],
|
|
47
|
+
['172.16/12 high end', 'http://172.31.255.255/'],
|
|
48
|
+
['192.168/16', 'http://192.168.1.1/'],
|
|
49
|
+
['unspecified', 'http://0.0.0.0/'],
|
|
50
|
+
['v4-mapped v6 metadata', 'http://[::ffff:169.254.169.254]/'],
|
|
51
|
+
])('%s (%s) as an IP literal, without fetching', async (_label, url) => {
|
|
52
|
+
const fetch = fakeFetch(html());
|
|
53
|
+
const err = await refusal(safeFetch(url, { fetch, lookup: fakeDns() }));
|
|
54
|
+
expect(err.code).toBe('BLOCKED_ADDRESS');
|
|
55
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('a DNS name that resolves to a private IP', async () => {
|
|
59
|
+
const fetch = fakeFetch(html());
|
|
60
|
+
const lookup = fakeDns({ 'patterns.evil.test': ['10.1.2.3'] });
|
|
61
|
+
const err = await refusal(safeFetch('https://patterns.evil.test/bunny', { fetch, lookup }));
|
|
62
|
+
expect(err.code).toBe('BLOCKED_ADDRESS');
|
|
63
|
+
expect(lookup).toHaveBeenCalledWith('patterns.evil.test');
|
|
64
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a DNS name where ANY of its addresses is private', async () => {
|
|
68
|
+
const fetch = fakeFetch(html());
|
|
69
|
+
const lookup = fakeDns({ 'mixed.test': [PUBLIC, '192.168.0.9'] });
|
|
70
|
+
const err = await refusal(safeFetch('https://mixed.test/', { fetch, lookup }));
|
|
71
|
+
expect(err.code).toBe('BLOCKED_ADDRESS');
|
|
72
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('a redirect hop to a host that resolves privately', async () => {
|
|
76
|
+
const fetch = fakeFetch(redirect('http://internal.test/admin'), html());
|
|
77
|
+
const lookup = fakeDns({ 'internal.test': ['192.168.0.10'] });
|
|
78
|
+
const err = await refusal(safeFetch('https://blog.test/bunny', { fetch, lookup }));
|
|
79
|
+
expect(err.code).toBe('BLOCKED_ADDRESS');
|
|
80
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('a redirect hop straight to the metadata IP', async () => {
|
|
84
|
+
const fetch = fakeFetch(redirect('http://169.254.169.254/latest/meta-data/'), html());
|
|
85
|
+
const err = await refusal(safeFetch('https://blog.test/bunny', { fetch, lookup: fakeDns() }));
|
|
86
|
+
expect(err.code).toBe('BLOCKED_ADDRESS');
|
|
87
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('a non-http scheme', async () => {
|
|
91
|
+
const fetch = fakeFetch(html());
|
|
92
|
+
const err = await refusal(safeFetch('file:///etc/passwd', { fetch, lookup: fakeDns() }));
|
|
93
|
+
expect(err.code).toBe('BAD_URL');
|
|
94
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('safeFetch fetches public pages', () => {
|
|
99
|
+
it('returns the body of a public html page, fetched with redirect: manual', async () => {
|
|
100
|
+
const fetch = fakeFetch(html());
|
|
101
|
+
const res = await safeFetch('https://blog.test/bunny', { fetch, lookup: fakeDns() });
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
expect(res.contentType).toBe('text/html');
|
|
104
|
+
expect(new TextDecoder().decode(res.body)).toContain('6 sc in magic ring');
|
|
105
|
+
expect(fetch.mock.calls[0]![1]?.redirect).toBe('manual');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('allows 172.32.0.1 (just outside 172.16/12)', async () => {
|
|
109
|
+
const fetch = fakeFetch(html());
|
|
110
|
+
await expect(safeFetch('http://172.32.0.1/', { fetch, lookup: fakeDns() })).resolves.toMatchObject({ status: 200 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('follows 3 redirects (relative Location resolved), re-checking every hop with redirect: manual', async () => {
|
|
114
|
+
const fetch = fakeFetch(redirect('https://a.test/1'), redirect('/2'), redirect('https://c.test/3'), html());
|
|
115
|
+
const lookup = fakeDns();
|
|
116
|
+
const res = await safeFetch('https://start.test/', { fetch, lookup });
|
|
117
|
+
expect(res.finalUrl).toBe('https://c.test/3');
|
|
118
|
+
expect(fetch).toHaveBeenCalledTimes(4);
|
|
119
|
+
expect(String(fetch.mock.calls[2]![0])).toBe('https://a.test/2');
|
|
120
|
+
for (const call of fetch.mock.calls) expect(call[1]?.redirect).toBe('manual');
|
|
121
|
+
expect(lookup.mock.calls.map((c) => c[0])).toEqual(expect.arrayContaining(['start.test', 'a.test', 'c.test']));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('refuses a 4th redirect', async () => {
|
|
125
|
+
const fetch = fakeFetch(redirect('/1'), redirect('/2'), redirect('/3'), redirect('/4'), html());
|
|
126
|
+
const err = await refusal(safeFetch('https://start.test/', { fetch, lookup: fakeDns() }));
|
|
127
|
+
expect(err.code).toBe('TOO_MANY_REDIRECTS');
|
|
128
|
+
expect(fetch).toHaveBeenCalledTimes(4);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('safeFetch content limits', () => {
|
|
133
|
+
it('accepts application/pdf', async () => {
|
|
134
|
+
const fetch = fakeFetch(new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]), { headers: { 'content-type': 'application/pdf' } }));
|
|
135
|
+
const res = await safeFetch('https://blog.test/bunny.pdf', { fetch, lookup: fakeDns() });
|
|
136
|
+
expect(res.contentType).toBe('application/pdf');
|
|
137
|
+
expect(res.body.byteLength).toBe(4);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it.each(['application/json', 'image/png', 'application/octet-stream'])('refuses %s', async (type) => {
|
|
141
|
+
const fetch = fakeFetch(html('{}', type));
|
|
142
|
+
const err = await refusal(safeFetch('https://blog.test/x', { fetch, lookup: fakeDns() }));
|
|
143
|
+
expect(err.code).toBe('UNSUPPORTED_TYPE');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('refuses a response with no content type', async () => {
|
|
147
|
+
const fetch = fakeFetch(new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
|
|
148
|
+
const err = await refusal(safeFetch('https://blog.test/x', { fetch, lookup: fakeDns() }));
|
|
149
|
+
expect(err.code).toBe('UNSUPPORTED_TYPE');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('refuses a declared content-length over maxBytes', async () => {
|
|
153
|
+
const fetch = fakeFetch(
|
|
154
|
+
new Response('x'.repeat(10), { headers: { 'content-type': 'text/html', 'content-length': '5000000' } }),
|
|
155
|
+
);
|
|
156
|
+
const err = await refusal(safeFetch('https://blog.test/x', { fetch, lookup: fakeDns(), maxBytes: 1000 }));
|
|
157
|
+
expect(err.code).toBe('TOO_LARGE');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('aborts a streamed body (no content-length) once it passes maxBytes', async () => {
|
|
161
|
+
let pulled = 0;
|
|
162
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
163
|
+
pull(c) {
|
|
164
|
+
pulled++;
|
|
165
|
+
if (pulled > 1000) return c.close();
|
|
166
|
+
c.enqueue(new Uint8Array(512));
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const fetch = fakeFetch(new Response(stream, { headers: { 'content-type': 'text/html' } }));
|
|
170
|
+
const err = await refusal(safeFetch('https://blog.test/x', { fetch, lookup: fakeDns(), maxBytes: 2048 }));
|
|
171
|
+
expect(err.code).toBe('TOO_LARGE');
|
|
172
|
+
expect(pulled).toBeLessThan(1000);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('aborts on timeout', async () => {
|
|
176
|
+
const hang = (init?: RequestInit) =>
|
|
177
|
+
new Promise<Response>((_resolve, reject) => {
|
|
178
|
+
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
|
179
|
+
});
|
|
180
|
+
const fetch = fakeFetch(hang);
|
|
181
|
+
const started = Date.now();
|
|
182
|
+
const err = await refusal(safeFetch('https://slow.test/', { fetch, lookup: fakeDns(), timeoutMs: 50 }));
|
|
183
|
+
expect(err.code).toBe('TIMEOUT');
|
|
184
|
+
expect(Date.now() - started).toBeLessThan(2000);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document ingest helpers (crochet_patterns-llmj, design D1/PK2): turn a fetched
|
|
3
|
+
* web page or a stored PDF into plain text for a model.
|
|
4
|
+
*
|
|
5
|
+
* SERVER-ONLY. Exported from the `@startsimpli/integrations/ingest` subpath,
|
|
6
|
+
* never from the package root (linkedom and unpdf are node-side dependencies).
|
|
7
|
+
*/
|
|
8
|
+
import { Readability } from '@mozilla/readability';
|
|
9
|
+
import { parseHTML } from 'linkedom';
|
|
10
|
+
import { extractText, getDocumentProxy } from 'unpdf';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// HTML
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'HEAD']);
|
|
17
|
+
const BLOCK = new Set([
|
|
18
|
+
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BODY', 'CAPTION', 'DD', 'DETAILS', 'DIV', 'DL', 'DT',
|
|
19
|
+
'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER',
|
|
20
|
+
'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TD', 'TFOOT',
|
|
21
|
+
'TH', 'THEAD', 'TR', 'UL',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
interface DomNode {
|
|
25
|
+
nodeType: number;
|
|
26
|
+
nodeName: string;
|
|
27
|
+
textContent: string | null;
|
|
28
|
+
childNodes: ArrayLike<DomNode>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Text of a DOM subtree with a line break around every block element and at `<br>`. */
|
|
32
|
+
function blockText(root: DomNode): string {
|
|
33
|
+
let out = '';
|
|
34
|
+
const walk = (node: DomNode) => {
|
|
35
|
+
if (node.nodeType === 3) {
|
|
36
|
+
out += (node.textContent ?? '').replace(/\s+/g, ' ');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return;
|
|
40
|
+
const tag = node.nodeName.toUpperCase();
|
|
41
|
+
if (SKIP.has(tag)) return;
|
|
42
|
+
if (tag === 'BR') {
|
|
43
|
+
out += '\n';
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const block = BLOCK.has(tag);
|
|
47
|
+
if (block) out += '\n';
|
|
48
|
+
for (let i = 0; i < node.childNodes.length; i++) walk(node.childNodes[i]!);
|
|
49
|
+
if (block) out += '\n';
|
|
50
|
+
};
|
|
51
|
+
walk(root);
|
|
52
|
+
return out
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((line) => line.replace(/[ \t ]+/g, ' ').trim())
|
|
55
|
+
.join('\n')
|
|
56
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The readable text of an HTML page: Readability's main article when it finds
|
|
62
|
+
* one, else the whole body (scripts and styles dropped). Each block element and
|
|
63
|
+
* `<br>` starts a new line, so "Rnd 1 … / Rnd 2 …" list items stay one per line.
|
|
64
|
+
* `baseUrl` lets Readability resolve relative links; it does not change the text.
|
|
65
|
+
*/
|
|
66
|
+
export function readableText(html: string, baseUrl?: string): { title?: string; text: string } {
|
|
67
|
+
// linkedom leaves a bare fragment out of <body>; give it a document to live in.
|
|
68
|
+
const whole = /<html[\s>]/i.test(html) ? html : `<!doctype html><html><head></head><body>${html}</body></html>`;
|
|
69
|
+
const { document } = parseHTML(whole);
|
|
70
|
+
if (baseUrl && !document.querySelector('base')) {
|
|
71
|
+
const base = document.createElement('base');
|
|
72
|
+
base.setAttribute('href', baseUrl);
|
|
73
|
+
document.head?.appendChild(base);
|
|
74
|
+
}
|
|
75
|
+
const docTitle = document.title?.trim() || undefined;
|
|
76
|
+
|
|
77
|
+
let text = '';
|
|
78
|
+
let title = docTitle;
|
|
79
|
+
try {
|
|
80
|
+
// Readability mutates the document it is given; parse a fresh copy.
|
|
81
|
+
const article = new Readability(parseHTML(document.toString()).document as unknown as Document).parse();
|
|
82
|
+
if (article?.content) {
|
|
83
|
+
text = blockText(parseHTML(`<html><body>${article.content}</body></html>`).document as unknown as DomNode);
|
|
84
|
+
title = article.title?.trim() || docTitle;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// fall through to the whole body
|
|
88
|
+
}
|
|
89
|
+
if (!text) text = blockText((document.body ?? document) as unknown as DomNode);
|
|
90
|
+
return title ? { title, text } : { text };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// PDF
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export interface PdfText {
|
|
98
|
+
/** One entry per page, trimmed. */
|
|
99
|
+
pages: string[];
|
|
100
|
+
/** Characters across all pages (trimmed). */
|
|
101
|
+
totalChars: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Text layer of a PDF, one string per page (unpdf / pdf.js). Throws on bytes
|
|
106
|
+
* that are not a readable PDF. The input is copied first, so the caller's
|
|
107
|
+
* buffer (the stored original) is never detached.
|
|
108
|
+
*/
|
|
109
|
+
export async function extractPdfText(bytes: Uint8Array): Promise<PdfText> {
|
|
110
|
+
const pdf = await getDocumentProxy(new Uint8Array(bytes), { verbosity: 0 });
|
|
111
|
+
try {
|
|
112
|
+
const { text } = await extractText(pdf, { mergePages: false });
|
|
113
|
+
const pages = text.map((t) => t.trim());
|
|
114
|
+
return { pages, totalChars: pages.reduce((n, p) => n + p.length, 0) };
|
|
115
|
+
} finally {
|
|
116
|
+
await pdf.loadingTask.destroy().catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Average under this many characters per page = no usable text layer (design R6). */
|
|
121
|
+
export const SCANNED_CHARS_PER_PAGE = 200;
|
|
122
|
+
|
|
123
|
+
/** True when a PDF has no usable text layer and should go to the model as a document. */
|
|
124
|
+
export function looksScanned(r: PdfText): boolean {
|
|
125
|
+
return r.pages.length === 0 || r.totalChars / r.pages.length < SCANNED_CHARS_PER_PAGE;
|
|
126
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safeFetch — server-side fetch of a USER-SUPPLIED url with SSRF guards
|
|
3
|
+
* (crochet_patterns-llmj: "paste a link to a pattern").
|
|
4
|
+
*
|
|
5
|
+
* SERVER-ONLY. Exported from the `@startsimpli/integrations/safe-fetch`
|
|
6
|
+
* subpath, never from the package root (client components import the root, and
|
|
7
|
+
* the default resolver is `node:dns`).
|
|
8
|
+
*
|
|
9
|
+
* Contract:
|
|
10
|
+
* - only http/https urls, no `user:pass@`;
|
|
11
|
+
* - every hop's host is resolved and EVERY address is checked
|
|
12
|
+
* (`isBlockedAddress`); redirects are followed by hand (`redirect: 'manual'`)
|
|
13
|
+
* so each hop is re-checked; at most `maxRedirects` (default 3);
|
|
14
|
+
* - only `allowTypes` content types (default text/html, application/pdf);
|
|
15
|
+
* - aborts over `maxBytes` (header or streamed) and after `timeoutMs`.
|
|
16
|
+
*
|
|
17
|
+
* DNS pinning (crochet_patterns-yfj8 #1, design D5): when `fetch` is not
|
|
18
|
+
* injected, requests go through npm `undici` with an Agent whose
|
|
19
|
+
* `connect.lookup` is `pinnedLookup(lookup)`. The check runs inside the lookup
|
|
20
|
+
* the socket uses, so the address that is checked is the address that is
|
|
21
|
+
* connected to — a record that flips to a private IP between the pre-flight
|
|
22
|
+
* check and connect is refused at connect.
|
|
23
|
+
*
|
|
24
|
+
* `lookup` and `fetch` are injectable so tests never touch the network.
|
|
25
|
+
*/
|
|
26
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
27
|
+
import { isIP, type LookupFunction } from 'node:net';
|
|
28
|
+
import { Agent, fetch as undiciFetch } from 'undici';
|
|
29
|
+
|
|
30
|
+
export type SafeFetchErrorCode =
|
|
31
|
+
| 'BAD_URL'
|
|
32
|
+
| 'BLOCKED_ADDRESS'
|
|
33
|
+
| 'TOO_MANY_REDIRECTS'
|
|
34
|
+
| 'UNSUPPORTED_TYPE'
|
|
35
|
+
| 'TOO_LARGE'
|
|
36
|
+
| 'TIMEOUT'
|
|
37
|
+
| 'HTTP_ERROR';
|
|
38
|
+
|
|
39
|
+
export class SafeFetchError extends Error {
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
public readonly code: SafeFetchErrorCode,
|
|
43
|
+
/** The hop that was refused, when there is one. */
|
|
44
|
+
public readonly url?: string,
|
|
45
|
+
) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'SafeFetchError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ResolvedAddress {
|
|
52
|
+
address: string;
|
|
53
|
+
family: 4 | 6;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SafeFetchOptions {
|
|
57
|
+
/** Allowed content types (parameters like `; charset=` are ignored). Default text/html + application/pdf. */
|
|
58
|
+
allowTypes?: string[];
|
|
59
|
+
/** Byte cap on the body. Default 5 MB. */
|
|
60
|
+
maxBytes?: number;
|
|
61
|
+
/** Redirect hops allowed. Default 3. */
|
|
62
|
+
maxRedirects?: number;
|
|
63
|
+
/** Whole-request time cap. Default 10 s. */
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
/** Resolves a hostname to ALL its addresses. Default: node:dns lookup({all:true}). */
|
|
66
|
+
lookup?: (hostname: string) => Promise<ResolvedAddress[]>;
|
|
67
|
+
/** Default: npm undici fetch with a DNS-pinning Agent (see `pinnedLookup`). */
|
|
68
|
+
fetch?: typeof fetch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SafeFetchResult {
|
|
72
|
+
/** The url that finally answered (after redirects). */
|
|
73
|
+
finalUrl: string;
|
|
74
|
+
status: number;
|
|
75
|
+
/** Bare media type, e.g. `text/html`. */
|
|
76
|
+
contentType: string;
|
|
77
|
+
body: Uint8Array;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const SAFE_FETCH_DEFAULTS = {
|
|
81
|
+
allowTypes: ['text/html', 'application/pdf'],
|
|
82
|
+
maxBytes: 5 * 1024 * 1024,
|
|
83
|
+
maxRedirects: 3,
|
|
84
|
+
timeoutMs: 10_000,
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Address blocklist
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function parseV4(addr: string): number[] | null {
|
|
92
|
+
const parts = addr.split('.');
|
|
93
|
+
if (parts.length !== 4) return null;
|
|
94
|
+
const bytes = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
|
|
95
|
+
return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseV6(addr: string): number[] | null {
|
|
99
|
+
let s = addr.toLowerCase();
|
|
100
|
+
const zone = s.indexOf('%');
|
|
101
|
+
if (zone !== -1) s = s.slice(0, zone);
|
|
102
|
+
// A trailing dotted quad (::ffff:1.2.3.4) becomes two hextets.
|
|
103
|
+
const lastColon = s.lastIndexOf(':');
|
|
104
|
+
const tail = s.slice(lastColon + 1);
|
|
105
|
+
if (tail.includes('.')) {
|
|
106
|
+
const v4 = parseV4(tail);
|
|
107
|
+
if (!v4) return null;
|
|
108
|
+
s = `${s.slice(0, lastColon + 1)}${((v4[0]! << 8) | v4[1]!).toString(16)}:${((v4[2]! << 8) | v4[3]!).toString(16)}`;
|
|
109
|
+
}
|
|
110
|
+
const halves = s.split('::');
|
|
111
|
+
if (halves.length > 2) return null;
|
|
112
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
113
|
+
const rest = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
114
|
+
const missing = 8 - head.length - rest.length;
|
|
115
|
+
if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
|
|
116
|
+
const hextets = [...head, ...Array<string>(halves.length === 2 ? missing : 0).fill('0'), ...rest];
|
|
117
|
+
const bytes: number[] = [];
|
|
118
|
+
for (const h of hextets) {
|
|
119
|
+
if (!/^[0-9a-f]{1,4}$/.test(h)) return null;
|
|
120
|
+
const n = parseInt(h, 16);
|
|
121
|
+
bytes.push(n >> 8, n & 0xff);
|
|
122
|
+
}
|
|
123
|
+
return bytes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isBlockedV4([a, b, c]: number[]): boolean {
|
|
127
|
+
return (
|
|
128
|
+
a === 0 || // 0/8 "this network", incl. 0.0.0.0
|
|
129
|
+
a === 10 || // 10/8
|
|
130
|
+
a === 127 || // loopback
|
|
131
|
+
(a === 100 && b! >= 64 && b! <= 127) || // 100.64/10 CGNAT
|
|
132
|
+
(a === 169 && b === 254) || // link-local, incl. cloud metadata 169.254.169.254
|
|
133
|
+
(a === 172 && b! >= 16 && b! <= 31) || // 172.16/12
|
|
134
|
+
(a === 192 && b === 0 && c === 0) || // 192.0.0/24 IETF protocol assignments
|
|
135
|
+
(a === 192 && b === 168) || // 192.168/16
|
|
136
|
+
(a === 198 && (b === 18 || b === 19)) || // 198.18/15 benchmarking
|
|
137
|
+
a! >= 224 // 224/4 multicast, 240/4 reserved, 255.255.255.255
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function startsWith(bytes: number[], prefix: number[]): boolean {
|
|
142
|
+
return prefix.every((b, i) => bytes[i] === b);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isBlockedV6(bytes: number[]): boolean {
|
|
146
|
+
const first = bytes[0]!;
|
|
147
|
+
if ((first & 0xfe) === 0xfc) return true; // fc00::/7 unique local
|
|
148
|
+
if (first === 0xfe && (bytes[1]! & 0xc0) === 0x80) return true; // fe80::/10 link-local
|
|
149
|
+
if (first === 0xfe && (bytes[1]! & 0xc0) === 0xc0) return true; // fec0::/10 site-local (deprecated)
|
|
150
|
+
if (first === 0xff) return true; // ff00::/8 multicast
|
|
151
|
+
const zero = (n: number) => Array<number>(n).fill(0);
|
|
152
|
+
// ::/96 — :: and ::1 and the deprecated v4-compatible form; checked as the embedded v4
|
|
153
|
+
// (:: -> 0.0.0.0, ::1 -> 0.0.0.1, both in 0/8).
|
|
154
|
+
if (startsWith(bytes, zero(12))) return isBlockedV4(bytes.slice(12));
|
|
155
|
+
// ::ffff:0:0/96 v4-mapped
|
|
156
|
+
if (startsWith(bytes, [...zero(10), 0xff, 0xff])) return isBlockedV4(bytes.slice(12));
|
|
157
|
+
// 64:ff9b::/96 NAT64 well-known prefix
|
|
158
|
+
if (startsWith(bytes, [0x00, 0x64, 0xff, 0x9b, ...zero(8)])) return isBlockedV4(bytes.slice(12));
|
|
159
|
+
// 64:ff9b:1::/48 local-use NAT64 — translates to whatever the local network chooses
|
|
160
|
+
if (startsWith(bytes, [0x00, 0x64, 0xff, 0x9b, 0x00, 0x01])) return true;
|
|
161
|
+
// 2002::/16 6to4 — the v4 is bytes 2..5
|
|
162
|
+
if (bytes[0] === 0x20 && bytes[1] === 0x02) return isBlockedV4(bytes.slice(2, 6));
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* True when a connection to `addr` must be refused: loopback, private,
|
|
168
|
+
* link-local (cloud metadata), CGNAT, benchmarking, multicast, reserved, the
|
|
169
|
+
* unspecified address, IPv6 loopback/ULA/link-local/multicast, and any IPv6
|
|
170
|
+
* form that embeds a blocked IPv4 (mapped, compatible, NAT64, 6to4).
|
|
171
|
+
* Anything that does not parse as an IP is refused (fail closed).
|
|
172
|
+
*/
|
|
173
|
+
export function isBlockedAddress(addr: string): boolean {
|
|
174
|
+
const s = addr.startsWith('[') && addr.endsWith(']') ? addr.slice(1, -1) : addr;
|
|
175
|
+
const family = isIP(s.split('%')[0]!);
|
|
176
|
+
if (family === 4) {
|
|
177
|
+
const v4 = parseV4(s);
|
|
178
|
+
return !v4 || isBlockedV4(v4);
|
|
179
|
+
}
|
|
180
|
+
if (family === 6) {
|
|
181
|
+
const v6 = parseV6(s);
|
|
182
|
+
return !v6 || isBlockedV6(v6);
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// DNS
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
async function dnsLookupAll(hostname: string): Promise<ResolvedAddress[]> {
|
|
192
|
+
const addrs = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
193
|
+
return addrs.map((a) => ({ address: a.address, family: a.family === 6 ? 6 : 4 }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Resolves `hostname` and throws BLOCKED_ADDRESS if ANY answer is blocked. */
|
|
197
|
+
async function resolveChecked(
|
|
198
|
+
resolve: (hostname: string) => Promise<ResolvedAddress[]>,
|
|
199
|
+
hostname: string,
|
|
200
|
+
url?: string,
|
|
201
|
+
): Promise<ResolvedAddress[]> {
|
|
202
|
+
let addrs: ResolvedAddress[];
|
|
203
|
+
try {
|
|
204
|
+
addrs = await resolve(hostname);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new SafeFetchError(`Could not resolve ${hostname}: ${(err as Error)?.message ?? err}`, 'BAD_URL', url);
|
|
207
|
+
}
|
|
208
|
+
if (!addrs.length) throw new SafeFetchError(`Could not resolve ${hostname}`, 'BAD_URL', url);
|
|
209
|
+
const blocked = addrs.find((a) => isBlockedAddress(a.address));
|
|
210
|
+
if (blocked) {
|
|
211
|
+
throw new SafeFetchError(`${hostname} resolves to a blocked address (${blocked.address})`, 'BLOCKED_ADDRESS', url);
|
|
212
|
+
}
|
|
213
|
+
return addrs;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A `dns.lookup`-shaped function for `net.connect` / undici `connect.lookup`.
|
|
218
|
+
* It resolves with `resolve`, refuses the whole answer if any address is
|
|
219
|
+
* blocked, and hands the socket only the addresses it validated — so the
|
|
220
|
+
* connection goes to an address that was checked, not to a second resolution.
|
|
221
|
+
* Handles both callback forms (`{all:true}` → array; otherwise address+family).
|
|
222
|
+
*/
|
|
223
|
+
export function pinnedLookup(resolve: SafeFetchOptions['lookup'] = dnsLookupAll): LookupFunction {
|
|
224
|
+
return (hostname, options, callback) => {
|
|
225
|
+
const wanted = options?.family === 4 || (options?.family as unknown) === 'IPv4' ? 4
|
|
226
|
+
: options?.family === 6 || (options?.family as unknown) === 'IPv6' ? 6 : 0;
|
|
227
|
+
resolveChecked(resolve, hostname).then(
|
|
228
|
+
(addrs) => {
|
|
229
|
+
const list = wanted ? addrs.filter((a) => a.family === wanted) : addrs;
|
|
230
|
+
if (!list.length) {
|
|
231
|
+
const err = Object.assign(new Error(`No IPv${wanted} address for ${hostname}`), { code: 'ENOTFOUND' });
|
|
232
|
+
callback(err, '', 0);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (options?.all) callback(null, list.map((a) => ({ address: a.address, family: a.family })));
|
|
236
|
+
else callback(null, list[0]!.address, list[0]!.family);
|
|
237
|
+
},
|
|
238
|
+
(err: Error) => callback(err as NodeJS.ErrnoException, '', 0),
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// safeFetch
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function parseHopUrl(raw: string | URL, base?: URL): URL {
|
|
248
|
+
let url: URL;
|
|
249
|
+
try {
|
|
250
|
+
url = new URL(raw, base);
|
|
251
|
+
} catch {
|
|
252
|
+
throw new SafeFetchError(`Not a valid url: ${String(raw)}`, 'BAD_URL', String(raw));
|
|
253
|
+
}
|
|
254
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
255
|
+
throw new SafeFetchError(`Only http and https urls are allowed (got ${url.protocol})`, 'BAD_URL', url.href);
|
|
256
|
+
}
|
|
257
|
+
if (url.username || url.password) {
|
|
258
|
+
throw new SafeFetchError('Urls with credentials are not allowed', 'BAD_URL', url.href);
|
|
259
|
+
}
|
|
260
|
+
return url;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function bareHost(url: URL): string {
|
|
264
|
+
const h = url.hostname;
|
|
265
|
+
return h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Rejects as soon as `signal` aborts, so a hung resolver cannot outlive the time cap. */
|
|
269
|
+
function untilAborted<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
270
|
+
if (signal.aborted) return Promise.reject(new SafeFetchError('Timed out', 'TIMEOUT'));
|
|
271
|
+
return new Promise<T>((resolve, reject) => {
|
|
272
|
+
const onAbort = () => reject(new SafeFetchError('Timed out', 'TIMEOUT'));
|
|
273
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
274
|
+
p.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** undici wraps connect failures as `TypeError('fetch failed', { cause })`. */
|
|
279
|
+
function findSafeFetchError(err: unknown): SafeFetchError | undefined {
|
|
280
|
+
for (let e: unknown = err, depth = 0; e && depth < 5; e = (e as { cause?: unknown }).cause, depth++) {
|
|
281
|
+
if (e instanceof SafeFetchError) return e;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function readCapped(res: Response, maxBytes: number, url: string): Promise<Uint8Array> {
|
|
287
|
+
if (!res.body) return new Uint8Array(0);
|
|
288
|
+
const reader = res.body.getReader();
|
|
289
|
+
const chunks: Uint8Array[] = [];
|
|
290
|
+
let total = 0;
|
|
291
|
+
for (;;) {
|
|
292
|
+
const { done, value } = await reader.read();
|
|
293
|
+
if (done) break;
|
|
294
|
+
total += value.byteLength;
|
|
295
|
+
if (total > maxBytes) {
|
|
296
|
+
await reader.cancel().catch(() => undefined);
|
|
297
|
+
throw new SafeFetchError(`Response is larger than ${maxBytes} bytes`, 'TOO_LARGE', url);
|
|
298
|
+
}
|
|
299
|
+
chunks.push(value);
|
|
300
|
+
}
|
|
301
|
+
const body = new Uint8Array(total);
|
|
302
|
+
let offset = 0;
|
|
303
|
+
for (const c of chunks) {
|
|
304
|
+
body.set(c, offset);
|
|
305
|
+
offset += c.byteLength;
|
|
306
|
+
}
|
|
307
|
+
return body;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<SafeFetchResult> {
|
|
311
|
+
const allowTypes = (options.allowTypes ?? SAFE_FETCH_DEFAULTS.allowTypes).map((t) => t.toLowerCase());
|
|
312
|
+
const maxBytes = options.maxBytes ?? SAFE_FETCH_DEFAULTS.maxBytes;
|
|
313
|
+
const maxRedirects = options.maxRedirects ?? SAFE_FETCH_DEFAULTS.maxRedirects;
|
|
314
|
+
const timeoutMs = options.timeoutMs ?? SAFE_FETCH_DEFAULTS.timeoutMs;
|
|
315
|
+
const resolve = options.lookup ?? dnsLookupAll;
|
|
316
|
+
|
|
317
|
+
let current = parseHopUrl(url);
|
|
318
|
+
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
321
|
+
let agent: Agent | undefined;
|
|
322
|
+
let fetchImpl: (input: string, init: RequestInit) => Promise<Response>;
|
|
323
|
+
if (options.fetch) {
|
|
324
|
+
fetchImpl = options.fetch;
|
|
325
|
+
} else {
|
|
326
|
+
// npm undici's own fetch: a npm-undici dispatcher must not be handed to Node's global fetch.
|
|
327
|
+
const pinned = new Agent({ connect: { lookup: pinnedLookup(resolve) } });
|
|
328
|
+
agent = pinned;
|
|
329
|
+
fetchImpl = (input, init) =>
|
|
330
|
+
undiciFetch(input, { ...(init as Parameters<typeof undiciFetch>[1]), dispatcher: pinned }) as unknown as Promise<Response>;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
for (let hop = 0; ; hop++) {
|
|
335
|
+
const host = bareHost(current);
|
|
336
|
+
// Pre-flight: an IP literal is checked as-is (never resolved); a name is resolved and every answer checked.
|
|
337
|
+
if (isIP(host)) {
|
|
338
|
+
if (isBlockedAddress(host)) {
|
|
339
|
+
throw new SafeFetchError(`Blocked address ${host}`, 'BLOCKED_ADDRESS', current.href);
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
await untilAborted(resolveChecked(resolve, host, current.href), controller.signal);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const res = await fetchImpl(current.href, { redirect: 'manual', signal: controller.signal });
|
|
346
|
+
|
|
347
|
+
if (res.status >= 300 && res.status < 400) {
|
|
348
|
+
const location = res.headers.get('location');
|
|
349
|
+
await res.body?.cancel().catch(() => undefined);
|
|
350
|
+
if (!location) throw new SafeFetchError(`HTTP ${res.status} without a Location`, 'HTTP_ERROR', current.href);
|
|
351
|
+
if (hop >= maxRedirects) {
|
|
352
|
+
throw new SafeFetchError(`More than ${maxRedirects} redirects`, 'TOO_MANY_REDIRECTS', current.href);
|
|
353
|
+
}
|
|
354
|
+
current = parseHopUrl(location, current);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
await res.body?.cancel().catch(() => undefined);
|
|
360
|
+
throw new SafeFetchError(`HTTP ${res.status}`, 'HTTP_ERROR', current.href);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const contentType = (res.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
|
|
364
|
+
if (!contentType || !allowTypes.includes(contentType)) {
|
|
365
|
+
await res.body?.cancel().catch(() => undefined);
|
|
366
|
+
throw new SafeFetchError(`Unsupported content type: ${contentType || '(none)'}`, 'UNSUPPORTED_TYPE', current.href);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const declared = Number(res.headers.get('content-length'));
|
|
370
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
371
|
+
await res.body?.cancel().catch(() => undefined);
|
|
372
|
+
throw new SafeFetchError(`Response is larger than ${maxBytes} bytes`, 'TOO_LARGE', current.href);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const body = await readCapped(res, maxBytes, current.href);
|
|
376
|
+
return { finalUrl: current.href, status: res.status, contentType, body };
|
|
377
|
+
}
|
|
378
|
+
} catch (err) {
|
|
379
|
+
const own = findSafeFetchError(err);
|
|
380
|
+
if (own) throw own;
|
|
381
|
+
if (controller.signal.aborted) throw new SafeFetchError(`Timed out after ${timeoutMs} ms`, 'TIMEOUT', current.href);
|
|
382
|
+
throw new SafeFetchError(`Fetch failed: ${(err as Error)?.message ?? err}`, 'HTTP_ERROR', current.href);
|
|
383
|
+
} finally {
|
|
384
|
+
clearTimeout(timer);
|
|
385
|
+
if (agent) void agent.destroy().catch(() => undefined);
|
|
386
|
+
}
|
|
387
|
+
}
|