Skip to content

Commit 888ded9

Browse files
max23468claude
andauthored
fix(crawler): riusa le connessioni tra le richieste (#92)
Il client outbound creava e chiudeva un Agent per ogni richiesta, quindi le centinaia di pagine di uno stesso monitor rifacevano handshake TCP/TLS e risoluzione DNS: con il timeout di 10 minuti del workflow gli scan grandi rischiavano di scadere prima di produrre output. Ora i dispatcher sono pooled per insieme di indirizzi fissati, il DNS è risolto una volta per host e lo scan chiude i pool in un finally. Chiude anche due thread Codex della PR #91: le sitemap duplicate non consumano più il budget di 32 prima di essere deduplicate al dequeue, e la classificazione degli indirizzi copre i prefissi IPv6 non globali e di transizione (Teredo, 6to4, NAT64, ORCHIDv2, SRv6, doc 3fff::/20) più il relay 6to4 IPv4 192.88.99.0/24. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ee12006 commit 888ded9

5 files changed

Lines changed: 166 additions & 58 deletions

File tree

src/outbound.ts

Lines changed: 80 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@ const MAX_SCAN_BYTES = 100 * 1024 * 1024;
1515
const EXTRA_SCAN_REQUESTS = 64;
1616
const blockedAddresses = createBlockedAddresses();
1717

18+
interface PinnedAddress {
19+
address: string;
20+
family: 4 | 6;
21+
}
22+
1823
interface OutboundDependencies {
1924
fetch?: typeof fetch;
20-
resolve?: (hostname: string) => Promise<Array<{ address: string; family: 4 | 6 }>>;
25+
resolve?: (hostname: string) => Promise<PinnedAddress[]>;
2126
}
2227

2328
interface OutboundOptions {
@@ -39,6 +44,11 @@ export class OutboundClient {
3944
private remainingRequests: number;
4045
private readonly fetchImpl: typeof fetch;
4146
private readonly resolve: NonNullable<OutboundDependencies["resolve"]>;
47+
// Un dispatcher per insieme di indirizzi fissati: le centinaia di pagine
48+
// dello stesso monitor riusano connessioni TCP/TLS invece di rifarle a ogni
49+
// richiesta. Il DNS resta risolto una volta sola per host per tutto lo scan.
50+
private readonly agents = new Map<string, Agent>();
51+
private readonly pinned = new Map<string, Promise<PinnedAddress[]>>();
4252

4353
constructor(
4454
private readonly site: SiteConfig,
@@ -57,47 +67,55 @@ export class OutboundClient {
5767
throw new OutboundBudgetError("Budget richieste dello scan esaurito.");
5868
}
5969

60-
const addresses = await this.resolvePinnedAddresses(currentUrl);
61-
const dispatcher = new Agent({
62-
connect: { lookup: pinnedLookup(addresses) }
63-
});
64-
65-
try {
66-
const response = await this.fetchImpl(currentUrl, {
67-
dispatcher,
68-
headers: options.headers,
69-
redirect: "manual",
70-
signal: AbortSignal.timeout(this.site.crawl.timeoutMs)
71-
} as RequestInit);
72-
73-
const location = response.headers.get("location");
74-
if (location && isRedirect(response.status)) {
75-
await response.body?.cancel();
76-
if (redirects >= MAX_REDIRECTS) throw new Error(`Troppi redirect: ${rawUrl}`);
77-
currentUrl = this.authorize(new URL(location, currentUrl).toString());
78-
continue;
79-
}
70+
const response = await this.fetchImpl(currentUrl, {
71+
dispatcher: this.dispatcherFor(await this.resolvePinnedAddresses(currentUrl)),
72+
headers: options.headers,
73+
redirect: "manual",
74+
signal: AbortSignal.timeout(this.site.crawl.timeoutMs)
75+
} as RequestInit);
8076

81-
if (response.status < 200 || response.status >= 300) {
82-
await response.body?.cancel();
83-
return {
84-
body: new Uint8Array(),
85-
headers: response.headers,
86-
status: response.status,
87-
url: currentUrl
88-
};
89-
}
77+
const location = response.headers.get("location");
78+
if (location && isRedirect(response.status)) {
79+
await response.body?.cancel();
80+
if (redirects >= MAX_REDIRECTS) throw new Error(`Troppi redirect: ${rawUrl}`);
81+
currentUrl = this.authorize(new URL(location, currentUrl).toString());
82+
continue;
83+
}
9084

91-
const maxBytes =
92-
typeof options.maxBytes === "function"
93-
? options.maxBytes(currentUrl, response.headers)
94-
: options.maxBytes;
95-
const body = await this.readBody(response, maxBytes);
96-
return { body, headers: response.headers, status: response.status, url: currentUrl };
97-
} finally {
98-
await dispatcher.close();
85+
if (response.status < 200 || response.status >= 300) {
86+
await response.body?.cancel();
87+
return {
88+
body: new Uint8Array(),
89+
headers: response.headers,
90+
status: response.status,
91+
url: currentUrl
92+
};
9993
}
94+
95+
const maxBytes =
96+
typeof options.maxBytes === "function"
97+
? options.maxBytes(currentUrl, response.headers)
98+
: options.maxBytes;
99+
const body = await this.readBody(response, maxBytes);
100+
return { body, headers: response.headers, status: response.status, url: currentUrl };
101+
}
102+
}
103+
104+
/** Chiude i pool aperti: senza questo il processo resta appeso a fine scan. */
105+
async close(): Promise<void> {
106+
const agents = [...this.agents.values()];
107+
this.agents.clear();
108+
await Promise.all(agents.map((agent) => agent.close()));
109+
}
110+
111+
private dispatcherFor(addresses: PinnedAddress[]): Agent {
112+
const key = addresses.map(({ address }) => address).join(",");
113+
let agent = this.agents.get(key);
114+
if (!agent) {
115+
agent = new Agent({ connect: { lookup: pinnedLookup(addresses) } });
116+
this.agents.set(key, agent);
100117
}
118+
return agent;
101119
}
102120

103121
private authorize(rawUrl: string): string {
@@ -113,15 +131,22 @@ export class OutboundClient {
113131
return url.toString();
114132
}
115133

116-
private async resolvePinnedAddresses(url: string): Promise<Array<{ address: string; family: 4 | 6 }>> {
134+
private resolvePinnedAddresses(url: string): Promise<PinnedAddress[]> {
117135
const rawHostname = new URL(url).hostname;
118136
const hostname = rawHostname.startsWith("[") ? rawHostname.slice(1, -1) : rawHostname;
119-
const addresses = await this.resolve(hostname);
120-
if (addresses.length === 0) throw new Error(`DNS senza indirizzi per ${hostname}`);
121-
if (addresses.some(({ address, family }) => !isPublicAddress(address, family))) {
122-
throw new Error(`Destinazione privata o riservata bloccata: ${hostname}`);
123-
}
124-
return addresses;
137+
const cached = this.pinned.get(hostname);
138+
if (cached) return cached;
139+
140+
const promise = (async () => {
141+
const addresses = await this.resolve(hostname);
142+
if (addresses.length === 0) throw new Error(`DNS senza indirizzi per ${hostname}`);
143+
if (addresses.some(({ address, family }) => !isPublicAddress(address, family))) {
144+
throw new Error(`Destinazione privata o riservata bloccata: ${hostname}`);
145+
}
146+
return addresses;
147+
})();
148+
this.pinned.set(hostname, promise);
149+
return promise;
125150
}
126151

127152
private async readBody(response: Response, maxBytes: number): Promise<Uint8Array> {
@@ -199,20 +224,30 @@ function createBlockedAddresses(): BlockList {
199224
["192.0.2.0", 24],
200225
["192.168.0.0", 16],
201226
["198.18.0.0", 15],
227+
["192.88.99.0", 24],
202228
["198.51.100.0", 24],
203229
["203.0.113.0", 24],
204230
["224.0.0.0", 4],
205231
["240.0.0.0", 4]
206232
] as const) {
207233
list.addSubnet(network, prefix, "ipv4");
208234
}
235+
// IANA IPv6 Special-Purpose Address Registry: tutto ciò che non è "Global:
236+
// True". I prefissi di transizione (NAT64, Teredo, 6to4) restano bloccati
237+
// perché incapsulano destinazioni IPv4 che possono essere riservate.
209238
for (const [network, prefix] of [
210239
["::", 128],
211240
["::1", 128],
241+
["64:ff9b::", 96],
212242
["64:ff9b:1::", 48],
213243
["100::", 64],
244+
["2001::", 32],
214245
["2001:2::", 48],
246+
["2001:20::", 28],
215247
["2001:db8::", 32],
248+
["2002::", 16],
249+
["3fff::", 20],
250+
["5f00::", 16],
216251
["fc00::", 7],
217252
["fe80::", 10],
218253
["ff00::", 8]

src/scan.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,15 @@ export async function scanSite(
5050
const seenUrls = new Set<string>();
5151
const guard = new RobotsGuard(site, client);
5252

53-
const queue = await buildInitialQueue(site, guard, issues, client);
5453
const counters: CrawlCounters = { scannedCount: 0, skippedCount: 0 };
55-
await crawlQueue(site, guard, client, queue, seenUrls, issues, resources, changes, siteState, baseline, counters);
54+
try {
55+
const queue = await buildInitialQueue(site, guard, issues, client);
56+
await crawlQueue(site, guard, client, queue, seenUrls, issues, resources, changes, siteState, baseline, counters);
57+
} finally {
58+
// Le connessioni riusate durante il crawling vanno chiuse qui, o il
59+
// processo resta appeso anche quando lo scan è finito.
60+
await client.close();
61+
}
5662

5763
if (!baseline && !hasFatalIssues(issues)) {
5864
changes.push(...collectRemovals(siteState, seenUrls, issues, resources));

src/sitemap.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,29 @@ export async function discoverFromSitemaps(
2020
client: OutboundClient
2121
): Promise<QueuedUrl[]> {
2222
const discovered = new Map<string, QueuedUrl>();
23-
const seenSitemaps = new Set<string>();
24-
const queue = sitemapUrls.map((url) => ({ url, depth: 0 }));
23+
const queued = new Set<string>();
24+
const queue: Array<{ url: string; depth: number }> = [];
25+
26+
// Il budget conta le sitemap distinte: un indice che ripete lo stesso figlio
27+
// non deve esaurire il limite prima delle sitemap ancora da leggere.
28+
const enqueue = (url: string, depth: number): boolean => {
29+
if (queued.has(url)) return true;
30+
if (queued.size >= MAX_SITEMAPS) return false;
31+
queued.add(url);
32+
queue.push({ url, depth });
33+
return true;
34+
};
35+
36+
for (const url of sitemapUrls) {
37+
if (enqueue(url, 0)) continue;
38+
issues.push({ url, message: `Limite di ${MAX_SITEMAPS} sitemap raggiunto.`, fatal: false });
39+
break;
40+
}
2541

2642
while (queue.length > 0 && discovered.size < site.crawl.maxUrls) {
2743
const item = queue.shift();
28-
if (!item || seenSitemaps.has(item.url)) continue;
29-
if (seenSitemaps.size >= MAX_SITEMAPS) {
30-
issues.push({ url: item.url, message: `Limite di ${MAX_SITEMAPS} sitemap raggiunto.`, fatal: false });
31-
break;
32-
}
44+
if (!item) break;
3345

34-
seenSitemaps.add(item.url);
3546
// Una sitemap illeggibile non ferma la scansione: il crawling dai roots
3647
// resta la fonte principale, la sitemap aggiunge le pagine non linkate.
3748
const body = await readSitemap(site, item.url, issues, client);
@@ -55,12 +66,10 @@ export async function discoverFromSitemaps(
5566
!normalized ||
5667
!isSameSite(normalized, site) ||
5768
item.depth >= MAX_SITEMAP_DEPTH ||
58-
queue.length + seenSitemaps.size >= MAX_SITEMAPS
69+
!enqueue(normalized, item.depth + 1)
5970
) {
6071
rejectedChild = true;
61-
continue;
6272
}
63-
queue.push({ url: normalized, depth: item.depth + 1 });
6473
}
6574
if (rejectedChild) {
6675
issues.push({

test/discovery.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,34 @@ describe("discoverFromSitemaps", () => {
160160
expect(fetch).toHaveBeenCalledTimes(5);
161161
expect(issues[0].message).toContain("oltre budget");
162162
});
163+
164+
it("non consuma il budget con sitemap duplicate nello stesso indice", async () => {
165+
const index = `<?xml version="1.0"?><sitemapindex>
166+
${'<sitemap><loc>https://example.com/doppia.xml</loc></sitemap>'.repeat(40)}
167+
<sitemap><loc>https://example.com/unica.xml</loc></sitemap>
168+
</sitemapindex>`;
169+
const unica = `<?xml version="1.0"?><urlset>
170+
<url><loc>https://example.com/pagina</loc></url>
171+
</urlset>`;
172+
173+
vi.stubGlobal(
174+
"fetch",
175+
vi.fn(async (url: string) => {
176+
if (url.endsWith("indice.xml")) return new Response(index, { status: 200 });
177+
if (url.endsWith("unica.xml")) return new Response(unica, { status: 200 });
178+
return new Response("<urlset></urlset>", { status: 200 });
179+
})
180+
);
181+
182+
const issues: ScanIssue[] = [];
183+
const discovered = await discoverFromSitemaps(
184+
site,
185+
["https://example.com/indice.xml"],
186+
issues,
187+
testOutboundClient(site, fetch)
188+
);
189+
190+
expect(discovered.map((item) => item.url)).toEqual(["https://example.com/pagina"]);
191+
expect(issues).toEqual([]);
192+
});
163193
});

test/security.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,32 @@ describe("hardening input remoti", () => {
8989

9090
expect(resolve).toHaveBeenCalledWith("2606:4700:4700::1111");
9191
});
92+
93+
it("blocca i prefissi IPv6 non globali e di transizione", () => {
94+
expect(isPublicAddress("2606:4700:4700::1111", 6)).toBe(true);
95+
expect(isPublicAddress("2001::1", 6)).toBe(false);
96+
expect(isPublicAddress("2002:7f00:1::1", 6)).toBe(false);
97+
expect(isPublicAddress("64:ff9b::a9fe:a9fe", 6)).toBe(false);
98+
expect(isPublicAddress("3fff::1", 6)).toBe(false);
99+
expect(isPublicAddress("192.88.99.1", 4)).toBe(false);
100+
});
101+
102+
it("riusa lo stesso pool per gli indirizzi già fissati", async () => {
103+
const resolve = vi.fn(async () => [{ address: "93.184.216.34", family: 4 as const }]);
104+
const dispatchers = new Set<unknown>();
105+
const client = new OutboundClient(site, {
106+
fetch: async (_url, init) => {
107+
dispatchers.add((init as { dispatcher?: unknown }).dispatcher);
108+
return new Response("ok");
109+
},
110+
resolve
111+
});
112+
113+
await client.get("https://example.com/a", { maxBytes: 10 });
114+
await client.get("https://example.com/b", { maxBytes: 10 });
115+
await client.close();
116+
117+
expect(dispatchers.size).toBe(1);
118+
expect(resolve).toHaveBeenCalledOnce();
119+
});
92120
});

0 commit comments

Comments
 (0)