trackly-cli 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/trackly +14 -5
- package/lib/client.js +26 -1
- package/mcp/server.js +20 -3
- package/package.json +1 -1
- package/server.json +2 -2
package/bin/trackly
CHANGED
|
@@ -512,11 +512,20 @@ async function cmdAsk(args) {
|
|
|
512
512
|
}
|
|
513
513
|
|
|
514
514
|
if (result.jobsUrl) {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
515
|
+
// Path allowlist: /ask can return any string for jobsUrl. normalizeEndpoint already
|
|
516
|
+
// blocks cross-origin fetches (baseUrl mismatch), but a compromised backend could
|
|
517
|
+
// still emit a same-origin path like `/api/admin/secret-dump`. Restrict to the two
|
|
518
|
+
// legitimate handlers that /ask is designed to route to.
|
|
519
|
+
const JOBS_URL_ALLOWLIST = /^\/api\/(v1|jobscout)\/jobs(\?|$)/;
|
|
520
|
+
if (!JOBS_URL_ALLOWLIST.test(result.jobsUrl)) {
|
|
521
|
+
console.log(color('dim', `Refusing to follow unexpected jobsUrl: ${result.jobsUrl}`));
|
|
522
|
+
} else {
|
|
523
|
+
console.log();
|
|
524
|
+
console.log(color('dim', `Fetching: ${result.jobsUrl}`));
|
|
525
|
+
const jobsResult = await apiRequest('GET', result.jobsUrl);
|
|
526
|
+
console.log();
|
|
527
|
+
outputJobs(jobsResult.jobs || jobsResult.data || []);
|
|
528
|
+
}
|
|
520
529
|
}
|
|
521
530
|
} catch (e) {
|
|
522
531
|
if (e.status === 429) setErrorExit('Daily limit reached (20 natural language queries per day)');
|
package/lib/client.js
CHANGED
|
@@ -262,9 +262,34 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
|
|
|
262
262
|
};
|
|
263
263
|
|
|
264
264
|
const req = lib.request(options, (res) => {
|
|
265
|
+
// Hard cap on response body size. A compromised / misconfigured backend (or a user-set
|
|
266
|
+
// TRACKLY_BASE_URL pointing at a malicious host) could stream unbounded data and OOM
|
|
267
|
+
// the long-lived MCP process. 10 MB is orders of magnitude larger than any real
|
|
268
|
+
// Trackly response; jobs-list maxes out around ~200 KB for 50 items.
|
|
269
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
265
270
|
let body = '';
|
|
266
|
-
|
|
271
|
+
let bytesReceived = 0;
|
|
272
|
+
let aborted = false;
|
|
273
|
+
// Handle socket-torn-down errors during streaming (happens when req.destroy()
|
|
274
|
+
// fires to abort an oversized body). Without this, the orphan 'error' event on
|
|
275
|
+
// the response stream can surface as an uncaught exception and crash the long-
|
|
276
|
+
// lived MCP process. The request-level `req.on('error', reject)` below catches
|
|
277
|
+
// the same underlying socket error for the request side; this covers the response
|
|
278
|
+
// side. (CodeRabbit PR #21.)
|
|
279
|
+
res.on('error', reject);
|
|
280
|
+
res.on('data', (chunk) => {
|
|
281
|
+
if (aborted) return;
|
|
282
|
+
bytesReceived += chunk.length;
|
|
283
|
+
if (bytesReceived > MAX_BODY_BYTES) {
|
|
284
|
+
aborted = true;
|
|
285
|
+
req.destroy(new Error(`Trackly response body exceeded ${MAX_BODY_BYTES} bytes`));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
body += chunk;
|
|
289
|
+
});
|
|
267
290
|
res.on('end', async () => {
|
|
291
|
+
if (aborted) return; // req.destroy already rejected via 'error' handler
|
|
292
|
+
|
|
268
293
|
let json;
|
|
269
294
|
try { json = body ? JSON.parse(body) : {}; } catch { json = { raw: body }; }
|
|
270
295
|
|
package/mcp/server.js
CHANGED
|
@@ -226,10 +226,27 @@ function createServer() {
|
|
|
226
226
|
wrapTool(async ({ query }) => {
|
|
227
227
|
const askResult = await apiRequest('GET', `/api/jobscout/ask?q=${encodeURIComponent(query)}`, null, false, false, MCP_USER_AGENT);
|
|
228
228
|
if (askResult.jobsUrl) {
|
|
229
|
-
|
|
229
|
+
// Path allowlist: /ask returns a jobsUrl string. normalizeEndpoint already blocks
|
|
230
|
+
// cross-origin fetches, but a compromised backend could emit a same-origin path
|
|
231
|
+
// like `/api/admin/secret-dump`. Only follow the two handlers /ask is designed to
|
|
232
|
+
// route to. Mirrors the same guard in bin/trackly:cmdAsk.
|
|
233
|
+
const JOBS_URL_ALLOWLIST = /^\/api\/(v1|jobscout)\/jobs(\?|$)/;
|
|
234
|
+
if (JOBS_URL_ALLOWLIST.test(askResult.jobsUrl)) {
|
|
235
|
+
const jobsResult = await apiRequest('GET', askResult.jobsUrl, null, false, false, MCP_USER_AGENT);
|
|
236
|
+
return {
|
|
237
|
+
...askResult,
|
|
238
|
+
jobs: jobsResult.jobs || jobsResult.data || [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// Untrusted jobsUrl. Strip it from the returned payload so the MCP client
|
|
242
|
+
// doesn't receive (and potentially act on) a path we just refused to follow
|
|
243
|
+
// ourselves. Include a telemetry breadcrumb so the agent sees the refusal.
|
|
244
|
+
// (Copilot finding #2 on PR #21.)
|
|
245
|
+
const { jobsUrl: _refused, ...safeAskResult } = askResult;
|
|
230
246
|
return {
|
|
231
|
-
...
|
|
232
|
-
|
|
247
|
+
...safeAskResult,
|
|
248
|
+
jobsUrl: null,
|
|
249
|
+
jobsUrlRefused: true,
|
|
233
250
|
};
|
|
234
251
|
}
|
|
235
252
|
return askResult;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"mcpName": "io.github.kevinastuhuaman/trackly",
|
|
5
5
|
"description": "AI job search CLI + hosted MCP server with OAuth. 128K+ jobs, 1,900+ companies, 40+ ATS. Works with Claude, ChatGPT, Cursor, Windsurf, Codex via hosted streamable-http or local stdio.",
|
|
6
6
|
"bin": {
|
package/server.json
CHANGED
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "0.2.
|
|
20
|
+
"version": "0.2.4",
|
|
21
21
|
"packages": [
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "trackly-cli",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.4",
|
|
26
26
|
"runtimeHint": "npx",
|
|
27
27
|
"runtimeArguments": [
|
|
28
28
|
{
|