Skip to content

Commit 93c2bf7

Browse files
authored
fix(api): enforce forced threat protection on v0 endpoints and team config (firecrawl#3965)
1 parent f05d93f commit 93c2bf7

7 files changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import request from "supertest";
2+
import {
3+
describeIf,
4+
idmux,
5+
Identity,
6+
TEST_API_URL,
7+
TEST_PRODUCTION,
8+
} from "../lib";
9+
import { scrapeRaw } from "./lib";
10+
11+
// =========================================
12+
// v0 + forced threat protection
13+
//
14+
// The deprecated v0 endpoints never resolve a threat protection policy, so
15+
// teams whose flag is "forced" must be rejected on the content-fetching v0
16+
// endpoints (scrape, crawl, search) with 403. The rejection happens right
17+
// after auth — before any credit check or scraping — so no provider or
18+
// scrape target is needed. crawl-status/crawl-cancel intentionally stay
19+
// open so existing jobs can drain.
20+
// =========================================
21+
22+
async function crawlRaw(body: unknown, identity: Identity) {
23+
return await request(TEST_API_URL)
24+
.post("/v0/crawl")
25+
.set("Authorization", `Bearer ${identity.apiKey}`)
26+
.set("Content-Type", "application/json")
27+
.send(body as object);
28+
}
29+
30+
async function searchRaw(body: unknown, identity: Identity) {
31+
return await request(TEST_API_URL)
32+
.post("/v0/search")
33+
.set("Authorization", `Bearer ${identity.apiKey}`)
34+
.set("Content-Type", "application/json")
35+
.send(body as object);
36+
}
37+
38+
// Requires idmux-provisioned team flags, which only exist in the production
39+
// test configuration.
40+
describeIf(TEST_PRODUCTION)("V0 threat protection (forced flag)", () => {
41+
let identity: Identity;
42+
43+
beforeAll(async () => {
44+
identity = await idmux({
45+
name: "v0-threat-protection/forced",
46+
flags: {
47+
threatProtection: "forced",
48+
},
49+
});
50+
}, 10000);
51+
52+
it.concurrent("rejects v0 scrape with 403", async () => {
53+
const res = await scrapeRaw({ url: "https://firecrawl.dev" }, identity);
54+
expect(res.statusCode).toBe(403);
55+
expect(res.body.error).toContain("Threat protection");
56+
expect(res.body.error).toContain("v0");
57+
});
58+
59+
it.concurrent("rejects v0 crawl with 403", async () => {
60+
const res = await crawlRaw({ url: "https://firecrawl.dev" }, identity);
61+
expect(res.statusCode).toBe(403);
62+
expect(res.body.error).toContain("Threat protection");
63+
expect(res.body.error).toContain("v0");
64+
});
65+
66+
it.concurrent("rejects v0 search with 403", async () => {
67+
const res = await searchRaw({ query: "firecrawl" }, identity);
68+
expect(res.statusCode).toBe(403);
69+
expect(res.body.error).toContain("Threat protection");
70+
expect(res.body.error).toContain("v0");
71+
});
72+
});

apps/api/src/__tests__/snips/v2/team-threat-protection.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,35 @@ describeIf(TEST_PRODUCTION)("Team threat protection config API", () => {
164164
}
165165
});
166166
});
167+
168+
describe("with the forced team flag", () => {
169+
let identity: Identity;
170+
171+
beforeAll(async () => {
172+
identity = await idmux({
173+
name: "team-threat-protection/forced",
174+
flags: {
175+
threatProtection: "forced",
176+
},
177+
});
178+
});
179+
180+
it('PUT rejects mode "off" with 403', async () => {
181+
const res = await putConfigRaw({ mode: "off" }, identity);
182+
expect(res.statusCode).toBe(403);
183+
expect(res.body.success).toBe(false);
184+
expect(res.body.error).toContain("enforced");
185+
expect(res.body.error).toContain('"off"');
186+
});
187+
188+
it('PUT accepts mode "normal" with 200 (forced teams may tighten config)', async () => {
189+
const res = await putConfigRaw({ mode: "normal" }, identity);
190+
expect(res.statusCode).toBe(200);
191+
expect(res.body.success).toBe(true);
192+
expect(res.body.data).toMatchObject({
193+
mode: "normal",
194+
configured: true,
195+
});
196+
});
197+
});
167198
});

apps/api/src/controllers/v0/crawl.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import {
3939
} from "../../services/worker/nuq-router";
4040
import { logRequest } from "../../services/logging/log_job";
4141
import { getScrapeZDR } from "../../lib/zdr-helpers";
42+
import {
43+
isThreatProtectionForced,
44+
THREAT_PROTECTION_V0_UNSUPPORTED_MESSAGE,
45+
} from "../../lib/threat-protection/request";
4246
import { applyAgentAuthDiscoveryHeader } from "../../lib/agent-auth-discovery";
4347

4448
export async function crawlController(req: Request, res: Response) {
@@ -58,6 +62,12 @@ export async function crawlController(req: Request, res: Response) {
5862
});
5963
}
6064

