Skip to content

Commit 75060fa

Browse files
mogeryclaude
andauthored
feat(api): per-key scope/format lockdown (keyRestriction flag) (firecrawl#3960)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8e0d1a2 commit 75060fa

17 files changed

Lines changed: 1102 additions & 9 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { eq } from "drizzle-orm";
2+
import { idmux, map, scrape, scrapeRaw, scrapeTimeout } from "./lib";
3+
import { createTestIdUrl, describeIf, Identity, TEST_PRODUCTION } from "../lib";
4+
import { db } from "../../../db/connection";
5+
import * as schema from "../../../db/schema";
6+
import { parseApi } from "../../../lib/parseApi";
7+
8+
// Needs idmux (to create teams with the keyRestriction flag) and direct DB
9+
// access (to seed key_restriction_config), so production-suite only.
10+
describeIf(TEST_PRODUCTION)(
11+
"Key restriction (keyRestriction team flag)",
12+
() => {
13+
const seededKeyIds: number[] = [];
14+
15+
async function seedRestriction(
16+
identity: Identity,
17+
restriction: { allowedFormats?: string[]; allowedEndpoints?: string[] },
18+
) {
19+
const [keyRow] = await db
20+
.select({ id: schema.api_keys.id })
21+
.from(schema.api_keys)
22+
.where(eq(schema.api_keys.key, parseApi(identity.apiKey)))
23+
.limit(1);
24+
expect(keyRow).toBeDefined();
25+
26+
await db.insert(schema.key_restriction_config).values({
27+
api_key_id: keyRow!.id,
28+
team_id: identity.teamId,
29+
allowed_formats: restriction.allowedFormats ?? [],
30+
allowed_endpoints: restriction.allowedEndpoints ?? [],
31+
});
32+
seededKeyIds.push(keyRow!.id);
33+
}
34+
35+
afterAll(async () => {
36+
for (const keyId of seededKeyIds) {
37+
await db
38+
.delete(schema.key_restriction_config)
39+
.where(eq(schema.key_restriction_config.api_key_id, keyId));
40+
}
41+
});
42+
43+
it.concurrent(
44+
"allows markdown scrapes on a markdown-only key",
45+
async () => {
46+
const identity = await idmux({
47+
name: "key-restriction/markdown-allowed",
48+
credits: 10000,
49+
flags: { keyRestriction: true },
50+
});
51+
await seedRestriction(identity, { allowedFormats: ["markdown"] });
52+
53+
// Explicit markdown and the implicit default must both pass.
54+
const doc = await scrape(
55+
{ url: createTestIdUrl(), formats: ["markdown"] },
56+
identity,
57+
);
58+
expect(doc.markdown).toBeDefined();
59+
60+
const docDefault = await scrape({ url: createTestIdUrl() }, identity);
61+
expect(docDefault.markdown).toBeDefined();
62+
},
63+
scrapeTimeout * 2,
64+
);
65+
66+
it.concurrent(
67+
"rejects non-allowed formats on a markdown-only key",
68+
async () => {
69+
const identity = await idmux({
70+
name: "key-restriction/format-blocked",
71+
credits: 10000,
72+
flags: { keyRestriction: true },
73+
});
74+
await seedRestriction(identity, { allowedFormats: ["markdown"] });
75+
76+
const response = await scrapeRaw(
77+
{ url: createTestIdUrl(), formats: ["markdown", "rawHtml"] },
78+
identity,
79+
);
80+
81+
expect(response.statusCode).toBe(403);
82+
expect(response.body.success).toBe(false);
83+
expect(response.body.error).toContain(
84+
"restricted to the following formats",
85+
);
86+
},
87+
scrapeTimeout,
88+
);
89+
90+
it.concurrent(
91+
"rejects content-returning actions on a format-restricted key",
92+
async () => {
93+
const identity = await idmux({
94+
name: "key-restriction/action-blocked",
95+
credits: 10000,
96+
flags: { keyRestriction: true },
97+
});
98+
await seedRestriction(identity, { allowedFormats: ["markdown"] });
99+
100+
const response = await scrapeRaw(
101+
{
102+
url: createTestIdUrl(),
103+
formats: ["markdown"],
104+
actions: [{ type: "screenshot" }],
105+
},
106+
identity,
107+
);
108+
109+
expect(response.statusCode).toBe(403);
110+
expect(response.body.success).toBe(false);
111+
expect(response.body.error).toContain("screenshot");
112+
},
113+
scrapeTimeout,
114+
);
115+
116+
it.concurrent(
117+
"enforces the endpoint allowlist",
118+
async () => {
119+
const identity = await idmux({
120+
name: "key-restriction/endpoint",
121+
credits: 10000,
122+
flags: { keyRestriction: true },
123+
});
124+
await seedRestriction(identity, { allowedEndpoints: ["scrape"] });
125+
126+
// Allowed endpoint group works...
127+
await scrape({ url: createTestIdUrl() }, identity);
128+
129+
// ...anything else is rejected at auth.
130+
const response = await map({ url: "https://firecrawl.dev" }, identity);
131+
expect(response.statusCode).toBe(403);
132+
expect(response.body.success).toBe(false);
133+
expect(response.body.error).toContain(
134+
"restricted to the following endpoints",
135+
);
136+
},
137+
scrapeTimeout * 2,
138+
);
139+
140+
it.concurrent(
141+
"does not restrict a flagged team before it configures a restriction",
142+
async () => {
143+
const identity = await idmux({
144+
name: "key-restriction/no-config",
145+
credits: 10000,
146+
flags: { keyRestriction: true },
147+
});
148+
149+
const doc = await scrape(
150+
{ url: createTestIdUrl(), formats: ["rawHtml"] },
151+
identity,
152+
);
153+
expect(doc.rawHtml).toBeDefined();
154+
},
155+
scrapeTimeout,
156+
);
157+
158+
it.concurrent(
159+
"does not restrict when the flag is off even if a config exists",
160+
async () => {
161+
const identity = await idmux({
162+
name: "key-restriction/no-flag",
163+
credits: 10000,
164+
});
165+
await seedRestriction(identity, { allowedFormats: ["markdown"] });
166+
167+
const doc = await scrape(
168+
{ url: createTestIdUrl(), formats: ["rawHtml"] },
169+
identity,
170+
);
171+
expect(doc.rawHtml).toBeDefined();
172+
},
173+
scrapeTimeout,
174+
);
175+
},
176+
);

apps/api/src/controllers/auth.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "../lib/keyless";
1818
import { isKeylessIpSuspicious } from "../lib/spur";
1919
import { checkIpRestriction } from "../lib/ip-restriction";
20+
import { checkKeyEndpointRestriction } from "../lib/key-restriction";
2021
import { deleteKey, getValue, setValue } from "../services/redis";
2122
import { redlock } from "../services/redlock";
2223
import { eq } from "drizzle-orm";
@@ -817,6 +818,23 @@ async function supaAuthenticateUser(
817818
}
818819
}
819820

821+
if (chunk?.flags?.keyRestriction) {
822+
// Enforced here rather than in route middleware so every authenticated
823+
// surface (v0/v1/v2, websocket status) goes through the same gate.
824+
const endpointCheck = await checkKeyEndpointRestriction(
825+
req.originalUrl ?? req.url ?? "",
826+
chunk.api_key_id,
827+
chunk.flags,
828+
);
829+
if (!endpointCheck.allowed) {
830+
return {
831+
success: false,
832+
error: endpointCheck.error,
833+
status: endpointCheck.status,
834+
};
835+
}
836+
}
837+
820838
const team_endpoint_token = token === config.PREVIEW_TOKEN ? iptoken : teamId;
821839

822840
try {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Request, Response } from "express";
2+
import { clearKeyRestrictionCache } from "../../../lib/key-restriction";
3+
import { logger } from "../../../lib/logger";
4+
5+
// Called by the dashboard after it writes key_restriction_config so
6+
// restriction edits take effect immediately instead of after the 60s
7+
// cache TTL.
8+
export async function keyRestrictionCacheClearController(
9+
req: Request,
10+
res: Response,
11+
) {
12+
try {
13+
const api_key_id = req.body?.api_key_id;
14+
15+
if (typeof api_key_id !== "number" || !Number.isInteger(api_key_id)) {
16+
return res.status(400).json({ error: "api_key_id is required" });
17+
}
18+
19+
await clearKeyRestrictionCache(api_key_id);
20+
21+
logger.info("Key restriction config cache cleared", { api_key_id });
22+
res.json({ ok: true });
23+
} catch (error) {
24+
logger.error("Error clearing key restriction cache via API route", {
25+
error,
26+
});
27+
res.status(500).json({ error: "Internal server error" });
28+
}
29+
}

apps/api/src/controllers/v1/batch-scrape.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ import { UNSUPPORTED_SITE_MESSAGE } from "../../lib/strings";
2626
import { isUrlBlocked } from "../../scraper/WebScraper/utils/blocklist";
2727
import { fromV1ScrapeOptions } from "../v2/types";
2828
import { checkPermissions } from "../../lib/permissions";
29+
import {
30+
actionTypesOf,
31+
checkKeyFormatRestriction,
32+
formatTypesOf,
33+
} from "../../lib/key-restriction";
2934
import {
3035
crawlGroup,
3136
resolveNewGroupBackend,
@@ -52,6 +57,19 @@ export async function batchScrapeController(
5257
});
5358
}
5459

