crawlforge-mcp-server 5.2.8 → 5.3.0
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/CLAUDE.md +13 -1
- package/README.md +9 -9
- package/package.json +2 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +407 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +517 -13
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- package/src/utils/webBotAuth.js +193 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* robotsGate — the one pre-fetch gate every fetching tool goes through.
|
|
3
|
+
*
|
|
4
|
+
* Before this module, `RobotsChecker` was instantiated in exactly one place
|
|
5
|
+
* (BFSCrawler), so only `crawl_deep` honoured robots.txt; `scrape`,
|
|
6
|
+
* `batch_scrape`, `scrape_template`, `track_changes`, `map_site` and every
|
|
7
|
+
* `extract_*` tool did no robots check at all. Ground rule G5 says every
|
|
8
|
+
* fetching tool respects robots.txt by default, so the check has to live at the
|
|
9
|
+
* fetch boundary rather than in one crawler.
|
|
10
|
+
*
|
|
11
|
+
* Order matters. The platform blocklist (G7) is consulted first and is not
|
|
12
|
+
* overridable by anything a caller can send; robots (G5) is next and *is*
|
|
13
|
+
* overridable, but only explicitly, with a warning and an audit row; the
|
|
14
|
+
* host's Crawl-delay (G6) then feeds the per-host throttle.
|
|
15
|
+
*
|
|
16
|
+
* Callers replace `await throttleHost(url)` with `await preflightFetch(url, …)`
|
|
17
|
+
* and spread the returned `headers` into the request.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { RobotsChecker } from './robotsChecker.js';
|
|
21
|
+
import { assertHostAllowed } from './hostBlocklist.js';
|
|
22
|
+
import { identityHeaders, resolveUserAgent } from './fetchIdentity.js';
|
|
23
|
+
import { throttleHost } from './hostRateLimiter.js';
|
|
24
|
+
import { recordComplianceEvent, apiKeyId } from './complianceAudit.js';
|
|
25
|
+
import { signRequestHeaders } from './webBotAuth.js';
|
|
26
|
+
import { markPreflightRefusal } from '../server/requestContext.js';
|
|
27
|
+
import { config } from '../constants/config.js';
|
|
28
|
+
|
|
29
|
+
export class RobotsDisallowedError extends Error {
|
|
30
|
+
constructor(url) {
|
|
31
|
+
super(
|
|
32
|
+
`robots.txt on ${new URL(url).host} disallows this path for CrawlForge. ` +
|
|
33
|
+
`Pass respect_robots: false to fetch it anyway — that override is recorded ` +
|
|
34
|
+
`against your API key and is your decision to make.`
|
|
35
|
+
);
|
|
36
|
+
this.name = 'RobotsDisallowedError';
|
|
37
|
+
this.code = 'ROBOTS_DISALLOWED';
|
|
38
|
+
this.url = url;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One checker per identity, so the robots cache is process-wide rather than
|
|
44
|
+
* per-tool — otherwise every tool would re-fetch the same robots.txt.
|
|
45
|
+
* @type {Map<string, RobotsChecker>}
|
|
46
|
+
*/
|
|
47
|
+
const checkers = new Map();
|
|
48
|
+
|
|
49
|
+
function checkerFor(userAgent) {
|
|
50
|
+
let checker = checkers.get(userAgent);
|
|
51
|
+
if (!checker) {
|
|
52
|
+
checker = new RobotsChecker(userAgent);
|
|
53
|
+
checkers.set(userAgent, checker);
|
|
54
|
+
}
|
|
55
|
+
return checker;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether a URL may be fetched. Pure decision — does no throttling and
|
|
60
|
+
* sends no request other than the (cached) robots.txt lookup.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} url
|
|
63
|
+
* @param {object} [options]
|
|
64
|
+
* @param {boolean} [options.respectRobots] per-request override; defaults to
|
|
65
|
+
* `config.crawling.respectRobots`. `false` is honoured, warned about, audited.
|
|
66
|
+
* @param {string} [options.userAgent] per-request identity override
|
|
67
|
+
* @param {string} [options.tool] tool name, for the audit row
|
|
68
|
+
* @param {string} [options.apiKey] hashed into the audit row, never stored raw
|
|
69
|
+
* @returns {Promise<{ allowed: boolean, userAgent: string, crawlDelayMs: number,
|
|
70
|
+
* warnings: string[], overridden: boolean }>}
|
|
71
|
+
* @throws {BlockedHostError} for a permanently blocked host
|
|
72
|
+
*/
|
|
73
|
+
export async function robotsPreflight(url, options = {}) {
|
|
74
|
+
// G7 — first, and not overridable. Stamp before rethrowing so a blocked host
|
|
75
|
+
// costs the caller nothing: we refused, we fetched nothing.
|
|
76
|
+
try {
|
|
77
|
+
assertHostAllowed(url);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error?.code === 'HOST_BLOCKED') markPreflightRefusal('HOST_BLOCKED');
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const userAgent = resolveUserAgent(options.userAgent);
|
|
84
|
+
const warnings = [];
|
|
85
|
+
|
|
86
|
+
const explicitOverride = options.respectRobots === false;
|
|
87
|
+
const respect = options.respectRobots === undefined
|
|
88
|
+
? config.crawling.respectRobots
|
|
89
|
+
: options.respectRobots !== false;
|
|
90
|
+
|
|
91
|
+
const checker = checkerFor(userAgent);
|
|
92
|
+
let allowed = true;
|
|
93
|
+
let crawlDelayMs = 0;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
allowed = await checker.canFetch(url);
|
|
97
|
+
crawlDelayMs = (await checker.fetchCrawlDelay(url)) * 1000;
|
|
98
|
+
} catch {
|
|
99
|
+
// Unreadable robots.txt is not a disallow (see RobotsChecker.canFetch).
|
|
100
|
+
allowed = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (explicitOverride) {
|
|
104
|
+
warnings.push(
|
|
105
|
+
allowed
|
|
106
|
+
? 'respect_robots was disabled for this request. robots.txt did not disallow this URL, so the override changed nothing. The request is recorded against your API key.'
|
|
107
|
+
: `respect_robots was disabled for this request and robots.txt on ${new URL(url).host} disallows this path. Fetching anyway is your decision and is recorded against your API key.`
|
|
108
|
+
);
|
|
109
|
+
recordComplianceEvent({
|
|
110
|
+
event: 'robots_override',
|
|
111
|
+
url,
|
|
112
|
+
tool: options.tool || null,
|
|
113
|
+
apiKeyId: apiKeyId(options.apiKey),
|
|
114
|
+
userAgent,
|
|
115
|
+
robotsAllowed: allowed
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
allowed: allowed || !respect,
|
|
121
|
+
userAgent,
|
|
122
|
+
crawlDelayMs,
|
|
123
|
+
warnings,
|
|
124
|
+
overridden: explicitOverride && !allowed
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The call-site helper: run the gate, honour Crawl-delay and any recorded
|
|
130
|
+
* `Retry-After`, and hand back the identity headers to send.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} url
|
|
133
|
+
* @param {object} [options] see {@link robotsPreflight}
|
|
134
|
+
* @returns {Promise<{ headers: Record<string,string>, userAgent: string,
|
|
135
|
+
* warnings: string[], overridden: boolean }>}
|
|
136
|
+
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
137
|
+
*/
|
|
138
|
+
export async function preflightFetch(url, options = {}) {
|
|
139
|
+
const decision = await robotsPreflight(url, options);
|
|
140
|
+
if (!decision.allowed) {
|
|
141
|
+
markPreflightRefusal('ROBOTS_DISALLOWED');
|
|
142
|
+
throw new RobotsDisallowedError(url);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
|
|
146
|
+
|
|
147
|
+
// Web Bot Auth: when a signing key is configured, every request also carries
|
|
148
|
+
// a signature a site owner can verify against our published key. No key
|
|
149
|
+
// configured means no headers and no behaviour change. Requests with a
|
|
150
|
+
// caller-supplied userAgent override are still signed — the signature covers
|
|
151
|
+
// @authority, not the UA, and it identifies the operator (us), not the
|
|
152
|
+
// identity the caller asked us to present.
|
|
153
|
+
const signature = signRequestHeaders(url) || {};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
headers: { ...identityHeaders({ userAgent: decision.userAgent }), ...signature },
|
|
157
|
+
userAgent: decision.userAgent,
|
|
158
|
+
warnings: decision.warnings,
|
|
159
|
+
overridden: decision.overridden
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The gate for browser paths. Same decision as {@link preflightFetch}, minus
|
|
165
|
+
* the identity and signature headers — those belong on an HTTP fetch, not on a
|
|
166
|
+
* browser context that presents its own identity.
|
|
167
|
+
*
|
|
168
|
+
* Deliberately takes no `userAgent`: robots.txt is matched against our
|
|
169
|
+
* canonical product token even when the browser presents another UA. Matching
|
|
170
|
+
* on the presented UA would let browser traffic walk past the rules our own
|
|
171
|
+
* token is bound by, which is the G5 hole this gate exists to close.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} url
|
|
174
|
+
* @param {object} [options]
|
|
175
|
+
* @param {boolean} [options.respectRobots] per-request override
|
|
176
|
+
* @param {string} [options.tool] tool name, for the audit row
|
|
177
|
+
* @param {string} [options.apiKey] hashed into the audit row, never stored raw
|
|
178
|
+
* @returns {Promise<string[]>} warnings to surface on the response
|
|
179
|
+
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
180
|
+
*/
|
|
181
|
+
export async function browserPreflight(url, options = {}) {
|
|
182
|
+
const decision = await robotsPreflight(url, {
|
|
183
|
+
respectRobots: options.respectRobots,
|
|
184
|
+
tool: options.tool,
|
|
185
|
+
apiKey: options.apiKey
|
|
186
|
+
});
|
|
187
|
+
if (!decision.allowed) {
|
|
188
|
+
markPreflightRefusal('ROBOTS_DISALLOWED');
|
|
189
|
+
throw new RobotsDisallowedError(url);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
|
|
193
|
+
return decision.warnings;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Test/diagnostic hook: drop every cached robots.txt. */
|
|
197
|
+
export function _resetRobotsGate() {
|
|
198
|
+
checkers.clear();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Test/diagnostic hook: total robots.txt requests made across all identities. */
|
|
202
|
+
export function _robotsFetchCount() {
|
|
203
|
+
let total = 0;
|
|
204
|
+
for (const checker of checkers.values()) total += checker.fetchCount;
|
|
205
|
+
return total;
|
|
206
|
+
}
|
|
@@ -4,13 +4,16 @@ import { promisify } from 'util';
|
|
|
4
4
|
import { CacheManager } from '../core/cache/CacheManager.js';
|
|
5
5
|
import { normalizeUrl } from './urlNormalizer.js';
|
|
6
6
|
import { safeFetch } from './ssrfGuard.js';
|
|
7
|
+
import { CRAWLFORGE_USER_AGENT } from './fetchIdentity.js';
|
|
8
|
+
import { preflightFetch } from './robotsGate.js';
|
|
9
|
+
import { noteRetryAfter } from './hostRateLimiter.js';
|
|
7
10
|
|
|
8
11
|
const gunzip = promisify(zlib.gunzip);
|
|
9
12
|
|
|
10
13
|
export class SitemapParser {
|
|
11
14
|
constructor(options = {}) {
|
|
12
15
|
const {
|
|
13
|
-
userAgent =
|
|
16
|
+
userAgent = CRAWLFORGE_USER_AGENT,
|
|
14
17
|
timeout = 10000,
|
|
15
18
|
maxRecursionDepth = 3,
|
|
16
19
|
maxUrlsPerSitemap = 50000,
|
|
@@ -50,13 +53,17 @@ export class SitemapParser {
|
|
|
50
53
|
* Parse a sitemap from a URL with full feature support
|
|
51
54
|
* @param {string} url - Sitemap URL
|
|
52
55
|
* @param {Object} options - Parsing options
|
|
56
|
+
* @param {boolean} [options.respectRobots] - Per-request robots override.
|
|
57
|
+
* Per call, not per instance: MapSiteTool builds one SitemapParser and
|
|
58
|
+
* reuses it, so a flag stored on the instance would leak between requests.
|
|
53
59
|
* @returns {Promise<Object>} Parsed sitemap data
|
|
54
60
|
*/
|
|
55
61
|
async parseSitemap(url, options = {}) {
|
|
56
62
|
const {
|
|
57
63
|
includeMetadata = true,
|
|
58
64
|
followIndexes = true,
|
|
59
|
-
maxDepth = this.maxRecursionDepth
|
|
65
|
+
maxDepth = this.maxRecursionDepth,
|
|
66
|
+
respectRobots
|
|
60
67
|
} = options;
|
|
61
68
|
|
|
62
69
|
// Reset stats for new parsing session
|
|
@@ -72,7 +79,8 @@ export class SitemapParser {
|
|
|
72
79
|
try {
|
|
73
80
|
const result = await this._parseSitemapRecursive(url, 0, maxDepth, {
|
|
74
81
|
includeMetadata,
|
|
75
|
-
followIndexes
|
|
82
|
+
followIndexes,
|
|
83
|
+
respectRobots
|
|
76
84
|
});
|
|
77
85
|
|
|
78
86
|
return {
|
|
@@ -99,11 +107,12 @@ export class SitemapParser {
|
|
|
99
107
|
/**
|
|
100
108
|
* Parse sitemap index files and return all contained sitemaps
|
|
101
109
|
* @param {string} indexUrl - Sitemap index URL
|
|
110
|
+
* @param {boolean} [respectRobots] - Per-request robots override
|
|
102
111
|
* @returns {Promise<Array>} Array of sitemap URLs with metadata
|
|
103
112
|
*/
|
|
104
|
-
async parseSitemapIndex(indexUrl) {
|
|
113
|
+
async parseSitemapIndex(indexUrl, respectRobots) {
|
|
105
114
|
try {
|
|
106
|
-
const content = await this._fetchSitemapContent(indexUrl);
|
|
115
|
+
const content = await this._fetchSitemapContent(indexUrl, respectRobots);
|
|
107
116
|
if (!content) return [];
|
|
108
117
|
|
|
109
118
|
const $ = load(content, { xmlMode: true });
|
|
@@ -260,9 +269,10 @@ export class SitemapParser {
|
|
|
260
269
|
* Discover sitemap URLs from various sources
|
|
261
270
|
* @param {string} baseUrl - Base URL of the website
|
|
262
271
|
* @param {Object} sources - Sources to check
|
|
272
|
+
* @param {boolean} [respectRobots] - Per-request robots override
|
|
263
273
|
* @returns {Promise<Array>} Array of discovered sitemap URLs
|
|
264
274
|
*/
|
|
265
|
-
async discoverSitemaps(baseUrl, sources = {}) {
|
|
275
|
+
async discoverSitemaps(baseUrl, sources = {}, respectRobots) {
|
|
266
276
|
const {
|
|
267
277
|
checkRobotsTxt = true,
|
|
268
278
|
checkCommonPaths = true,
|
|
@@ -277,7 +287,7 @@ export class SitemapParser {
|
|
|
277
287
|
if (checkRobotsTxt) {
|
|
278
288
|
try {
|
|
279
289
|
const robotsUrl = `${baseOrigin}/robots.txt`;
|
|
280
|
-
const robotsContent = await this._fetchWithTimeout(robotsUrl);
|
|
290
|
+
const robotsContent = await this._fetchWithTimeout(robotsUrl, respectRobots);
|
|
281
291
|
if (robotsContent) {
|
|
282
292
|
const sitemapMatches = robotsContent.match(/^Sitemap:\s*(.+)$/gmi);
|
|
283
293
|
if (sitemapMatches) {
|
|
@@ -308,7 +318,7 @@ export class SitemapParser {
|
|
|
308
318
|
for (const path of commonPaths) {
|
|
309
319
|
const sitemapUrl = `${baseOrigin}${path}`;
|
|
310
320
|
try {
|
|
311
|
-
const response = await this._fetchWithTimeoutResponse(sitemapUrl);
|
|
321
|
+
const response = await this._fetchWithTimeoutResponse(sitemapUrl, respectRobots);
|
|
312
322
|
if (response && response.ok) {
|
|
313
323
|
discovered.add(sitemapUrl);
|
|
314
324
|
}
|
|
@@ -344,7 +354,7 @@ export class SitemapParser {
|
|
|
344
354
|
}
|
|
345
355
|
|
|
346
356
|
try {
|
|
347
|
-
const content = await this._fetchSitemapContent(url);
|
|
357
|
+
const content = await this._fetchSitemapContent(url, options.respectRobots);
|
|
348
358
|
if (!content) {
|
|
349
359
|
throw new Error(`Failed to fetch sitemap content from ${url}`);
|
|
350
360
|
}
|
|
@@ -385,9 +395,9 @@ export class SitemapParser {
|
|
|
385
395
|
* Fetch and decompress sitemap content
|
|
386
396
|
* @private
|
|
387
397
|
*/
|
|
388
|
-
async _fetchSitemapContent(url) {
|
|
398
|
+
async _fetchSitemapContent(url, respectRobots) {
|
|
389
399
|
try {
|
|
390
|
-
const response = await this._fetchWithTimeoutResponse(url);
|
|
400
|
+
const response = await this._fetchWithTimeoutResponse(url, respectRobots);
|
|
391
401
|
if (!response || !response.ok) {
|
|
392
402
|
return null;
|
|
393
403
|
}
|
|
@@ -621,8 +631,8 @@ export class SitemapParser {
|
|
|
621
631
|
* Fetch with timeout
|
|
622
632
|
* @private
|
|
623
633
|
*/
|
|
624
|
-
async _fetchWithTimeout(url) {
|
|
625
|
-
const response = await this._fetchWithTimeoutResponse(url);
|
|
634
|
+
async _fetchWithTimeout(url, respectRobots) {
|
|
635
|
+
const response = await this._fetchWithTimeoutResponse(url, respectRobots);
|
|
626
636
|
return response ? await response.text() : null;
|
|
627
637
|
}
|
|
628
638
|
|
|
@@ -630,7 +640,12 @@ export class SitemapParser {
|
|
|
630
640
|
* Fetch with timeout returning response object
|
|
631
641
|
* @private
|
|
632
642
|
*/
|
|
633
|
-
async _fetchWithTimeoutResponse(url) {
|
|
643
|
+
async _fetchWithTimeoutResponse(url, respectRobots) {
|
|
644
|
+
const gate = await preflightFetch(url, {
|
|
645
|
+
respectRobots,
|
|
646
|
+
userAgent: this.userAgent,
|
|
647
|
+
tool: 'sitemap'
|
|
648
|
+
});
|
|
634
649
|
const controller = new AbortController();
|
|
635
650
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
636
651
|
|
|
@@ -638,12 +653,15 @@ export class SitemapParser {
|
|
|
638
653
|
const response = await safeFetch(url, {
|
|
639
654
|
signal: controller.signal,
|
|
640
655
|
headers: {
|
|
641
|
-
|
|
656
|
+
...gate.headers,
|
|
642
657
|
'Accept': 'application/xml,text/xml,text/plain,*/*',
|
|
643
658
|
'Accept-Encoding': 'gzip, deflate'
|
|
644
659
|
}
|
|
645
660
|
});
|
|
646
661
|
clearTimeout(timeoutId);
|
|
662
|
+
if (response.status === 429 || response.status === 503) {
|
|
663
|
+
noteRetryAfter(url, response.headers.get('retry-after'));
|
|
664
|
+
}
|
|
647
665
|
return response;
|
|
648
666
|
} catch (error) {
|
|
649
667
|
clearTimeout(timeoutId);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { promisify } from 'util';
|
|
7
7
|
import dns from 'dns';
|
|
8
8
|
import net from 'net';
|
|
9
|
+
import { identityHeaders } from './fetchIdentity.js';
|
|
9
10
|
|
|
10
11
|
const dnsLookup = promisify(dns.lookup);
|
|
11
12
|
|
|
@@ -560,7 +561,7 @@ export class SSRFProtection {
|
|
|
560
561
|
timeout: Math.min(fetchOptions.timeout || 30000, this.config.maxTimeout),
|
|
561
562
|
redirect: 'manual', // Handle redirects manually
|
|
562
563
|
headers: {
|
|
563
|
-
'
|
|
564
|
+
...identityHeaders({ role: 'health-check' }),
|
|
564
565
|
...fetchOptions.headers
|
|
565
566
|
}
|
|
566
567
|
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webBotAuth — signs outbound requests so a site owner can verify who we are.
|
|
3
|
+
*
|
|
4
|
+
* A User-Agent is a claim anyone can make. Web Bot Auth turns our identity into
|
|
5
|
+
* something a site can check: an Ed25519 signature over the request, per
|
|
6
|
+
* RFC 9421 (HTTP Message Signatures) with the `web-bot-auth` profile from
|
|
7
|
+
* draft-meunier-web-bot-auth-architecture. The public key is published at
|
|
8
|
+
* `/.well-known/http-message-signatures-directory` on crawlforge.dev.
|
|
9
|
+
*
|
|
10
|
+
* This is the mechanism behind ground rule G4. Honest identification only helps
|
|
11
|
+
* a site owner if it cannot be spoofed by someone else claiming to be us.
|
|
12
|
+
*
|
|
13
|
+
* Signing is OPT-IN and absent by default: with no key configured every export
|
|
14
|
+
* here is a no-op and requests go out exactly as before. Key material lives
|
|
15
|
+
* only in the environment, never in the repo.
|
|
16
|
+
*
|
|
17
|
+
* Verified against the official test vectors (architecture draft Appendix A.2.1
|
|
18
|
+
* with the RFC 9421 Appendix B.1.4 key) — see tests/unit/webBotAuth.test.js.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash, createPrivateKey, createPublicKey, sign, randomBytes } from 'crypto';
|
|
22
|
+
|
|
23
|
+
/** The profile tag every web-bot-auth signature carries. */
|
|
24
|
+
const WEB_BOT_AUTH_TAG = 'web-bot-auth';
|
|
25
|
+
|
|
26
|
+
/** The draft RECOMMENDS an expiry no more than 24 hours; ours is far shorter. */
|
|
27
|
+
const DEFAULT_LIFETIME_SECONDS = 300;
|
|
28
|
+
|
|
29
|
+
let cachedKey; // undefined = not yet resolved, null = none configured
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The raw 32-byte Ed25519 public key as base64url, which is the JWK `x`.
|
|
33
|
+
* @param {import('crypto').KeyObject} publicKey
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function publicKeyX(publicKey) {
|
|
37
|
+
// An Ed25519 SPKI DER is a 12-byte header followed by the 32-byte key.
|
|
38
|
+
const der = publicKey.export({ type: 'spki', format: 'der' });
|
|
39
|
+
return Buffer.from(der.subarray(der.length - 32)).toString('base64url');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* RFC 8037 Appendix A.3 thumbprint: SHA-256 over the canonical JWK with its
|
|
44
|
+
* member names in lexicographic order and no whitespace, base64url unpadded.
|
|
45
|
+
* The member order is load-bearing — reordering it changes the key id.
|
|
46
|
+
* @param {string} x base64url raw public key
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function jwkThumbprint(x) {
|
|
50
|
+
const canonical = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x });
|
|
51
|
+
return createHash('sha256').update(canonical).digest('base64url');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The public JWK for a key pair, in the shape the directory publishes.
|
|
56
|
+
* @param {import('crypto').KeyObject} publicKey
|
|
57
|
+
*/
|
|
58
|
+
export function publicJwk(publicKey) {
|
|
59
|
+
const x = publicKeyX(publicKey);
|
|
60
|
+
return { kty: 'OKP', crv: 'Ed25519', kid: jwkThumbprint(x), x, use: 'sig', alg: 'ed25519' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the signing key from the environment, or null when none is set.
|
|
65
|
+
*
|
|
66
|
+
* `CRAWLFORGE_SIGNING_KEY` holds an Ed25519 private key as a PKCS#8 PEM —
|
|
67
|
+
* either literally (with real newlines) or base64-encoded, since most secret
|
|
68
|
+
* stores mangle multi-line values. A malformed key is a configuration error we
|
|
69
|
+
* surface once and then ignore: it must not take every fetch down with it.
|
|
70
|
+
*
|
|
71
|
+
* @returns {{ privateKey: import('crypto').KeyObject, jwk: object } | null}
|
|
72
|
+
*/
|
|
73
|
+
export function getSigningKey() {
|
|
74
|
+
if (cachedKey !== undefined) return cachedKey;
|
|
75
|
+
|
|
76
|
+
const raw = process.env.CRAWLFORGE_SIGNING_KEY;
|
|
77
|
+
if (!raw || !raw.trim()) {
|
|
78
|
+
cachedKey = null;
|
|
79
|
+
return cachedKey;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const pem = raw.includes('-----BEGIN')
|
|
84
|
+
? raw.replace(/\\n/g, '\n')
|
|
85
|
+
: Buffer.from(raw.trim(), 'base64').toString('utf8');
|
|
86
|
+
|
|
87
|
+
const privateKey = createPrivateKey(pem);
|
|
88
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') {
|
|
89
|
+
throw new Error(`expected an ed25519 key, got ${privateKey.asymmetricKeyType}`);
|
|
90
|
+
}
|
|
91
|
+
cachedKey = { privateKey, jwk: publicJwk(createPublicKey(privateKey)) };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(
|
|
94
|
+
`[web-bot-auth] CRAWLFORGE_SIGNING_KEY could not be loaded, so requests will go out unsigned: ${error.message}`
|
|
95
|
+
);
|
|
96
|
+
cachedKey = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return cachedKey;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Serialise the @signature-params of a signature base.
|
|
104
|
+
* Parameter order is part of the signed bytes, so it must match what the
|
|
105
|
+
* verifier reconstructs — it is the draft's order, not an arbitrary one.
|
|
106
|
+
*/
|
|
107
|
+
function signatureParams(components, { created, expires, keyid, nonce }) {
|
|
108
|
+
const covered = components.map((c) => `"${c}"`).join(' ');
|
|
109
|
+
return (
|
|
110
|
+
`(${covered})` +
|
|
111
|
+
`;created=${created}` +
|
|
112
|
+
`;keyid="${keyid}"` +
|
|
113
|
+
`;alg="ed25519"` +
|
|
114
|
+
`;expires=${expires}` +
|
|
115
|
+
`;nonce="${nonce}"` +
|
|
116
|
+
`;tag="${WEB_BOT_AUTH_TAG}"`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build the RFC 9421 signature base for a request.
|
|
122
|
+
*
|
|
123
|
+
* Exported for the test vectors: the base is the exact byte string that gets
|
|
124
|
+
* signed, so reproducing the published one is what proves the implementation
|
|
125
|
+
* interoperates rather than merely agreeing with itself.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ authority: string, signatureAgent?: string|null }} request
|
|
128
|
+
* @param {{ created: number, expires: number, keyid: string, nonce: string }} params
|
|
129
|
+
* @returns {{ base: string, components: string[], params: string }}
|
|
130
|
+
*/
|
|
131
|
+
export function buildSignatureBase(request, params) {
|
|
132
|
+
const components = ['@authority'];
|
|
133
|
+
const lines = [`"@authority": ${request.authority}`];
|
|
134
|
+
|
|
135
|
+
// The draft requires Signature-Agent to be covered whenever it is sent.
|
|
136
|
+
if (request.signatureAgent) {
|
|
137
|
+
components.push('signature-agent');
|
|
138
|
+
lines.push(`"signature-agent": "${request.signatureAgent}"`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const serialised = signatureParams(components, params);
|
|
142
|
+
lines.push(`"@signature-params": ${serialised}`);
|
|
143
|
+
|
|
144
|
+
return { base: lines.join('\n'), components, params: serialised };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Signature headers for an outbound request, or null when signing is off.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} url the request target
|
|
151
|
+
* @param {object} [options]
|
|
152
|
+
* @param {string|null} [options.signatureAgent] directory URL to advertise
|
|
153
|
+
* @param {number} [options.now] epoch seconds, for deterministic tests
|
|
154
|
+
* @param {number} [options.expires] epoch seconds, for deterministic tests
|
|
155
|
+
* @param {string} [options.nonce] base64 nonce, for deterministic tests
|
|
156
|
+
* @returns {Record<string,string>|null}
|
|
157
|
+
*/
|
|
158
|
+
export function signRequestHeaders(url, options = {}) {
|
|
159
|
+
const key = getSigningKey();
|
|
160
|
+
if (!key) return null;
|
|
161
|
+
|
|
162
|
+
let authority;
|
|
163
|
+
try {
|
|
164
|
+
authority = new URL(url).host;
|
|
165
|
+
} catch {
|
|
166
|
+
return null; // not our job to validate URLs; the fetch path already does
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const created = options.now ?? Math.floor(Date.now() / 1000);
|
|
170
|
+
const expires = options.expires ?? created + DEFAULT_LIFETIME_SECONDS;
|
|
171
|
+
// The draft RECOMMENDS 64 random bytes, unique within the validity window.
|
|
172
|
+
const nonce = options.nonce ?? randomBytes(64).toString('base64');
|
|
173
|
+
const signatureAgent = options.signatureAgent ?? process.env.WEB_BOT_AUTH_DIRECTORY ?? null;
|
|
174
|
+
|
|
175
|
+
const { base, params } = buildSignatureBase(
|
|
176
|
+
{ authority, signatureAgent },
|
|
177
|
+
{ created, expires, keyid: key.jwk.kid, nonce }
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const signature = sign(null, Buffer.from(base, 'utf8'), key.privateKey).toString('base64');
|
|
181
|
+
|
|
182
|
+
const headers = {
|
|
183
|
+
'Signature-Input': `sig1=${params}`,
|
|
184
|
+
'Signature': `sig1=:${signature}:`
|
|
185
|
+
};
|
|
186
|
+
if (signatureAgent) headers['Signature-Agent'] = `"${signatureAgent}"`;
|
|
187
|
+
return headers;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Test hook: forget the resolved key so a changed env var is picked up. */
|
|
191
|
+
export function _resetSigningKey() {
|
|
192
|
+
cachedKey = undefined;
|
|
193
|
+
}
|