@0dai-dev/cli 3.3.1 → 3.3.2

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.
Files changed (2) hide show
  1. package/bin/0dai.js +53 -12
  2. package/package.json +1 -1
package/bin/0dai.js CHANGED
@@ -8,7 +8,26 @@ const path = require("path");
8
8
  const os = require("os");
9
9
 
10
10
  const VERSION = require("../package.json").version;
11
- const API_URL = process.env.ODAI_API_URL || "https://api.0dai.dev";
11
+
12
+ // CRITICAL: validate API_URL to prevent credential exfiltration to attacker hosts
13
+ function _validateApiUrl(url) {
14
+ const DEFAULT = "https://api.0dai.dev";
15
+ if (!url) return DEFAULT;
16
+ try {
17
+ const u = new URL(url);
18
+ // Only allow https (or http for localhost during dev)
19
+ if (u.protocol === "https:") return url;
20
+ if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1")) {
21
+ return url;
22
+ }
23
+ console.error(`[0dai] warning: ODAI_API_URL must use https (or http://localhost). Got: ${u.protocol}//${u.hostname}. Ignoring.`);
24
+ return DEFAULT;
25
+ } catch {
26
+ console.error(`[0dai] warning: ODAI_API_URL is not a valid URL, ignoring`);
27
+ return DEFAULT;
28
+ }
29
+ }
30
+ const API_URL = _validateApiUrl(process.env.ODAI_API_URL);
12
31
  const T = process.stdout.isTTY ? "\x1b[38;2;45;212;168m" : ""; // teal
13
32
  const R = process.stdout.isTTY ? "\x1b[0m" : ""; // reset
14
33
  const D = process.stdout.isTTY ? "\x1b[2m" : ""; // dim
@@ -321,17 +340,20 @@ function mergeSettingsJson(existing, incoming) {
321
340
  } catch { return incoming; }
322
341
  }
323
342
 
324
- function mergeAgentsMd(existing, incoming) {
325
- // If user marked as unmanaged, don't touch
326
- if (existing.includes("managed: false")) return existing;
327
- // If managed, update with new content but preserve user additions after managed block
328
- return incoming;
329
- }
330
-
331
343
  function writeFiles(target, files) {
332
- let created = 0, updated = 0, unchanged = 0, merged = 0;
344
+ let created = 0, updated = 0, unchanged = 0, merged = 0, skipped = 0;
345
+ const targetResolved = path.resolve(target);
333
346
  for (const [rel, content] of Object.entries(files)) {
334
- const p = path.join(target, rel);
347
+ // HIGH: path traversal protection — reject absolute paths and `..` escapes
348
+ if (typeof rel !== "string" || !rel || path.isAbsolute(rel) || rel.split(/[/\\]/).includes("..")) {
349
+ skipped++;
350
+ continue;
351
+ }
352
+ const p = path.resolve(targetResolved, rel);
353
+ if (!p.startsWith(targetResolved + path.sep) && p !== targetResolved) {
354
+ skipped++;
355
+ continue;
356
+ }
335
357
  fs.mkdirSync(path.dirname(p), { recursive: true });
336
358
 
337
359
  let finalContent = content;
@@ -365,6 +387,7 @@ function writeFiles(target, files) {
365
387
  }
366
388
  const parts = [`${created} created`, `${updated} updated`, `${unchanged} unchanged`];
367
389
  if (merged) parts.push(`${merged} merged`);
390
+ if (skipped) parts.push(`${skipped} skipped (unsafe path)`);
368
391
  log(parts.join(", "));
369
392
  return created + updated;
370
393
  }
@@ -1446,9 +1469,10 @@ async function cmdAuthLogin() {
1446
1469
  const url = `${API_URL}/v1/auth/${method}?cli=true`;
1447
1470
  p.log.info(`Opening browser: ${url}`);
1448
1471
  try {
1449
- const { execSync } = require("child_process");
1472
+ const { execFileSync } = require("child_process");
1450
1473
  const cmd = os.platform() === "darwin" ? "open" : os.platform() === "win32" ? "start" : "xdg-open";
1451
- execSync(`${cmd} "${url}"`, { stdio: "ignore" });
1474
+ // MED: use execFileSync to avoid shell injection via URL metacharacters
1475
+ execFileSync(cmd, [url], { stdio: "ignore" });
1452
1476
  } catch {
1453
1477
  p.log.warn(`Could not open browser. Visit manually:\n ${url}`);
1454
1478
  }
@@ -1791,6 +1815,23 @@ function cmdSwarm(target, sub, args) {
1791
1815
  const event = args.find((_, i) => args[i-1] === "--event") || "all";
1792
1816
  const secret = args.find((_, i) => args[i-1] === "--secret") || "";
1793
1817
  if (!url || !url.startsWith("http")) { log("Usage: 0dai swarm webhook add <url> [--event task_done|task_failed|all] [--secret TOKEN]"); return; }
1818
+ // MED: SSRF protection — block internal/metadata endpoints
1819
+ try {
1820
+ const u = new URL(url);
1821
+ const host = u.hostname;
1822
+ const BLOCKED = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.|::1$|fc00:|fe80:|localhost$|0\.0\.0\.0$)/i;
1823
+ if (BLOCKED.test(host) || host === "metadata.google.internal") {
1824
+ log(`rejected: ${host} is a private/internal address (SSRF protection)`);
1825
+ return;
1826
+ }
1827
+ if (u.protocol !== "https:" && u.protocol !== "http:") {
1828
+ log(`rejected: only http/https allowed, got ${u.protocol}`);
1829
+ return;
1830
+ }
1831
+ } catch {
1832
+ log(`invalid URL: ${url}`);
1833
+ return;
1834
+ }
1794
1835
  const hooks = loadHooks();
1795
1836
  if (hooks.find(h => h.url === url)) { log(`already registered: ${url}`); return; }
1796
1837
  hooks.push({ url, event, secret: secret || undefined, added_at: new Date().toISOString() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0dai-dev/cli",
3
- "version": "3.3.1",
3
+ "version": "3.3.2",
4
4
  "description": "One config layer for 5 AI agent CLIs — Claude Code, Codex, OpenCode, Gemini, Aider",
5
5
  "bin": {
6
6
  "0dai": "./bin/0dai.js"