From 2cd8c2455a95f665019a023cae46b219394274df Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:03:57 +0000 Subject: [PATCH 1/2] feat(world-id): verify with World ID and send the credential on keyless requests --- package.json | 2 +- src/__tests__/commands/world-id.test.ts | 201 +++++++++++++++++++++++ src/commands/interact.ts | 3 +- src/commands/parse.ts | 3 +- src/commands/world-id.ts | 206 ++++++++++++++++++++++++ src/index.ts | 2 + src/utils/auth.ts | 2 +- src/utils/client.ts | 6 +- src/utils/world-id.ts | 72 +++++++++ 9 files changed, 492 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/commands/world-id.test.ts create mode 100644 src/commands/world-id.ts create mode 100644 src/utils/world-id.ts diff --git a/package.json b/package.json index 04add921d4..acbb0e929b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.26.18", + "version": "1.27.0", "publishConfig": { "tag": "latest" }, diff --git a/src/__tests__/commands/world-id.test.ts b/src/__tests__/commands/world-id.test.ts new file mode 100644 index 0000000000..191274ed27 --- /dev/null +++ b/src/__tests__/commands/world-id.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for World ID verification (device flow) and the keyless header. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'firecrawl-world-id-')); + +vi.mock('../../utils/credentials', () => ({ + getConfigDirectoryPath: () => configDir, + loadCredentials: () => null, +})); +vi.mock('../../utils/auth', () => ({ openBrowser: vi.fn(async () => {}) })); + +import { + verifyWorldId, + WORLD_ID_UNAVAILABLE_MESSAGE, +} from '../../commands/world-id'; +import { keylessGet, keylessRequest } from '../../utils/client'; +import { + activeWorldIdCredential, + clearWorldIdCredential, + loadWorldIdCredential, + saveWorldIdCredential, +} from '../../utils/world-id'; + +const CREDENTIAL = 'fcwid_payload.signature'; +const START = { + success: true, + device_handle: 'handle-1', + user_code: 'ABCD-EFGH', + verification_uri: 'https://world.example/device', + expires_in: 1200, + interval: 5, +}; + +function stubResponses(...responses: Array<[number, unknown]>) { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => { + const [status, body] = responses.shift() ?? [500, {}]; + return new Response(JSON.stringify(body), { status }); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +const quiet = { log: () => {}, sleep: async () => {}, openBrowser: false }; + +describe('firecrawl world-id', () => { + beforeEach(() => { + clearWorldIdCredential(); + delete process.env.FIRECRAWL_WORLD_ID; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('runs the device flow and stores the credential', async () => { + const fetchMock = stubResponses( + [200, START], + [400, { success: false, error: 'authorization_pending' }], + [ + 200, + { + success: true, + credential: CREDENTIAL, + expires_at: '2099-01-01T00:00:00.000Z', + }, + ] + ); + + const stored = await verifyWorldId(quiet); + + expect(stored).toEqual({ + credential: CREDENTIAL, + expiresAt: '2099-01-01T00:00:00.000Z', + }); + expect(loadWorldIdCredential()).toEqual(stored); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://api.firecrawl.dev/v2/keyless/world-id/device', + 'https://api.firecrawl.dev/v2/keyless/world-id/token', + 'https://api.firecrawl.dev/v2/keyless/world-id/token', + ]); + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ + device_handle: 'handle-1', + }); + const headers = fetchMock.mock.calls[0][1]?.headers as Record< + string, + string + >; + expect(headers['X-Origin']).toBe('cli'); + expect(headers.Authorization).toBeUndefined(); + }); + + it('says so when the API has World ID turned off', async () => { + stubResponses([404, { success: false, error: 'Not found' }]); + + await expect(verifyWorldId(quiet)).rejects.toThrow( + WORLD_ID_UNAVAILABLE_MESSAGE + ); + expect(loadWorldIdCredential()).toBeNull(); + }); + + it('slows down when asked and stops on a declined approval', async () => { + const sleep = vi.fn(async (_ms: number) => {}); + stubResponses( + [200, START], + [400, { success: false, error: 'slow_down' }], + [400, { success: false, error: 'access_denied' }] + ); + + await expect(verifyWorldId({ ...quiet, sleep })).rejects.toThrow( + 'World ID verification was declined.' + ); + expect(sleep.mock.calls.map(([ms]) => ms)).toEqual([5000, 10000]); + }); + + it('reports a World ID that is not on the allowlist', async () => { + stubResponses( + [200, START], + [ + 403, + { + success: false, + error: 'subject_not_allowed', + subject_hash: 'h'.repeat(43), + }, + ] + ); + + await expect(verifyWorldId(quiet)).rejects.toThrow('h'.repeat(43)); + }); + + it('gives up when the code expires', async () => { + let clock = 0; + stubResponses( + [200, { ...START, expires_in: 10 }], + [400, { success: false, error: 'authorization_pending' }], + [400, { success: false, error: 'authorization_pending' }] + ); + + await expect( + verifyWorldId({ + ...quiet, + now: () => clock, + sleep: async (ms: number) => { + clock += ms; + }, + }) + ).rejects.toThrow('The World ID code expired'); + }); + + it('sends the stored credential on keyless requests only while unexpired', async () => { + const fetchMock = stubResponses( + [200, { success: true }], + [200, { success: true }] + ); + saveWorldIdCredential({ + credential: CREDENTIAL, + expiresAt: '2099-01-01T00:00:00.000Z', + }); + + await keylessRequest('/v2/scrape', { url: 'https://example.com' }); + await keylessGet('/v2/research/search?q=test'); + + for (const [, init] of fetchMock.mock.calls) { + const headers = init?.headers as Record; + expect(headers['x-firecrawl-world-id']).toBe(CREDENTIAL); + expect(headers.Authorization).toBeUndefined(); + } + + saveWorldIdCredential({ + credential: CREDENTIAL, + expiresAt: '2000-01-01T00:00:00.000Z', + }); + expect(activeWorldIdCredential()).toBeUndefined(); + }); + + it('prefers FIRECRAWL_WORLD_ID over the stored credential', () => { + saveWorldIdCredential({ + credential: CREDENTIAL, + expiresAt: '2099-01-01T00:00:00.000Z', + }); + process.env.FIRECRAWL_WORLD_ID = 'fcwid_from.env'; + + expect(activeWorldIdCredential()).toBe('fcwid_from.env'); + }); + + it('stores the credential readable by the owner only', () => { + saveWorldIdCredential({ + credential: CREDENTIAL, + expiresAt: '2099-01-01T00:00:00.000Z', + }); + const mode = + fs.statSync(path.join(configDir, 'world-id.json')).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); diff --git a/src/commands/interact.ts b/src/commands/interact.ts index c42b810b12..3b220efba5 100644 --- a/src/commands/interact.ts +++ b/src/commands/interact.ts @@ -5,6 +5,7 @@ import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; +import { worldIdHeaders } from '../utils/world-id'; import { getScrapeId, loadInteractSession, @@ -60,7 +61,7 @@ function buildHeaders(apiKey: string | undefined, keyless: boolean) { if (!keyless && apiKey) { headers.Authorization = `Bearer ${apiKey}`; } - if (keyless) Object.assign(headers, KEYLESS_CLI_HEADERS); + if (keyless) Object.assign(headers, KEYLESS_CLI_HEADERS, worldIdHeaders()); return { ...headers, ...integrationHeaders() }; } diff --git a/src/commands/parse.ts b/src/commands/parse.ts index cd852b23c6..1212994617 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -13,6 +13,7 @@ import type { ParseOptions, ParseResult } from '../types/parse'; import type { ScrapeFormat } from '../types/scrape'; import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; +import { worldIdHeaders } from '../utils/world-id'; import { handleScrapeOutput } from '../utils/output'; import { integrationHeaders } from '../utils/integration'; @@ -189,7 +190,7 @@ export async function executeParse( // CLI origin to the keyless check. headers: { ...(!keyless && apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), - ...(keyless ? KEYLESS_CLI_HEADERS : {}), + ...(keyless ? { ...KEYLESS_CLI_HEADERS, ...worldIdHeaders() } : {}), ...integrationHeaders(), }, body: form, diff --git a/src/commands/world-id.ts b/src/commands/world-id.ts new file mode 100644 index 0000000000..f36bbe465d --- /dev/null +++ b/src/commands/world-id.ts @@ -0,0 +1,206 @@ +/** + * `firecrawl world-id`: verify with World ID for the keyless tier. + * + * Runs the API's device flow (POST /v2/keyless/world-id/device, then polls + * /v2/keyless/world-id/token) and stores the `fcwid_` credential it returns. + * Keyless requests then send it as `x-firecrawl-world-id`. + */ + +import { Command } from 'commander'; +import { getConfig } from '../utils/config'; +import { KEYLESS_CLI_HEADERS } from '../utils/client'; +import { openBrowser } from '../utils/auth'; +import { + activeWorldIdCredential, + clearWorldIdCredential, + loadWorldIdCredential, + saveWorldIdCredential, + type StoredWorldIdCredential, +} from '../utils/world-id'; + +const DEFAULT_API_URL = 'https://api.firecrawl.dev'; + +export const WORLD_ID_UNAVAILABLE_MESSAGE = + 'World ID verification is not enabled on this Firecrawl API.'; + +interface DeviceStart { + device_handle: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; + interval: number; +} + +export interface VerifyWorldIdOptions { + openBrowser?: boolean; + log?: (message: string) => void; + sleep?: (ms: number) => Promise; + now?: () => number; +} + +function apiUrl(): string { + return (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); +} + +async function post( + path: string, + body: Record +): Promise<{ status: number; json: any; retryAfter?: number }> { + const response = await fetch(`${apiUrl()}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS }, + body: JSON.stringify(body), + }); + const json: any = await response.json().catch(() => ({})); + const retryAfter = Number(response.headers.get('retry-after')); + return { + status: response.status, + json, + ...(retryAfter > 0 ? { retryAfter } : {}), + }; +} + +function pollError(error: unknown, json: any): Error { + switch (error) { + case 'access_denied': + return new Error('World ID verification was declined.'); + case 'expired_token': + return new Error( + 'The World ID code expired. Run "firecrawl world-id verify" again.' + ); + case 'subject_not_allowed': + return new Error( + `World ID verification succeeded, but this World ID is not enabled for the verified keyless tier yet (subject ${json?.subject_hash ?? 'unknown'}).` + ); + default: + return new Error(`World ID verification failed: ${String(error)}`); + } +} + +/** Runs the device flow to completion and stores the credential. */ +export async function verifyWorldId( + options: VerifyWorldIdOptions = {} +): Promise { + const log = options.log ?? ((message: string) => console.error(message)); + const sleep = + options.sleep ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const now = options.now ?? Date.now; + + const start = await post('/v2/keyless/world-id/device', {}); + if (start.status === 404) throw new Error(WORLD_ID_UNAVAILABLE_MESSAGE); + if (start.status === 429) { + throw new Error( + `Too many World ID verification attempts. Try again in ${start.retryAfter ?? 60} seconds.` + ); + } + if (start.status !== 200 || typeof start.json?.device_handle !== 'string') { + throw new Error( + `Could not start World ID verification: ${start.json?.error ?? `HTTP ${start.status}`}` + ); + } + const device = start.json as DeviceStart; + const link = device.verification_uri_complete ?? device.verification_uri; + + log(`Verify with World ID at ${link}`); + log(`Code: ${device.user_code}`); + if (options.openBrowser !== false) { + await openBrowser(link).catch(() => undefined); + } + log('Waiting for approval in World App...'); + + let intervalSeconds = device.interval > 0 ? device.interval : 5; + const deadline = + now() + (device.expires_in > 0 ? device.expires_in : 1200) * 1000; + + while (now() < deadline) { + await sleep(intervalSeconds * 1000); + const poll = await post('/v2/keyless/world-id/token', { + device_handle: device.device_handle, + }); + if (poll.status === 200 && typeof poll.json?.credential === 'string') { + const stored = { + credential: poll.json.credential, + expiresAt: poll.json.expires_at, + }; + saveWorldIdCredential(stored); + return stored; + } + if (poll.status === 404) throw new Error(WORLD_ID_UNAVAILABLE_MESSAGE); + const error = poll.json?.error; + if (error === 'authorization_pending' || poll.status >= 500) continue; + if (error === 'slow_down') { + intervalSeconds = Math.max(intervalSeconds + 5, poll.retryAfter ?? 0); + continue; + } + throw pollError(error ?? `HTTP ${poll.status}`, poll.json); + } + throw pollError('expired_token', {}); +} + +function describeStatus(): Record { + if (process.env.FIRECRAWL_WORLD_ID?.trim()) { + return { verified: true, source: 'env' }; + } + const stored = loadWorldIdCredential(); + if (!stored) return { verified: false }; + return { + verified: Boolean(activeWorldIdCredential()), + source: 'stored', + expiresAt: stored.expiresAt, + }; +} + +export function createWorldIdCommand(): Command { + const command = new Command('world-id').description( + 'Verify with World ID for a keyless tier of your own, separate from your IP' + ); + + command + .command('verify') + .description('Verify with World ID and store the credential') + .option('--no-browser', 'Print the verification link without opening it') + .action(async (options) => { + const stored = await verifyWorldId({ openBrowser: options.browser }); + console.log( + `✓ Verified with World ID. Keyless requests use your own bucket until ${stored.expiresAt}.` + ); + }); + + command + .command('status') + .description('Show whether a World ID credential is stored') + .option('--json', 'Output JSON') + .action((options) => { + const status = describeStatus(); + if (options.json) { + console.log(JSON.stringify(status)); + return; + } + if (status.source === 'env') { + console.log('Using the World ID credential from FIRECRAWL_WORLD_ID.'); + } else if (!status.source) { + console.log('Not verified. Run "firecrawl world-id verify".'); + } else if (status.verified) { + console.log(`Verified with World ID until ${status.expiresAt}.`); + } else { + console.log( + `World ID credential expired at ${status.expiresAt}. Run "firecrawl world-id verify" again.` + ); + } + }); + + command + .command('clear') + .description('Remove the stored World ID credential') + .action(() => { + console.log( + clearWorldIdCredential() + ? '✓ Removed the stored World ID credential.' + : 'No World ID credential stored.' + ); + }); + + return command; +} diff --git a/src/index.ts b/src/index.ts index 2dc49d5446..20002a6d34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { Command, Option } from 'commander'; import { createSqlCommand } from './commands/sql'; +import { createWorldIdCommand } from './commands/world-id'; import { addFormatsAlias } from './utils/format-option'; import { addAlexandriaScrapeOptions, @@ -2588,6 +2589,7 @@ Shorthand: "firecrawl x" is an alias for "firecrawl experimental". ); experimental.addCommand(createDownloadCommand()); program.addCommand(createSqlCommand(), { hidden: true }); +program.addCommand(createWorldIdCommand(), { hidden: true }); experimental.addCommand(createSqlCommand(), { hidden: true }); program.addCommand(experimental); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 831d3245a9..b75df7b77e 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -30,7 +30,7 @@ const NO_LOGIN_INPUT_MESSAGE = /** * Open URL in the default browser */ -async function openBrowser(url: string): Promise { +export async function openBrowser(url: string): Promise { const { exec } = await import('child_process'); const platform = process.platform; diff --git a/src/utils/client.ts b/src/utils/client.ts index 0ce35d37cd..309481cac5 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -14,6 +14,7 @@ import { type GlobalConfig, } from './config'; import { integrationHeaders, withIntegrationHeader } from './integration'; +import { worldIdHeaders } from './world-id'; let clientInstance: Firecrawl | null = null; @@ -36,7 +37,8 @@ export function isKeylessMode(apiKey?: string, apiUrl?: string): boolean { * (GET research and developer lookups, interact stop) carry only this and * X-Integration. * Keyless error messages are printed as the API sends them: their - * firecrawl.dev/k/ link already resolves to CLI attribution. + * firecrawl.dev/k/ link already resolves to CLI attribution. A World ID + * credential, when present, rides along on every keyless request. */ export const KEYLESS_CLI_HEADERS: Readonly> = { 'X-Origin': 'cli', @@ -53,6 +55,7 @@ export async function keylessRequest( 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS, ...integrationHeaders(), + ...worldIdHeaders(), }, body: JSON.stringify(body), }); @@ -73,6 +76,7 @@ export async function keylessGet(path: string): Promise { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS, ...integrationHeaders(), + ...worldIdHeaders(), }, }); const json: any = await response.json().catch(() => ({})); diff --git a/src/utils/world-id.ts b/src/utils/world-id.ts new file mode 100644 index 0000000000..fbe6373a88 --- /dev/null +++ b/src/utils/world-id.ts @@ -0,0 +1,72 @@ +/** + * World ID credential for the keyless tier. + * + * A verified human's `fcwid_` credential (from `firecrawl world-id verify`) + * is sent as `x-firecrawl-world-id` on keyless requests, which the API then + * counts against that human's own bucket instead of the IP. The API ignores + * the header while the feature is off. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getConfigDirectoryPath } from './credentials'; + +export const WORLD_ID_HEADER = 'x-firecrawl-world-id'; + +export interface StoredWorldIdCredential { + credential: string; + expiresAt: string; +} + +function worldIdPath(): string { + return path.join(getConfigDirectoryPath(), 'world-id.json'); +} + +export function loadWorldIdCredential(): StoredWorldIdCredential | null { + try { + const parsed = JSON.parse(fs.readFileSync(worldIdPath(), 'utf-8')); + if ( + typeof parsed?.credential !== 'string' || + typeof parsed?.expiresAt !== 'string' + ) { + return null; + } + return parsed; + } catch { + return null; + } +} + +export function saveWorldIdCredential(stored: StoredWorldIdCredential): void { + fs.mkdirSync(getConfigDirectoryPath(), { recursive: true, mode: 0o700 }); + fs.writeFileSync(worldIdPath(), JSON.stringify(stored, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); +} + +export function clearWorldIdCredential(): boolean { + if (!fs.existsSync(worldIdPath())) return false; + fs.unlinkSync(worldIdPath()); + return true; +} + +/** + * The credential to send: FIRECRAWL_WORLD_ID wins, else the stored one while + * it has not expired. + */ +export function activeWorldIdCredential(now = Date.now()): string | undefined { + const fromEnv = process.env.FIRECRAWL_WORLD_ID?.trim(); + if (fromEnv) return fromEnv; + const stored = loadWorldIdCredential(); + if (!stored) return undefined; + const expiresAt = Date.parse(stored.expiresAt); + return Number.isFinite(expiresAt) && expiresAt > now + ? stored.credential + : undefined; +} + +export function worldIdHeaders(): Record { + const credential = activeWorldIdCredential(); + return credential ? { [WORLD_ID_HEADER]: credential } : {}; +} From fe09c92ccee94d4c4a1028b30ef07aca3f909e6e Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:11:28 +0000 Subject: [PATCH 2/2] fix(world-id): open links without a shell, retry transport errors, validate expiry --- src/__tests__/commands/parse.test.ts | 19 ++++++++++++ src/__tests__/commands/world-id.test.ts | 40 +++++++++++++++++++++++++ src/__tests__/utils/auth.test.ts | 9 ++++++ src/commands/interact.ts | 5 ++-- src/commands/parse.ts | 5 ++-- src/commands/world-id.ts | 16 ++++++++-- src/utils/auth.ts | 25 +++++++--------- src/utils/client.ts | 11 ++++--- src/utils/world-id.ts | 1 + 9 files changed, 104 insertions(+), 27 deletions(-) diff --git a/src/__tests__/commands/parse.test.ts b/src/__tests__/commands/parse.test.ts index 119a51124c..1aad5803bb 100644 --- a/src/__tests__/commands/parse.test.ts +++ b/src/__tests__/commands/parse.test.ts @@ -72,6 +72,25 @@ describe('executeParse', () => { expect(init.body.get('file')).toBeInstanceOf(Blob); }); + it('sends a World ID credential on keyless parse requests', async () => { + initializeConfig({ apiUrl: 'https://api.firecrawl.dev' }); + process.env.FIRECRAWL_WORLD_ID = 'fcwid_payload.signature'; + try { + await executeParse({ file: filePath }); + } finally { + delete process.env.FIRECRAWL_WORLD_ID; + } + + const [, init] = mockFetch.mock.calls[0] as [ + string, + { headers?: Record }, + ]; + expect(init.headers).toEqual({ + 'X-Origin': 'cli', + 'x-firecrawl-world-id': 'fcwid_payload.signature', + }); + }); + it('includes the bearer token when an API key is configured', async () => { initializeConfig({ apiKey: 'fc-test-key', diff --git a/src/__tests__/commands/world-id.test.ts b/src/__tests__/commands/world-id.test.ts index 191274ed27..848d2f0436 100644 --- a/src/__tests__/commands/world-id.test.ts +++ b/src/__tests__/commands/world-id.test.ts @@ -95,6 +95,43 @@ describe('firecrawl world-id', () => { expect(headers.Authorization).toBeUndefined(); }); + it('keeps polling through a transport error', async () => { + const responses: Array<[number, unknown] | Error> = [ + [200, START], + new TypeError('fetch failed'), + [ + 200, + { + success: true, + credential: CREDENTIAL, + expires_at: '2099-01-01T00:00:00.000Z', + }, + ], + ]; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + const next = responses.shift()!; + if (next instanceof Error) throw next; + return new Response(JSON.stringify(next[1]), { status: next[0] }); + }) + ); + + await expect(verifyWorldId(quiet)).resolves.toMatchObject({ + credential: CREDENTIAL, + }); + }); + + it('refuses a credential without a valid expiry', async () => { + stubResponses( + [200, START], + [200, { success: true, credential: CREDENTIAL }] + ); + + await expect(verifyWorldId(quiet)).rejects.toThrow('no valid expiry'); + expect(loadWorldIdCredential()).toBeNull(); + }); + it('says so when the API has World ID turned off', async () => { stubResponses([404, { success: false, error: 'Not found' }]); @@ -190,6 +227,9 @@ describe('firecrawl world-id', () => { }); it('stores the credential readable by the owner only', () => { + fs.writeFileSync(path.join(configDir, 'world-id.json'), '{}', { + mode: 0o644, + }); saveWorldIdCredential({ credential: CREDENTIAL, expiresAt: '2099-01-01T00:00:00.000Z', diff --git a/src/__tests__/utils/auth.test.ts b/src/__tests__/utils/auth.test.ts index c3326d1531..54990aaee6 100644 --- a/src/__tests__/utils/auth.test.ts +++ b/src/__tests__/utils/auth.test.ts @@ -9,6 +9,7 @@ import { interactiveLogin, isAuthenticated, manualLogin, + openBrowser, } from '../../utils/auth'; import { initializeConfig, resetConfig } from '../../utils/config'; import * as credentials from '../../utils/credentials'; @@ -225,3 +226,11 @@ describe('Authentication Utilities', () => { }); }); }); + +describe('openBrowser', () => { + it('refuses anything but a web URL before launching a process', async () => { + await expect(openBrowser('file:///etc/passwd')).rejects.toThrow( + 'Refusing to open a non-web URL' + ); + }); +}); diff --git a/src/commands/interact.ts b/src/commands/interact.ts index 3b220efba5..1b220e79b3 100644 --- a/src/commands/interact.ts +++ b/src/commands/interact.ts @@ -3,9 +3,8 @@ * Execute AI prompts or code against a scraped page in a live browser session */ -import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; +import { getClient, isKeylessMode, keylessHeaders } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; -import { worldIdHeaders } from '../utils/world-id'; import { getScrapeId, loadInteractSession, @@ -61,7 +60,7 @@ function buildHeaders(apiKey: string | undefined, keyless: boolean) { if (!keyless && apiKey) { headers.Authorization = `Bearer ${apiKey}`; } - if (keyless) Object.assign(headers, KEYLESS_CLI_HEADERS, worldIdHeaders()); + if (keyless) Object.assign(headers, keylessHeaders()); return { ...headers, ...integrationHeaders() }; } diff --git a/src/commands/parse.ts b/src/commands/parse.ts index 1212994617..f6505a47c1 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -11,9 +11,8 @@ import * as path from 'path'; import type { FormatOption } from 'firecrawl'; import type { ParseOptions, ParseResult } from '../types/parse'; import type { ScrapeFormat } from '../types/scrape'; -import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; +import { getClient, isKeylessMode, keylessHeaders } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; -import { worldIdHeaders } from '../utils/world-id'; import { handleScrapeOutput } from '../utils/output'; import { integrationHeaders } from '../utils/integration'; @@ -190,7 +189,7 @@ export async function executeParse( // CLI origin to the keyless check. headers: { ...(!keyless && apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), - ...(keyless ? { ...KEYLESS_CLI_HEADERS, ...worldIdHeaders() } : {}), + ...(keyless ? keylessHeaders() : {}), ...integrationHeaders(), }, body: form, diff --git a/src/commands/world-id.ts b/src/commands/world-id.ts index f36bbe465d..1fd34220b4 100644 --- a/src/commands/world-id.ts +++ b/src/commands/world-id.ts @@ -19,6 +19,7 @@ import { } from '../utils/world-id'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; +const REQUEST_TIMEOUT_MS = 15_000; export const WORLD_ID_UNAVAILABLE_MESSAGE = 'World ID verification is not enabled on this Firecrawl API.'; @@ -51,6 +52,7 @@ async function post( method: 'POST', headers: { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS }, body: JSON.stringify(body), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const json: any = await response.json().catch(() => ({})); const retryAfter = Number(response.headers.get('retry-after')); @@ -116,10 +118,18 @@ export async function verifyWorldId( while (now() < deadline) { await sleep(intervalSeconds * 1000); - const poll = await post('/v2/keyless/world-id/token', { - device_handle: device.device_handle, - }); + let poll: Awaited>; + try { + poll = await post('/v2/keyless/world-id/token', { + device_handle: device.device_handle, + }); + } catch { + continue; + } if (poll.status === 200 && typeof poll.json?.credential === 'string') { + if (!Number.isFinite(Date.parse(poll.json.expires_at))) { + throw new Error('World ID verification returned no valid expiry time.'); + } const stored = { credential: poll.json.credential, expiresAt: poll.json.expires_at, diff --git a/src/utils/auth.ts b/src/utils/auth.ts index b75df7b77e..c9c3b9d76f 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -31,23 +31,20 @@ const NO_LOGIN_INPUT_MESSAGE = * Open URL in the default browser */ export async function openBrowser(url: string): Promise { - const { exec } = await import('child_process'); - const platform = process.platform; - - let command: string; - switch (platform) { - case 'darwin': - command = `open "${url}"`; - break; - case 'win32': - command = `start "" "${url}"`; - break; - default: - command = `xdg-open "${url}"`; + const { protocol } = new URL(url); + if (protocol !== 'https:' && protocol !== 'http:') { + throw new Error(`Refusing to open a non-web URL: ${url}`); } + const { execFile } = await import('child_process'); + const [command, args] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['rundll32', ['url.dll,FileProtocolHandler', url]] + : ['xdg-open', [url]]; return new Promise((resolve, reject) => { - exec(command, (error: Error | null) => { + execFile(command, args, (error: Error | null) => { if (error) { reject(error); } else { diff --git a/src/utils/client.ts b/src/utils/client.ts index 309481cac5..e818149b83 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -44,6 +44,11 @@ export const KEYLESS_CLI_HEADERS: Readonly> = { 'X-Origin': 'cli', }; +/** Headers every keyless request sends: the CLI origin and any World ID credential. */ +export function keylessHeaders(): Record { + return { ...KEYLESS_CLI_HEADERS, ...worldIdHeaders() }; +} + export async function keylessRequest( path: string, body: Record @@ -53,9 +58,8 @@ export async function keylessRequest( method: 'POST', headers: { 'Content-Type': 'application/json', - ...KEYLESS_CLI_HEADERS, + ...keylessHeaders(), ...integrationHeaders(), - ...worldIdHeaders(), }, body: JSON.stringify(body), }); @@ -74,9 +78,8 @@ export async function keylessGet(path: string): Promise { method: 'GET', headers: { 'Content-Type': 'application/json', - ...KEYLESS_CLI_HEADERS, + ...keylessHeaders(), ...integrationHeaders(), - ...worldIdHeaders(), }, }); const json: any = await response.json().catch(() => ({})); diff --git a/src/utils/world-id.ts b/src/utils/world-id.ts index fbe6373a88..8405a649e1 100644 --- a/src/utils/world-id.ts +++ b/src/utils/world-id.ts @@ -43,6 +43,7 @@ export function saveWorldIdCredential(stored: StoredWorldIdCredential): void { encoding: 'utf-8', mode: 0o600, }); + fs.chmodSync(worldIdPath(), 0o600); } export function clearWorldIdCredential(): boolean {