Skip to content

Commit a590a25

Browse files
committed
fix(url-reader): address browser solver review
Coverage: 94.37% (was 94.21%)
1 parent 8e6b329 commit a590a25

9 files changed

Lines changed: 437 additions & 263 deletions

SECURITY.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ Transient solver failures use the direct URL-reader path once. Cancellation
9999
does not trigger fallback and is propagated through acquisition, replay, body
100100
streaming, and PDF extraction. Invalid solver
101101
configuration and hostname-divergent solutions fail closed. The solver API
102-
response is capped at 256 KiB for FlareSolverr and 5 MiB for Byparr, whose
103-
current API always returns rendered content alongside cookies. Concurrent
102+
response is capped at 256 KiB for FlareSolverr and 5 MiB for Byparr. During
103+
2.1.0 verification, Byparr returned rendered content alongside cookies.
104+
Concurrent
104105
acquisitions are bounded independently by the selected provider's
105106
`*_MAX_CONCURRENT_REQUESTS` variable.
106107

__tests__/e2e/browser-solver.e2e.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
/**
44
* E2E Tests: browser-solver-backed web_url_read through the built MCP STDIO
55
* transport. The deterministic local test always runs. The real protected-PDF
6-
* test runs when FLARESOLVERR_URL points at an available service.
6+
* test runs when either provider endpoint points at an available service, or
7+
* both verification endpoints are supplied for the fail-closed matrix mode.
78
*/
89

910
import { strict as assert } from 'node:assert';

