@cat-factory/executor-harness 1.98.0 → 1.100.0

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.
@@ -277,19 +277,33 @@ export const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
277
277
  * reached the container by any other route is held to the rule too.
278
278
  */
279
279
  export function isAllowedMcpHttpUrl(raw) {
280
- const match = /^(https?):\/\/([^/?#]*)/i.exec(raw);
281
- if (!match)
280
+ // ASCII control characters and the space are refused ANYWHERE rather than canonicalised: the
281
+ // WHATWG parser trims leading/trailing C0-and-space and removes tab, LF and CR from anywhere, so
282
+ // a url carrying one parses to something other than what it reads as, and this url is written
283
+ // VERBATIM into the CLI's MCP config below.
284
+ for (let i = 0; i < raw.length; i += 1)
285
+ if (raw.charCodeAt(i) <= 0x20)
286
+ return false;
287
+ let parsed;
288
+ try {
289
+ parsed = new URL(raw);
290
+ }
291
+ catch {
292
+ // silent-catch-ok: an unparseable url is exactly what this predicate refuses.
293
+ return false;
294
+ }
295
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
282
296
  return false;
283
- if (match[1].toLowerCase() === 'https')
297
+ if (parsed.protocol === 'https:')
284
298
  return true;
285
- // Plain http from here: the host must be loopback. Strip userinfo FIRST and from the LAST `@`,
286
- // or `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example.
287
- const authority = match[2];
288
- const hostPort = authority.slice(authority.lastIndexOf('@') + 1);
289
- const closingBracket = hostPort.indexOf(']');
290
- const host = (hostPort.startsWith('[') && closingBracket !== -1
291
- ? hostPort.slice(1, closingBracket) // IPv6 literal, e.g. [::1]:8080
292
- : (hostPort.split(':')[0] ?? '')).toLowerCase();
299
+ // Plain http from here: the host must be loopback. `new URL` rather than a hand-written parse
300
+ // because it is the parser the CLI resolves the request with, so the host ruled on is the host
301
+ // the credential header travels to. It strips userinfo from the LAST `@` (or
302
+ // `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example), and
303
+ // it terminates the authority at a backslash as well as at `/?#` (or
304
+ // `http://evil.example\@127.0.0.1` reads as loopback the same way).
305
+ const hostname = parsed.hostname;
306
+ const host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
293
307
  return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host);
294
308
  }
295
309
  /** A string→string record, dropping any non-string entry. Undefined when nothing survives. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.98.0",
3
+ "version": "1.100.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.13.0",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.267.0",
34
- "@cat-factory/server": "0.247.0",
35
- "@cat-factory/spend": "0.15.35"
33
+ "@cat-factory/kernel": "0.272.0",
34
+ "@cat-factory/server": "0.252.0",
35
+ "@cat-factory/spend": "0.15.40"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
@@ -382,19 +382,28 @@ export const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
382
382
  * reached the container by any other route is held to the rule too.
383
383
  */
384
384
  export function isAllowedMcpHttpUrl(raw: string): boolean {
385
- const match = /^(https?):\/\/([^/?#]*)/i.exec(raw)
386
- if (!match) return false
387
- if (match[1]!.toLowerCase() === 'https') return true
388
- // Plain http from here: the host must be loopback. Strip userinfo FIRST and from the LAST `@`,
389
- // or `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example.
390
- const authority = match[2]!
391
- const hostPort = authority.slice(authority.lastIndexOf('@') + 1)
392
- const closingBracket = hostPort.indexOf(']')
393
- const host = (
394
- hostPort.startsWith('[') && closingBracket !== -1
395
- ? hostPort.slice(1, closingBracket) // IPv6 literal, e.g. [::1]:8080
396
- : (hostPort.split(':')[0] ?? '')
397
- ).toLowerCase()
385
+ // ASCII control characters and the space are refused ANYWHERE rather than canonicalised: the
386
+ // WHATWG parser trims leading/trailing C0-and-space and removes tab, LF and CR from anywhere, so
387
+ // a url carrying one parses to something other than what it reads as, and this url is written
388
+ // VERBATIM into the CLI's MCP config below.
389
+ for (let i = 0; i < raw.length; i += 1) if (raw.charCodeAt(i) <= 0x20) return false
390
+ let parsed: URL
391
+ try {
392
+ parsed = new URL(raw)
393
+ } catch {
394
+ // silent-catch-ok: an unparseable url is exactly what this predicate refuses.
395
+ return false
396
+ }
397
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
398
+ if (parsed.protocol === 'https:') return true
399
+ // Plain http from here: the host must be loopback. `new URL` rather than a hand-written parse
400
+ // because it is the parser the CLI resolves the request with, so the host ruled on is the host
401
+ // the credential header travels to. It strips userinfo from the LAST `@` (or
402
+ // `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example), and
403
+ // it terminates the authority at a backslash as well as at `/?#` (or
404
+ // `http://evil.example\@127.0.0.1` reads as loopback the same way).
405
+ const hostname = parsed.hostname
406
+ const host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
398
407
  return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host)
399
408
  }
400
409