Skip to content

Commit fb0ac14

Browse files
authored
fix(mcp): restore hosted keyless Parse safety boundaries (#334)
1 parent 8ead330 commit fb0ac14

3 files changed

Lines changed: 413 additions & 13 deletions

File tree

src/index.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createRequire } from 'node:module';
88
import { randomUUID } from 'node:crypto';
99
import path from 'node:path';
1010
import { z } from 'zod';
11+
import { extractSinglePublicClientIp } from './keyless-client-ip';
1112
import { registerMonitorTools } from './monitor';
1213
import { registerResearchTools } from './research';
1314
import {
@@ -1586,6 +1587,16 @@ async function executeHostedParse(
15861587
});
15871588
}
15881589

1590+
if (isHostedKeylessSession(session) && args.zeroDataRetention === true) {
1591+
const payload = {
1592+
...recoveryPayload('KEYLESS_OPTION_NOT_AVAILABLE'),
1593+
option: 'zeroDataRetention',
1594+
message:
1595+
'Zero Data Retention is not available in anonymous keyless mode. Omit zeroDataRetention to parse with keyless access, or connect an account or configure an API key for a team where Zero Data Retention is enabled, then retry.',
1596+
};
1597+
throw new UserError(String(payload.message), payload);
1598+
}
1599+
15891600
const options = extractParseOptions(args);
15901601