__tests__/run-all.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { runTests as runCacheTests } from './unit/cache.test.js';
1818
import { runTests as runSearchCacheTests } from './unit/search-cache.test.js';
1919
import { runTests as runProxyTests } from './unit/proxy.test.js';
2020
import { runTests as runBrowserSolverTests } from './unit/browser-solver.test.js';
21+
import { runTests as runBrowserSolverStateTests } from './unit/browser-solver-state.test.js';
2122
import { runTests as runPdfReaderTests } from './unit/pdf-reader.test.js';
2223
import { runTests as runPdfNetworkGuardTests } from './unit/pdf-network-guard.test.js';
2324
import { runTests as runErrorHandlerTests } from './unit/error-handler.test.js';
@@ -58,6 +59,7 @@ const testSuites: TestSuite[] = [
5859
{ name: 'Search Cache', category: 'unit', run: runSearchCacheTests },
5960
{ name: 'Proxy', category: 'unit', run: runProxyTests },
6061
{ name: 'Browser Solver', category: 'unit', run: runBrowserSolverTests },
62+
{ name: 'Browser Solver State', category: 'unit', run: runBrowserSolverStateTests },
6163
{ name: 'PDF Reader', category: 'unit', run: runPdfReaderTests },
6264
{ name: 'PDF Network Guard', category: 'unit', run: runPdfNetworkGuardTests },
6365
{ name: 'Error Handler', category: 'unit', run: runErrorHandlerTests },
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env tsx
2+
3+
import { strict as assert } from "node:assert";
4+
import * as http from "node:http";
5+
import * as net from "node:net";
6+
import { fileURLToPath } from "node:url";
7+
import {
8+
acquireBrowserSolverSolution,
9+
buildBrowserSolverHeaders,
10+
createBrowserSolverCacheKey,
11+
resolveBrowserSolverConfig,
12+
type BrowserSolverAcquisition,
13+
type BrowserSolverConfig,
14+
} from "../../src/browser-solver.js";
15+
import { createMockServer, createMockServerWithTracking } from "../helpers/mock-server.js";
16+
import { createTestResults, printTestSummary, testFunction } from "../helpers/test-utils.js";
17+
18+
const results = createTestResults();
19+
20+
interface TestServer {
21+
url: string;
22+
close: () => Promise<void>;
23+
}
24+
25+
function startServer(
26+
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
27+
): Promise<TestServer> {
28+
return new Promise((resolve, reject) => {
29+
const server = http.createServer(handler);
30+
server.listen(0, "127.0.0.1", () => {
31+
const address = server.address() as net.AddressInfo;
32+
resolve({
33+
url: `http://127.0.0.1:${address.port}`,
34+
close: () => new Promise<void>((done) => {
35+
server.closeAllConnections();
36+
server.close(() => done());
37+
}),
38+
});
39+
});
40+
server.once("error", reject);
41+
});
42+
}
43+
44+
function jsonSolution(url: string, extra: Record<string, unknown> = {}) {
45+
return {
46+
status: "ok",
47+
solution: {
48+
url,
49+
status: 200,
50+
cookies: [],
51+
userAgent: "SolverUA/1.0",
52+
...extra,
53+
},
54+
};
55+
}
56+
57+
async function waitForPending(
58+
pending: http.ServerResponse[],
59+
count: number,
60+
): Promise<void> {
61+
while (pending.length < count) {
62+
await new Promise((resolve) => setImmediate(resolve));
63+
}
64+
}
65+
66+
interface SwitchedProviderState {
67+
flarePending: Promise<BrowserSolverAcquisition>;
68+
byparrConfig: BrowserSolverConfig;
69+
}
70+
71+
async function saturateFlareThenSwitch(
72+
target: URL,
73+
pending: http.ServerResponse[],
74+
hanging: TestServer,
75+
responding: TestServer,
76+
): Promise<SwitchedProviderState> {
77+
process.env.FLARESOLVERR_URL = hanging.url;
78+
delete process.env.BYPARR_URL;
79+
process.env.FLARESOLVERR_MAX_CONCURRENT_REQUESTS = "1";
80+
const flareConfig = resolveBrowserSolverConfig(createMockServer() as any)!;
81+
const flarePending = acquireBrowserSolverSolution(
82+
createMockServer() as any,
83+
flareConfig,
84+
target,
85+
);
86+
await waitForPending(pending, 1);
87+
assert.deepEqual(
88+
await acquireBrowserSolverSolution(createMockServer() as any, flareConfig, target),
89+
{ kind: "fallback", reason: "busy" },
90+
);
91+
92+
delete process.env.FLARESOLVERR_URL;
93+
process.env.BYPARR_URL = responding.url;
94+
process.env.BYPARR_MAX_CONCURRENT_REQUESTS = "1";
95+
const byparrConfig = resolveBrowserSolverConfig(createMockServer() as any)!;
96+
assert.equal(
97+
(await acquireBrowserSolverSolution(
98+
createMockServer() as any,
99+
byparrConfig,
100+
target,
101+
)).kind,
102+
"solved",
103+
);
104+
return { flarePending, byparrConfig };
105+
}
106+
107+
async function assertByparrSaturation(
108+
target: URL,
109+
pending: http.ServerResponse[],
110+
hanging: TestServer,
111+
byparrConfig: BrowserSolverConfig,
112+
): Promise<void> {
113+
const byparrHanging = { ...byparrConfig, endpoint: new URL(`${hanging.url}/v1`) };
114+
const byparrPending = acquireBrowserSolverSolution(
115+
createMockServer() as any,
116+
byparrHanging,
117+
target,
118+
);
119+
await waitForPending(pending, 2);
120+
assert.deepEqual(
121+
await acquireBrowserSolverSolution(
122+
createMockServer() as any,
123+
byparrHanging,
124+
target,
125+
),
126+
{ kind: "fallback", reason: "busy" },
127+
);
128+
pending[1].writeHead(200, { "content-type": "application/json" });
129+
pending[1].end(JSON.stringify(jsonSolution(target.href)));
130+
assert.equal((await byparrPending).kind, "solved");
131+
}
132+
133+
async function assertProviderCounterIsolation(): Promise<void> {
134+
const target = new URL("https://example.com/paper");
135+
const pending: http.ServerResponse[] = [];
136+
const hanging = await startServer((_req, res) => pending.push(res));
137+
const responding = await startServer((_req, res) => {
138+
res.writeHead(200, { "content-type": "application/json" });
139+
res.end(JSON.stringify(jsonSolution(target.href)));
140+
});
141+
try {
142+
const { flarePending, byparrConfig } = await saturateFlareThenSwitch(
143+
target,
144+
pending,
145+
hanging,
146+
responding,
147+
);
148+
pending[0].writeHead(200, { "content-type": "application/json" });
149+
pending[0].end(JSON.stringify(jsonSolution(target.href)));
150+
assert.equal((await flarePending).kind, "solved");
151+
await assertByparrSaturation(target, pending, hanging, byparrConfig);
152+
} finally {
153+
for (const response of pending) {
154+
if (!response.writableEnded) {
155+
response.end();
156+
}
157+
}
158+
await responding.close();
159+
await hanging.close();
160+
}
161+
}
162+
163+
async function runTests() {
164+
console.log("🧪 Testing: browser solver state and replay headers\n");
165+
166+
await testFunction(
167+
"provider counters stay independent across environment switches and saturation",
168+
assertProviderCounterIsolation,
169+
results,
170+
);
171+
172+
await testFunction("solution validation never logs clearance-cookie values", async () => {
173+
const cookieSecret = "clearance-sensitive-value";
174+
const target = new URL("https://example.com/paper");
175+
const solver = await startServer((_req, res) => {
176+
res.writeHead(200, { "content-type": "application/json" });
177+
res.end(JSON.stringify(jsonSolution("https://other.example/paper", {
178+
cookies: [{ name: "cf_clearance", value: cookieSecret }],
179+
})));
180+
});
181+
const { server, getLoggingCalls } = createMockServerWithTracking();
182+
try {
183+
await assert.rejects(
184+
acquireBrowserSolverSolution(
185+
server as any,
186+
{
187+
provider: "flaresolverr",
188+
endpoint: new URL(`${solver.url}/v1`),
189+
timeoutMs: 1000,
190+
wireTimeout: 1000,
191+
maxConcurrentRequests: 2,
192+
maxResponseBytes: 256 * 1024,
193+
},
194+
target,
195+
),
196+
/different or unsupported hostname/iu,
197+
);
198+
await new Promise((resolve) => setImmediate(resolve));
199+
assert.ok(!JSON.stringify(getLoggingCalls()).includes(cookieSecret));
200+
} finally {
201+
await solver.close();
202+
}
203+
}, results);
204+
205+
await testFunction("cookie scope, expiry, security, and path specificity are enforced", () => {
206+
const solution = {
207+
url: "https://sub.example.com/a/b",
208+
status: 200,
209+
userAgent: "SolverUA/1.0",
210+
cookies: [
211+
{ name: "domain", value: "2", domain: ".example.com", path: "/", secure: true, expires: 4102444800 },
212+
{ name: "deep", value: "1", domain: "sub.example.com", path: "/a", secure: true, expires: 4102444800 },
213+
{ name: "session", value: "3", path: "/", secure: false, expires: -1 },
214+
{ name: "expired", value: "x", domain: ".example.com", path: "/", expires: 1000 },
215+
{ name: "wrong-domain", value: "x", domain: ".other.example", path: "/" },
216+
{ name: "wrong-path", value: "x", domain: ".example.com", path: "/other" },
217+
{ name: "secure-http", value: "x", domain: ".example.com", path: "/", secure: true },
218+
{ name: "bad\nname", value: "x", domain: ".example.com", path: "/" },
219+
{ name: "bad-value", value: "x;y", domain: ".example.com", path: "/" },
220+
{ name: "oversized", value: "x".repeat(5000), domain: ".example.com", path: "/" },
221+
{ name: "", value: "x", domain: ".example.com", path: "/" },
222+
],
223+
};
224+
225+
assert.deepEqual(
226+
buildBrowserSolverHeaders(solution, new URL("https://sub.example.com/a/b"), 2000),
227+
{
228+
"User-Agent": "SolverUA/1.0",
229+
Cookie: "deep=1; domain=2; session=3; secure-http=x",
230+
},
231+
);
232+
assert.deepEqual(
233+
buildBrowserSolverHeaders(solution, new URL("http://sub.example.com/a/b"), 2000),
234+
{
235+
"User-Agent": "SolverUA/1.0",
236+
Cookie: "session=3",
237+
},
238+
);
239+
assert.deepEqual(
240+
buildBrowserSolverHeaders(
241+
{ ...solution, cookies: [] },
242+
new URL("https://sub.example.com/a/b"),
243+
2000,
244+
),
245+
{ "User-Agent": "SolverUA/1.0" },
246+
);
247+
assert.deepEqual(
248+
buildBrowserSolverHeaders(
249+
solution,
250+
new URL("https://other.example.com/a/b"),
251+
2000,
252+
),
253+
{ "User-Agent": "SolverUA/1.0" },
254+
);
255+
}, results);
256+
257+
await testFunction("solver cache key uses the original requested URL", () => {
258+
assert.equal(
259+
createBrowserSolverCacheKey("flaresolverr", "https://example.com/original"),
260+
"solver:flaresolverr:https://example.com/original",
261+
);
262+
}, results);
263+
264+
printTestSummary(results, "Browser Solver State");
265+
return results;
266+
}
267+
268+
if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) {
269+
runTests()
270+
.then((result) => {
271+
process.exitCode = result.failed > 0 ? 1 : 0;
272+
})
273+
.catch((error) => {
274+
console.error(error);
275+
process.exitCode = 1;
276+
});
277+
}
278+
279+
export { runTests };

0 commit comments

Comments
 (0)