Skip to content

Commit 142b3eb

Browse files
fix(lockdown): gate remaining outbound paths on lockdown mode (firecrawl#3414)
* fix(lockdown): gate remaining outbound paths on lockdown mode Lockdown promises no outbound requests to the target URL. Three paths still fired: the checkRobotsOnScrape team-flag pre-fetch of robots.txt, the fetchAudio transformer POSTing the URL to the avgrab service, and sendDocumentToSearchIndex forwarding the URL + content to the search service. Each now short-circuits when options.lockdown is true, with unit tests locking in the invariant. * test(lockdown): move unit tests under __tests__/, add audio-gate snip Aligns with the repo's dominant pattern (__tests__ folder per feature dir rather than colocated). Adds a snip that requests the audio format under lockdown on a cached URL — succeeds only if the audio gate fires, since firecrawl.dev would otherwise raise AudioUnsupportedUrlError.
1 parent cc6a286 commit 142b3eb

8 files changed

Lines changed: 233 additions & 1 deletion

File tree

apps/api/src/__tests__/snips/v2/scrape-lockdown.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,36 @@ describeIf(TEST_PRODUCTION)("V2 Scrape Lockdown Mode", () => {
6969
},
7070
scrapeTimeout,
7171
);
72+
73+
test(
74+
"should serve cache and skip audio fetch even when audio format is requested",
75+
async () => {
76+
const id = crypto.randomUUID();
77+
const url = "https://firecrawl.dev/?testAudioGate=" + id;
78+
79+
const seed = await scrape({ url }, identity);
80+
expect(seed).toBeDefined();
81+
expect(seed.metadata.cacheState).toBe("miss");
82+
83+
await new Promise(resolve => setTimeout(resolve, 20000));
84+
85+
// Without the lockdown audio gate this would either throw
86+
// AudioUnsupportedUrlError (firecrawl.dev is not an audio source) or
87+
// POST to AVGRAB_SERVICE_URL with the target URL. Success here implies
88+
// the gate short-circuited before any outbound call.
89+
const data = await scrape(
90+
{
91+
url,
92+
lockdown: true,
93+
formats: ["markdown", "audio"],
94+
},
95+
identity,
96+
);
97+
98+
expect(data).toBeDefined();
99+
expect(data.metadata.cacheState).toBe("hit");
100+
expect(data.audio).toBeUndefined();
101+
},
102+
scrapeTimeout * 2 + 20000,
103+
);
72104
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { shouldCheckRobots } from "../shouldCheckRobots";
2+
3+
describe("shouldCheckRobots", () => {
4+
it("returns false when lockdown is true, even if the team flag is set", () => {
5+
expect(
6+
shouldCheckRobots(
7+
{ lockdown: true },
8+
{ teamFlags: { checkRobotsOnScrape: true } as any },
9+
),
10+
).toBe(false);
11+
});
12+
13+
it("returns false when the team flag is off, regardless of lockdown", () => {
14+
expect(
15+
shouldCheckRobots(
16+
{ lockdown: false },
17+
{ teamFlags: { checkRobotsOnScrape: false } as any },
18+
),
19+
).toBe(false);
20+
expect(
21+
shouldCheckRobots({ lockdown: true }, { teamFlags: {} as any }),
22+
).toBe(false);
23+
});
24+
25+
it("returns false when teamFlags is missing", () => {
26+
expect(shouldCheckRobots({ lockdown: false }, {})).toBe(false);
27+
});
28+
29+
it("returns true only when the team flag is on and lockdown is off", () => {
30+
expect(
31+
shouldCheckRobots(
32+
{ lockdown: false },
33+
{ teamFlags: { checkRobotsOnScrape: true } as any },
34+
),
35+
).toBe(true);
36+
});
37+
});

apps/api/src/scraper/scrapeURL/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { ScrapeRetryTracker } from "./retryTracker";
5757
import { executeTransformers } from "./transformers";
5858
import { LLMRefusalError } from "./transformers/llmExtract";
5959
import { urlSpecificParams } from "./lib/urlSpecificParams";
60+
import { shouldCheckRobots } from "./shouldCheckRobots";
6061
import { loadMock, MockState } from "./lib/mock";
6162
import { CostTracking } from "../../lib/cost-tracking";
6263
import { getEngineForUrl } from "../WebScraper/utils/engine-forcing";
@@ -1052,7 +1053,7 @@ export async function scrapeURL(
10521053
});
10531054
}
10541055