65+
if (isThreatProtectionForced(chunk?.flags)) {
66+
return res.status(403).json({
67+
error: THREAT_PROTECTION_V0_UNSUPPORTED_MESSAGE,
68+
});
69+
}
70+
6171
const id = uuidv7();
6272

6373
await logRequest({

apps/api/src/controllers/v0/scrape.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import { scrapeQueue } from "../../services/worker/nuq-router";
2626
import { getErrorContactMessage } from "../../lib/deployment";
2727
import { logRequest } from "../../services/logging/log_job";
2828
import { getScrapeZDR } from "../../lib/zdr-helpers";
29+
import {
30+
isThreatProtectionForced,
31+
THREAT_PROTECTION_V0_UNSUPPORTED_MESSAGE,
32+
} from "../../lib/threat-protection/request";
2933
import { applyAgentAuthDiscoveryHeader } from "../../lib/agent-auth-discovery";
3034

3135
async function scrapeHelper(
@@ -200,6 +204,12 @@ export async function scrapeController(req: Request, res: Response) {
200204
});
201205
}
202206

207+
if (isThreatProtectionForced(chunk?.flags)) {
208+
return res.status(403).json({
209+
error: THREAT_PROTECTION_V0_UNSUPPORTED_MESSAGE,
210+
});
211+
}
212+
203213
const jobId = uuidv7();
204214

205215
await logRequest({

apps/api/src/controllers/v0/search.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import { ScrapeJobTimeoutError } from "../../lib/error";
2323
import { scrapeQueue } from "../../services/worker/nuq-router";
2424
import { defaultOrigin } from "../../lib/default-values";
2525
import { getSearchZDR } from "../../lib/zdr-helpers";
26+
import {
27+
isThreatProtectionForced,
28+
THREAT_PROTECTION_V0_UNSUPPORTED_MESSAGE,
29+
} from "../../lib/threat-protection/request";
2630
import { applyAgentAuthDiscoveryHeader } from "../../lib/agent-auth-discovery";
2731

2832
async function searchHelper(
@@ -192,6 +196,12 @@ export async function searchController(req: Request, res: Response) {
192196
});
193197
}
194198

199+
if (isThreatProtectionForced(chunk?.flags)) {
200+
return res.status(403).json({
201+
error: THREAT_PROTECTION_V0_UNSUPPORTED_MESSAGE,
202+
});
203+
}
204+
195205
const jobId = uuidv7();
196206

197207
await logRequest({

apps/api/src/controllers/v2/team-threat-protection.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { logger as _logger } from "../../lib/logger";
44
import { RequestWithAuth } from "./types";
55
import { getThreatProtection } from "../../lib/zdr-helpers";
66
import { threatProtectionConfigSchema } from "../../lib/threat-protection/config";
7+
import { THREAT_PROTECTION_CANNOT_TURN_OFF_MESSAGE } from "../../lib/threat-protection/request";
78
import {
89
getOrgIdForTeam,
910
getOrgThreatProtectionConfig,
@@ -103,6 +104,19 @@ export async function putTeamThreatProtectionController(
103104

104105
const input = threatProtectionConfigSchema.parse(req.body);
105106

107+
// "forced" guarantees enforcement: the org config may be tightened, but its
108+
// mode may never be turned off through the API.
109+
if (
110+
getThreatProtection(req.acuc?.flags) === "forced" &&
111+
input.mode === "off"
112+
) {
113+
res.status(403).json({
114+
success: false,
115+
error: THREAT_PROTECTION_CANNOT_TURN_OFF_MESSAGE,
116+
});
117+
return;
118+
}
119+
106120
const orgId = await resolveOrgId(req, res);
107121
if (!orgId) return;
108122

apps/api/src/lib/threat-protection/request.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,25 @@ export const THREAT_PROTECTION_OVERRIDES_DISABLED_MESSAGE =
2525
export const THREAT_PROTECTION_CANNOT_DISABLE_MESSAGE =
2626
'Threat protection is enforced for your team and cannot be disabled per-request (threatProtection.mode may not be "off"). Remove the mode field or contact your organization administrator.';
2727

28+
export const THREAT_PROTECTION_CANNOT_TURN_OFF_MESSAGE =
29+
'Threat protection is enforced for your team and its mode cannot be set to "off". Contact your organization administrator.';
30+
31+
export const THREAT_PROTECTION_V0_UNSUPPORTED_MESSAGE =
32+
"Threat protection is enforced for your team and is not supported on the deprecated v0 API. Please update your code to use the v1 or v2 API.";
33+
34+
/**
35+
* The deprecated v0 endpoints never resolve a threat protection policy, so
36+
* teams whose flag is "forced" must not be able to fetch content through
37+
* them. Content-fetching v0 controllers (scrape, crawl, search) call this
38+
* right after auth and reject with 403 when it returns true; crawl-status and
39+
* crawl-cancel intentionally do not, so existing jobs can drain.
40+
*/
41+
export function isThreatProtectionForced(
42+
flags: TeamFlags | undefined,
43+
): boolean {
44+
return getThreatProtection(flags) === "forced";
45+
}
46+
2847
interface ResolvedThreatProtection {
2948
/** Set when the request must be rejected with a 403. */
3049
error?: string;

0 commit comments

Comments
 (0)