60+
const keyRestriction = await checkKeyFormatRestriction(
61+
formatTypesOf(req.body.formats),
62+
actionTypesOf(req.body.actions),
63+
req.acuc?.api_key_id,
64+
req.acuc?.flags ?? null,
65+
);
66+
if (!keyRestriction.allowed) {
67+
return res.status(keyRestriction.status).json({
68+
success: false,
69+
error: keyRestriction.error,
70+
});
71+
}
72+
5573
const zeroDataRetention =
5674
getScrapeZDR(req.acuc?.flags) === "forced" || req.body.zeroDataRetention;
5775

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import { _addScrapeJobToBullMQ } from "../../services/queue-jobs";
1818
import { logger as _logger } from "../../lib/logger";
1919
import { fromV1ScrapeOptions } from "../v2/types";
2020
import { checkPermissions } from "../../lib/permissions";
21+
import {
22+
actionTypesOf,
23+
checkKeyFormatRestriction,
24+
formatTypesOf,
25+
} from "../../lib/key-restriction";
2126
import {
2227
crawlGroup,
2328
resolveNewGroupBackend,
@@ -43,6 +48,19 @@ export async function crawlController(
4348
});
4449
}
4550

51+
const keyRestriction = await checkKeyFormatRestriction(
52+
formatTypesOf(req.body.scrapeOptions?.formats),
53+
actionTypesOf(req.body.scrapeOptions?.actions),
54+
req.acuc?.api_key_id,
55+
req.acuc?.flags ?? null,
56+
);
57+
if (!keyRestriction.allowed) {
58+
return res.status(keyRestriction.status).json({
59+
success: false,
60+
error: keyRestriction.error,
61+
});
62+
}
63+
4664
const zeroDataRetention =
4765
getScrapeZDR(req.acuc?.flags) === "forced" || req.body.zeroDataRetention;
4866

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import { fromV1ScrapeOptions } from "../v2/types";
1414
import { TransportableError } from "../../lib/error";
1515
import { NuQJob } from "../../services/worker/nuq";
1616
import { checkPermissions } from "../../lib/permissions";
17+
import {
18+
actionTypesOf,
19+
checkKeyFormatRestriction,
20+
formatTypesOf,
21+
} from "../../lib/key-restriction";
1722
import { includesFormat } from "../../lib/format-utils";
1823
import { teamConcurrencySemaphore } from "../../services/worker/team-semaphore";
1924
import { processJobInternal } from "../../services/worker/scrape-worker";
@@ -53,6 +58,19 @@ export async function scrapeController(
5358
});
5459
}
5560

61+
const keyRestriction = await checkKeyFormatRestriction(
62+
formatTypesOf(req.body.formats),
63+
actionTypesOf(req.body.actions),
64+
req.acuc?.api_key_id,
65+
req.acuc?.flags ?? null,
66+
);
67+
if (!keyRestriction.allowed) {
68+
return res.status(keyRestriction.status).json({
69+
success: false,
70+
error: keyRestriction.error,
71+
});
72+
}
73+
5674
const zeroDataRetention =
5775
getScrapeZDR(req.acuc?.flags) === "forced" || req.body.zeroDataRetention;
5876

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import { v7 as uuidv7 } from "uuid";
1212
import { logSearch, logRequest } from "../../services/logging/log_job";
1313
import { search } from "../../search";
1414
import { logger as _logger } from "../../lib/logger";
15+
import {
16+
actionTypesOf,
17+
checkKeyFormatRestriction,
18+
formatTypesOf,
19+
} from "../../lib/key-restriction";
1520
import type { Logger } from "winston";
1621
import { ScrapeJobTimeoutError } from "../../lib/error";
1722
import { captureExceptionWithZdrCheck } from "../../services/sentry";
@@ -122,6 +127,24 @@ export async function searchController(
122127
try {
123128
req.body = searchRequestSchema.parse(req.body);
124129

130+
const requestedFormats = formatTypesOf(req.body.scrapeOptions?.formats);
131+
const keyRestriction = await checkKeyFormatRestriction(
132+
requestedFormats,
133+
// Search only scrapes (and only runs actions) when formats are
134+
// requested; without them scrapeOptions is ignored entirely.
135+
requestedFormats.length > 0
136+
? actionTypesOf(req.body.scrapeOptions?.actions)
137+
: [],
138+
req.acuc?.api_key_id,
139+
req.acuc?.flags ?? null,
140+
);
141+
if (!keyRestriction.allowed) {
142+
return res.status(keyRestriction.status).json({
143+
success: false,
144+
error: keyRestriction.error,
145+
});
146+
}
147+
125148
logger = logger.child({
126149
version: "v1",
127150
query: req.body.query,

apps/api/src/controllers/v1/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,8 @@ export type TeamFlags = {
13131313
ipWhitelist?: boolean;
13141314
// gates the per-team API key IP allowlist (ip_restriction_config table)
13151315
ipRestriction?: boolean;
1316+
// gates the per-key scope/format lockdown (key_restriction_config table)
1317+
keyRestriction?: boolean;
13161318
skipCountryCheck?: boolean;
13171319
browserBeta?: boolean;
13181320
bypassCreditChecks?: boolean;

0 commit comments

Comments
 (0)