1055-
if (internalOptions.teamFlags?.checkRobotsOnScrape) {
1056+
if (shouldCheckRobots(options, internalOptions)) {
10561057
await withSpan("scrape.robots_check", async robotsSpan => {
10571058
const urlToCheck = meta.rewrittenUrl || meta.url;
10581059
meta.logger.info("Checking robots.txt", { url: urlToCheck });
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { TeamFlags } from "../../controllers/v1/types";
2+
3+
// The invariant "lockdown never fetches robots.txt" is load-bearing for the
4+
// lockdown guarantee (robots.txt is a request to the target domain). Keep this
5+
// in its own file so it can be unit-tested without dragging in scrapeURL's
6+
// ESM-heavy module graph.
7+
export function shouldCheckRobots(
8+
options: { lockdown?: boolean },
9+
internalOptions: { teamFlags?: TeamFlags },
10+
): boolean {
11+
if (options.lockdown) {
12+
return false;
13+
}
14+
return !!internalOptions.teamFlags?.checkRobotsOnScrape;
15+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { fetchAudio } from "../audio";
2+
3+
describe("fetchAudio lockdown guard", () => {
4+
const originalFetch = global.fetch;
5+
6+
afterEach(() => {
7+
global.fetch = originalFetch;
8+
jest.clearAllMocks();
9+
});
10+
11+
it("does not issue any fetch when lockdown is true, even if audio format is requested", async () => {
12+
const fetchSpy = jest.fn();
13+
global.fetch = fetchSpy as any;
14+
15+
const meta: any = {
16+
url: "https://example.com/audio",
17+
options: {
18+
lockdown: true,
19+
formats: [{ type: "audio" }],
20+
},
21+
logger: { warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
22+
};
23+
const document: any = { markdown: "cached" };
24+
25+
const result = await fetchAudio(meta, document);
26+
27+
expect(fetchSpy).not.toHaveBeenCalled();
28+
expect(result).toBe(document);
29+
expect(result.audio).toBeUndefined();
30+
});
31+
32+
it("returns early when audio format is not requested regardless of lockdown", async () => {
33+
const fetchSpy = jest.fn();
34+
global.fetch = fetchSpy as any;
35+
36+
const meta: any = {
37+
url: "https://example.com/audio",
38+
options: {
39+
lockdown: false,
40+
formats: [{ type: "markdown" }],
41+
},
42+
logger: { warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
43+
};
44+
const document: any = { markdown: "cached" };
45+
46+
await fetchAudio(meta, document);
47+
48+
expect(fetchSpy).not.toHaveBeenCalled();
49+
});
50+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { sendDocumentToSearchIndex } from "../sendToSearchIndex";
2+
import { indexDocumentIfEnabled } from "../../../../lib/search-index-client";
3+
import { config } from "../../../../config";
4+
5+
jest.mock("../../../../lib/search-index-client", () => ({
6+
indexDocumentIfEnabled: jest.fn(),
7+
}));
8+
9+
const mockedIndex = indexDocumentIfEnabled as jest.MockedFunction<
10+
typeof indexDocumentIfEnabled
11+
>;
12+
13+
describe("sendDocumentToSearchIndex lockdown guard", () => {
14+
const originalEnabled = config.ENABLE_SEARCH_INDEX;
15+
const originalServiceUrl = config.SEARCH_SERVICE_URL;
16+
const originalSampleRate = config.SEARCH_INDEX_SAMPLE_RATE;
17+
18+
beforeEach(() => {
19+
// Force the transformer past the "enabled" and "sampling" early-returns so
20+
// only the lockdown / should-index checks can stop it.
21+
(config as any).ENABLE_SEARCH_INDEX = true;
22+
(config as any).SEARCH_SERVICE_URL = "https://search.internal";
23+
(config as any).SEARCH_INDEX_SAMPLE_RATE = 1;
24+
mockedIndex.mockClear();
25+
});
26+
27+
afterAll(() => {
28+
(config as any).ENABLE_SEARCH_INDEX = originalEnabled;
29+
(config as any).SEARCH_SERVICE_URL = originalServiceUrl;
30+
(config as any).SEARCH_INDEX_SAMPLE_RATE = originalSampleRate;
31+
});
32+
33+
const baseDocument: any = {
34+
markdown: "a".repeat(500),
35+
rawHtml: "<html><body>x</body></html>",
36+
metadata: {
37+
statusCode: 200,
38+
indexId: "idx-123",
39+
},
40+
};
41+
42+
const baseMeta = (overrides: Partial<Record<string, unknown>> = {}) =>
43+
({
44+
url: "https://example.com/page",
45+
options: {
46+
lockdown: false,
47+
headers: undefined,
48+
mobile: false,
49+
},
50+
internalOptions: {
51+
isParse: false,
52+
zeroDataRetention: false,
53+
},
54+
logger: {
55+
warn: jest.fn(),
56+
info: jest.fn(),
57+
debug: jest.fn(),
58+
error: jest.fn(),
59+
},
60+
...overrides,
61+
}) as any;
62+
63+
it("does not forward the URL to the search service when lockdown is true", async () => {
64+
const meta = baseMeta({
65+
options: { lockdown: true, headers: undefined, mobile: false },
66+
});
67+
68+
await sendDocumentToSearchIndex(meta, { ...baseDocument });
69+
70+
// Fire-and-forget promise: give it a tick to settle.
71+
await new Promise(r => setImmediate(r));
72+
73+
expect(mockedIndex).not.toHaveBeenCalled();
74+
});
75+
76+
it("still forwards normal (non-lockdown) documents to the search service", async () => {
77+
const meta = baseMeta();
78+
79+
await sendDocumentToSearchIndex(meta, { ...baseDocument });
80+
81+
await new Promise(r => setImmediate(r));
82+
83+
expect(mockedIndex).toHaveBeenCalledTimes(1);
84+
expect(mockedIndex.mock.calls[0][0].url).toBe("https://example.com/page");
85+
});
86+
});

apps/api/src/scraper/scrapeURL/transformers/audio.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export async function fetchAudio(
4242
return document;
4343
}
4444

45+
// Lockdown forbids any outbound request touching the target URL. The avgrab
46+
// service fetches the source on our behalf, so we must skip it here.
47+
if (meta.options.lockdown) {
48+
return document;
49+
}
50+
4551
if (!config.AVGRAB_SERVICE_URL) {
4652
meta.logger.warn("AVGRAB_SERVICE_URL is not configured");
4753
document.warning =

apps/api/src/scraper/scrapeURL/transformers/sendToSearchIndex.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ function shouldIndexForSearch(meta: Meta, document: Document): boolean {
2727
return false;
2828
}
2929

30+
// Lockdown must not forward the target URL to any external service.
31+
if (meta.options.lockdown) {
32+
return false;
33+
}
34+
3035
const statusCode = document.metadata.statusCode;
3136
if (statusCode < 200 || statusCode >= 300) {
3237
return false;

0 commit comments

Comments
 (0)