15911602
if (hasFilePath && args.filePath) {
@@ -2016,10 +2027,7 @@ function resolveApiBaseUrl(): string {
20162027
function extractClientIp(request?: {
20172028
headers: IncomingHttpHeaders;
20182029
}): string | undefined {
2019-
const xff = request?.headers?.['x-forwarded-for'];
2020-
const raw = Array.isArray(xff) ? xff[0] : xff;
2021-
const first = typeof raw === 'string' ? raw.split(',')[0].trim() : undefined;
2022-
return first || undefined;
2030+
return extractSinglePublicClientIp(request?.headers?.['x-forwarded-for']);
20232031
}
20242032

20252033
/**
@@ -3094,15 +3102,15 @@ In local/non-cloud MCP mode, this tool reads filePath from the MCP server filesy
30943102
30953103
In hosted CLOUD_SERVICE mode, this tool is a two-call flow because hosted MCP cannot read your local filesystem:
30963104
1. Call with filePath, contentType, parse options, and optional declaredSizeBytes. The hosted server mints a short-lived upload URL and returns a safe local curl PUT command plus nextToolCall.
3097-
2. Run the returned curl command locally, then call firecrawl_parse again with uploadRef and the desired parse options. The hosted server calls /v2/parse server-side with your session credential.
3105+
2. Run the returned curl command locally, then call firecrawl_parse again with uploadRef and the desired parse options. The hosted server calls /v2/parse server-side with your account credential or eligible anonymous keyless session.
30983106
30993107
**Best for:** Extracting content from a local document (PDF, Word, Excel, HTML, etc.); pulling structured data out of a file with JSON format; converting binary documents into markdown for downstream reasoning.
31003108
**Not recommended for:** Remote URLs (use firecrawl_scrape); multiple files at once (call parse multiple times); documents that require interactive actions, screenshots, or change tracking — those aren't supported by the parse endpoint.
31013109
**Common mistakes:** In hosted mode, do not pass both filePath and uploadRef. Phase 1 uses filePath only to generate upload instructions; phase 2 uses uploadRef only to parse server-side.
31023110
31033111
**Supported file types:** .html, .htm, .xhtml, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls
31043112
**Unsupported options:** actions, screenshot/branding/changeTracking formats, waitFor > 0, location, mobile, proxy values other than "auto" or "basic".
3105-
**Privacy:** Set \`redactPII: true\` to return content with personally identifiable information redacted.
3113+
**Privacy:** Set \`redactPII: true\` to return content with personally identifiable information redacted. \`zeroDataRetention: true\` requires an account or API key for a team where Zero Data Retention is enabled; omit it for anonymous keyless use.
31063114
31073115
**CRITICAL - Format Selection (same rules as firecrawl_scrape):**
31083116
When the user asks for SPECIFIC data points from a document, you MUST use JSON format with a schema. Only use markdown when the user needs the ENTIRE document content.
@@ -3118,8 +3126,7 @@ Add \`"parsers": ["pdf"]\` (optionally with \`pdfOptions.maxPages\`) when parsin
31183126
"filePath": "/absolute/path/to/document.pdf",
31193127
"contentType": "application/pdf",
31203128
"formats": ["markdown"],
3121-
"parsers": ["pdf"],
3122-
"zeroDataRetention": true
3129+
"parsers": ["pdf"]
31233130
}
31243131
}
31253132
\`\`\`
@@ -3131,8 +3138,7 @@ Add \`"parsers": ["pdf"]\` (optionally with \`pdfOptions.maxPages\`) when parsin
31313138
"arguments": {
31323139
"uploadRef": "upload-ref-from-phase-1",
31333140
"formats": ["markdown"],
3134-
"parsers": ["pdf"],
3135-
"zeroDataRetention": true
3141+
"parsers": ["pdf"]
31363142
}
31373143
}
31383144
\`\`\`

src/keyless-client-ip.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import net from 'node:net';
2+
3+
function parseIpv4(value: string): number[] | undefined {
4+
const parts = value.split('.');
5+
if (parts.length !== 4) return undefined;
6+
const bytes = parts.map((part) => {
7+
if (!/^\d+$/.test(part)) return Number.NaN;
8+
const value = Number(part);
9+
return value >= 0 && value <= 255 ? value : Number.NaN;
10+
});
11+
return bytes.some(Number.isNaN) ? undefined : bytes;
12+
}
13+
14+
function parseIpv6Words(value: string): number[] | undefined {
15+
let input = value.toLowerCase();
16+
const zoneIndex = input.indexOf('%');
17+
if (zoneIndex >= 0) input = input.slice(0, zoneIndex);
18+
19+
const lastColon = input.lastIndexOf(':');
20+
if (lastColon >= 0 && input.slice(lastColon + 1).includes('.')) {
21+
const ipv4Tail = parseIpv4(input.slice(lastColon + 1));
22+
if (!ipv4Tail) return undefined;
23+
const firstWord = ((ipv4Tail[0] << 8) | ipv4Tail[1]).toString(16);
24+
const secondWord = ((ipv4Tail[2] << 8) | ipv4Tail[3]).toString(16);
25+
input = `${input.slice(0, lastColon)}:${firstWord}:${secondWord}`;
26+
}
27+
28+
const halves = input.split('::');
29+
if (halves.length > 2) return undefined;
30+
const left = halves[0] ? halves[0].split(':') : [];
31+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
32+
const parseWord = (part: string): number | undefined => {
33+
if (!/^[0-9a-f]{1,4}$/.test(part)) return undefined;
34+
return Number.parseInt(part, 16);
35+
};
36+
const words = [...left, ...right].map(parseWord);
37+
if (words.some((word) => word === undefined)) return undefined;
38+
const missing = halves.length === 2 ? 8 - words.length : 0;
39+
if ((halves.length === 1 && words.length !== 8) || missing < 0) {
40+
return undefined;
41+
}
42+
return [
43+
...(left.map(parseWord) as number[]),
44+
...Array(missing).fill(0),
45+
...(right.map(parseWord) as number[]),
46+
];
47+
}
48+
49+
function ipv4In(
50+
bytes: number[],
51+
first: number,
52+
secondStart?: number,
53+
secondEnd?: number
54+
): boolean {
55+
if (bytes[0] !== first) return false;
56+
if (secondStart === undefined) return true;
57+
return bytes[1] >= secondStart && bytes[1] <= (secondEnd ?? secondStart);
58+
}
59+
60+
function isPublicIpLiteral(value: string): boolean {
61+
const ipVersion = net.isIP(value);
62+
if (ipVersion === 4) {
63+
const bytes = parseIpv4(value);
64+
if (!bytes) return false;
65+
if (ipv4In(bytes, 0)) return false;
66+
if (ipv4In(bytes, 10)) return false;
67+
if (ipv4In(bytes, 100, 64, 127)) return false; // CGNAT
68+
if (ipv4In(bytes, 127)) return false;
69+
if (ipv4In(bytes, 169, 254)) return false;
70+
if (ipv4In(bytes, 172, 16, 31)) return false;
71+
if (ipv4In(bytes, 192, 0, 0)) return false;
72+
if (ipv4In(bytes, 192, 0, 2)) return false; // TEST-NET-1
73+
if (ipv4In(bytes, 192, 88, 99)) return false; // 6to4 relay anycast
74+
if (ipv4In(bytes, 192, 168)) return false;
75+
if (ipv4In(bytes, 198, 18, 19)) return false; // benchmark
76+
if (ipv4In(bytes, 198, 51, 100)) return false; // TEST-NET-2
77+
if (ipv4In(bytes, 203, 0, 113)) return false; // TEST-NET-3
78+
if (bytes[0] >= 224) return false;
79+
return true;
80+
}
81+
82+
if (ipVersion === 6) {
83+
const words = parseIpv6Words(value);
84+
if (!words) return false;
85+
const first = words[0];
86+
const isAllZero = words.every((word) => word === 0);
87+
if (isAllZero) return false;
88+
if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) return false;
89+
if ((first & 0xffc0) === 0xfe80) return false; // link-local
90+
if ((first & 0xfe00) === 0xfc00) return false; // unique local
91+
if ((first & 0xff00) === 0xff00) return false; // multicast
92+
if ((first & 0xfff0) === 0x2001 && words[1] === 0x0db8) return false; // documentation
93+
if (first === 0x2002) return false; // 6to4 embeds IPv4
94+
if (first === 0x64 && words[1] === 0xff9b) return false; // well-known NAT64
95+
const isMapped =
96+
words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff;
97+
if (isMapped) {
98+
const bytes = [
99+
words[6] >> 8,
100+
words[6] & 0xff,
101+
words[7] >> 8,
102+
words[7] & 0xff,
103+
];
104+
return isPublicIpLiteral(bytes.join('.'));
105+
}
106+
return true;
107+
}
108+
109+
return false;
110+
}
111+
112+
/**
113+
* Returns a trusted client identity only when the hosting edge has replaced
114+
* X-Forwarded-For with one public address. Multi-hop or private values are
115+
* client-spoofable at the application boundary, so keyless access fails closed.
116+
*/
117+
export function extractSinglePublicClientIp(
118+
rawForwardedFor: string | string[] | undefined
119+
): string | undefined {
120+
const raw = Array.isArray(rawForwardedFor)
121+
? rawForwardedFor[0]
122+
: rawForwardedFor;
123+
if (typeof raw !== 'string') return undefined;
124+
125+
const parts = raw
126+
.split(',')
127+
.map((part) => part.trim())
128+
.filter(Boolean);
129+
if (parts.length !== 1) return undefined;
130+
131+
const candidate = parts[0].replace(/^\[(.*)\]$/, '$1').toLowerCase();
132+
return isPublicIpLiteral(candidate) ? candidate : undefined;
133+
}

0 commit comments

Comments
 (0)