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 CHANGED
@@ -512,11 +512,20 @@ async function cmdAsk(args) {
512
512
  }
513
513
 
514
514
  if (result.jobsUrl) {
515
- console.log();
516
- console.log(color('dim', `Fetching: ${result.jobsUrl}`));
517
- const jobsResult = await apiRequest('GET', result.jobsUrl);
518
- console.log();
519
- outputJobs(jobsResult.jobs || jobsResult.data || []);
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
- res.on('data', chunk => body += chunk);
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
- const jobsResult = await apiRequest('GET', askResult.jobsUrl, null, false, false, MCP_USER_AGENT);
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
- ...askResult,
232
- jobs: jobsResult.jobs || jobsResult.data || [],
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",
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.3",
20
+ "version": "0.2.4",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.2.3",
25
+ "version": "0.2.4",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {