alvin-bot 5.3.0 → 5.4.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.
@@ -62,6 +62,13 @@ function getMissingFileFailureMs() {
62
62
  const pending = new Map();
63
63
  let pollTimer = null;
64
64
  let started = false;
65
+ /**
66
+ * C-M2 — Set of agent IDs registered in THIS boot (not loaded from disk).
67
+ * Only in-memory-registered agents have a pid we can safely attribute to
68
+ * our own subprocess — disk-loaded pids may have been reused by the OS
69
+ * after a restart. We never kill a disk-loaded pid; only pids in this set.
70
+ */
71
+ const thisBootAgentIds = new Set();
65
72
  /**
66
73
  * Hard cap on the pending-agents map. Without this, a bot that runs many
67
74
  * async agents but sees some fail to write their outputFile would see
@@ -135,6 +142,9 @@ export function registerPendingAgent(input) {
135
142
  };
136
143
  enforcePendingCap();
137
144
  pending.set(input.agentId, entry);
145
+ // C-M2: mark this agent as registered in the current boot.
146
+ // Only this-boot agents have pids we can safely attribute to our own subprocess.
147
+ thisBootAgentIds.add(input.agentId);
138
148
  saveToDisk();
139
149
  }
140
150
  /**
@@ -295,11 +305,32 @@ async function deliverAsFailure(entry, status, error) {
295
305
  *
296
306
  * Never throws — all per-entry errors are swallowed.
297
307
  */
298
- export function killSessionDetachedAgents(session, killFn = (p) => {
308
+ /**
309
+ * C-M1 — Compute the signal target for a detached subprocess pid.
310
+ *
311
+ * Since agents are spawned `detached:true` they become process-group
312
+ * leaders. `claude -p` typically forks further (sub-agents), leaving
313
+ * grandchildren in the same group. Signalling only the group-leader PID
314
+ * lets those grandchildren survive. Instead, we signal the entire group
315
+ * by negating the pid (POSIX: kill(-pgid, sig) = signal the group).
316
+ *
317
+ * Windows does not support negative-pid group signals; on win32 we fall
318
+ * back to the positive pid (signals the leader only). A full win32 group-
319
+ * kill would require `taskkill /T /PID` — that can be layered later if
320
+ * Windows support becomes important.
321
+ *
322
+ * The injectable `killFn` always receives the already-transformed value
323
+ * (negative on POSIX, positive on win32) so tests can assert the correct
324
+ * target without needing platform-specific logic in test code.
325
+ */
326
+ function resolveKillTarget(pid) {
327
+ return process.platform !== "win32" ? -pid : pid;
328
+ }
329
+ export function killSessionDetachedAgents(session, killFn = (target) => {
299
330
  try {
300
- process.kill(p, "SIGTERM");
331
+ process.kill(target, "SIGTERM");
301
332
  }
302
- catch { /* already gone */ }
333
+ catch { /* already gone — ESRCH is fine */ }
303
334
  }) {
304
335
  // Use session.sessionKey — the real canonical key stamped by getSession().
305
336
  // Before v5.1.x this field did not exist on UserSession, causing a silent
@@ -310,12 +341,24 @@ export function killSessionDetachedAgents(session, killFn = (p) => {
310
341
  for (const entry of pending.values()) {
311
342
  if (entry.sessionKey !== key)
312
343
  continue;
313
- if (typeof entry.pid === "number") {
314
- try {
315
- killFn(entry.pid);
316
- }
317
- catch { /* best-effort */ }
344
+ if (typeof entry.pid !== "number")
345
+ continue;
346
+ // C-M2: only kill pids that are attributable to our own subprocess.
347
+ // Pids loaded from disk on a previous boot may have been reused by
348
+ // the OS for an unrelated process. We guard by only killing agents
349
+ // registered in THIS boot (thisBootAgentIds). Disk-loaded entries
350
+ // (those not in the set) are skipped — their subprocess may have
351
+ // already exited and the pid may point at an innocent process.
352
+ if (!thisBootAgentIds.has(entry.agentId)) {
353
+ console.log(`[async-watcher] skipping kill for disk-loaded agent ${entry.agentId} ` +
354
+ `(pid=${entry.pid}) — cannot safely attribute pid after restart`);
355
+ continue;
356
+ }
357
+ // C-M1: pass the group-kill target (negative pid on POSIX) to killFn.
358
+ try {
359
+ killFn(resolveKillTarget(entry.pid));
318
360
  }
361
+ catch { /* best-effort */ }
319
362
  }
320
363
  }
321
364
  /**
@@ -345,6 +388,7 @@ export function cancelPendingForSession(sessionKey) {
345
388
  /** Test-only: drop in-memory state. Doesn't touch disk. */
346
389
  export function __resetForTest() {
347
390
  pending.clear();
391
+ thisBootAgentIds.clear();
348
392
  if (pollTimer)
349
393
  clearInterval(pollTimer);
350
394
  pollTimer = null;
@@ -10,7 +10,7 @@
10
10
  * If a strategy is unavailable, we automatically cascade to the next one
11
11
  * and log a warning so failures are visible, not silent.
12
12
  */
13
- import { execSync, spawn } from "child_process";
13
+ import { execSync, execFileSync, spawn } from "child_process";
14
14
  import http from "http";
15
15
  import fs from "fs";
16
16
  import { config } from "../config.js";
@@ -22,7 +22,7 @@ const CDP_PORT = 9222;
22
22
  const EXEC_TIMEOUT = 60_000; // 60s for page loads via shell
23
23
  // ── Logging ──────────────────────────────────────────────────────────
24
24
  function log(msg) {
25
- console.warn(`[browser-manager] ${msg}`);
25
+ console.log(`[browser-manager] ${msg}`);
26
26
  }
27
27
  // ── Availability Checks ──────────────────────────────────────────────
28
28
  function isGatewayScriptPresent() {
@@ -170,9 +170,11 @@ export async function resolveStrategy(preferred) {
170
170
  }
171
171
  return "cli";
172
172
  }
173
- function execHub(args) {
173
+ function execHub(argv) {
174
174
  try {
175
- const result = execSync(`"${HUB_BROWSER_SH}" ${args}`, {
175
+ // H3: use execFileSync with discrete argv array — no shell interpolation,
176
+ // so attacker-controlled URLs cannot inject shell metacharacters.
177
+ const result = execFileSync(HUB_BROWSER_SH, argv, {
176
178
  stdio: "pipe",
177
179
  timeout: EXEC_TIMEOUT,
178
180
  env: { ...process.env, PATH: process.env.PATH },
@@ -310,7 +312,7 @@ async function navigateOne(strategy, url) {
310
312
  case "cdp": {
311
313
  // Try hub CDP first
312
314
  if (isHubBrowserAvailable()) {
313
- const result = execHub(`cdp goto "${url}"`);
315
+ const result = execHub(["cdp", "goto", url]);
314
316
  if (result && !result.error) {
315
317
  return { title: result.title || "", url: result.url || url };
316
318
  }
@@ -329,7 +331,7 @@ async function navigateOne(strategy, url) {
329
331
  log(`Direct CDP failed: ${err.message}`);
330
332
  // Last resort: try stealth
331
333
  if (isHubBrowserAvailable()) {
332
- const stealthResult = execHub(`stealth "${url}"`);
334
+ const stealthResult = execHub(["stealth", url]);
333
335
  if (stealthResult) {
334
336
  return { title: stealthResult.title || "", url: stealthResult.url || url };
335
337
  }
@@ -338,7 +340,7 @@ async function navigateOne(strategy, url) {
338
340
  }
339
341
  }
340
342
  case "hub-stealth": {
341
- const result = execHub(`stealth "${url}"`);
343
+ const result = execHub(["stealth", url]);
342
344
  if (result && !result.error) {
343
345
  return { title: result.title || "", url: result.url || url };
344
346
  }
@@ -369,7 +371,7 @@ export async function screenshot(url, options = {}) {
369
371
  case "cdp": {
370
372
  if (isHubBrowserAvailable()) {
371
373
  const tmpName = `shot_${Date.now()}.png`;
372
- const result = execHub(`cdp shot "${url}" ${tmpName}`);
374
+ const result = execHub(["cdp", "shot", url, tmpName]);
373
375
  if (result?.screenshot)
374
376
  return result.screenshot;
375
377
  }
@@ -378,7 +380,7 @@ export async function screenshot(url, options = {}) {
378
380
  }
379
381
  case "hub-stealth": {
380
382
  const tmpName = `shot_${Date.now()}.png`;
381
- const result = execHub(`stealth "${url}" --screenshot=${tmpName}`);
383
+ const result = execHub(["stealth", url, `--screenshot=${tmpName}`]);
382
384
  if (result?.screenshot)
383
385
  return result.screenshot;
384
386
  // Fallback
@@ -11,8 +11,18 @@
11
11
  * See browser-manager.ts for the full cascade; this module is the
12
12
  * leaf-level primitive with no dependencies on that file so both can
13
13
  * be unit-tested in isolation.
14
+ *
15
+ * SSRF hardening (M1): assertSsrfSafe() is called before every fetch hop to
16
+ * reject loopback / link-local / RFC-1918 / metadata / non-http(s)
17
+ * destinations. Redirects are followed manually (redirect:"manual") so every
18
+ * hop's Location header is re-validated before following — a public host that
19
+ * returns 302 → 169.254.169.254 is therefore blocked. Redirects are capped at
20
+ * 10 hops; an operator who needs redirect-to-internal can set
21
+ * ALLOW_PRIVATE_FETCH=1.
14
22
  */
23
+ import { assertSsrfSafe, SsrfBlockedError } from "./ssrf-guard.js";
15
24
  const DEFAULT_TIMEOUT_MS = 15_000;
25
+ const MAX_REDIRECTS = 10;
16
26
  const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 " +
17
27
  "(KHTML, like Gecko) Version/17.0 Safari/605.1.15 AlvinBot/webfetch";
18
28
  export class WebfetchFailed extends Error {
@@ -53,24 +63,48 @@ export function parseTitle(html) {
53
63
  return decodeEntities(inner);
54
64
  }
55
65
  export async function webfetchNavigate(url, options = {}) {
66
+ // M1: SSRF guard — reject private/internal destinations before fetching.
67
+ // SsrfBlockedError is intentionally not wrapped in WebfetchFailed so
68
+ // callers can distinguish "blocked by policy" from "server error".
69
+ // We validate EVERY redirect hop manually (redirect:"manual") so a
70
+ // public host cannot 302 us into an internal address.
71
+ await assertSsrfSafe(url);
56
72
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
57
73
  const controller = new AbortController();
58
74
  const timer = setTimeout(() => controller.abort(), timeoutMs);
59
75
  try {
76
+ let currentUrl = url;
60
77
  let response;
61
- try {
62
- response = await fetch(url, {
63
- method: "GET",
64
- headers: {
65
- "User-Agent": options.userAgent ?? DEFAULT_USER_AGENT,
66
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
67
- },
68
- redirect: "follow",
69
- signal: controller.signal,
70
- });
71
- }
72
- catch (err) {
73
- throw new WebfetchFailed(url, err.message, { cause: err });
78
+ for (let hop = 0;; hop++) {
79
+ try {
80
+ response = await fetch(currentUrl, {
81
+ method: "GET",
82
+ headers: {
83
+ "User-Agent": options.userAgent ?? DEFAULT_USER_AGENT,
84
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
85
+ },
86
+ redirect: "manual",
87
+ signal: controller.signal,
88
+ });
89
+ }
90
+ catch (err) {
91
+ throw new WebfetchFailed(url, err.message, { cause: err });
92
+ }
93
+ // Not a redirect — we have the final response
94
+ if (response.status < 300 || response.status >= 400)
95
+ break;
96
+ const loc = response.headers.get("location");
97
+ if (!loc)
98
+ break; // no Location header — treat as final response
99
+ if (hop >= MAX_REDIRECTS) {
100
+ throw new SsrfBlockedError(url, `too many redirects (> ${MAX_REDIRECTS})`);
101
+ }
102
+ const next = new URL(loc, currentUrl).href;
103
+ // Re-validate each redirect target before following — closes the
104
+ // post-redirect SSRF bypass where fetch would silently follow a
105
+ // 302 pointing at 169.254.169.254 / loopback / RFC-1918.
106
+ await assertSsrfSafe(next);
107
+ currentUrl = next;
74
108
  }
75
109
  if (!response.ok) {
76
110
  throw new WebfetchFailed(url, `HTTP ${response.status}`, { status: response.status });
@@ -29,34 +29,94 @@ function parseInterval(input) {
29
29
  };
30
30
  return value * (mult[unit] || 60_000);
31
31
  }
32
- function parseField(expr, min, max) {
33
- if (expr === "*")
34
- return Array.from({ length: max - min + 1 }, (_, i) => i + min);
35
- if (expr.includes("/")) {
36
- const [, step] = expr.split("/");
37
- const s = parseInt(step);
38
- return Array.from({ length: max - min + 1 }, (_, i) => i + min).filter((v) => v % s === 0);
32
+ /**
33
+ * Parse a single cron field token (no commas — commas are handled by parseField).
34
+ * Supports: `*`, `a`, `a-b`, `a/s`, `a-b/s`, `*\/s`.
35
+ * Returns an array of valid integers in [min,max], or null if the token is invalid/garbage.
36
+ */
37
+ function parseFieldToken(token, min, max) {
38
+ const fullRange = () => Array.from({ length: max - min + 1 }, (_, i) => i + min);
39
+ if (token.includes("/")) {
40
+ const slashIdx = token.indexOf("/");
41
+ const basePart = token.slice(0, slashIdx);
42
+ const stepPart = token.slice(slashIdx + 1);
43
+ const step = parseInt(stepPart, 10);
44
+ if (!Number.isFinite(step) || step <= 0)
45
+ return null;
46
+ let base;
47
+ if (basePart === "*") {
48
+ base = fullRange();
49
+ }
50
+ else if (basePart.includes("-")) {
51
+ const [aPart, bPart] = basePart.split("-");
52
+ const a = parseInt(aPart, 10);
53
+ const b = parseInt(bPart, 10);
54
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a > b || a < min || b > max)
55
+ return null;
56
+ base = Array.from({ length: b - a + 1 }, (_, i) => i + a);
57
+ }
58
+ else {
59
+ const a = parseInt(basePart, 10);
60
+ if (!Number.isFinite(a) || a < min || a > max)
61
+ return null;
62
+ base = [a];
63
+ }
64
+ // Filter by step aligned to base start
65
+ const baseStart = base[0];
66
+ return base.filter((v) => (v - baseStart) % step === 0);
39
67
  }
40
- if (expr.includes(","))
41
- return expr.split(",").map(Number);
42
- if (expr.includes("-")) {
43
- const [a, b] = expr.split("-").map(Number);
68
+ if (token === "*")
69
+ return fullRange();
70
+ if (token.includes("-")) {
71
+ const parts = token.split("-");
72
+ if (parts.length !== 2)
73
+ return null;
74
+ const a = parseInt(parts[0], 10);
75
+ const b = parseInt(parts[1], 10);
76
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a > b || a < min || b > max)
77
+ return null;
44
78
  return Array.from({ length: b - a + 1 }, (_, i) => i + a);
45
79
  }
46
- return [parseInt(expr)];
80
+ const v = parseInt(token, 10);
81
+ if (!Number.isFinite(v) || v < min || v > max)
82
+ return null;
83
+ return [v];
84
+ }
85
+ /**
86
+ * Parse a cron field expression (may contain commas) into a sorted array of valid integers.
87
+ * Supports comma-separated combinations of: `*`, `a`, `a-b`, `a-b/s`, `*\/s`.
88
+ * Returns null if any token is invalid/garbage (signals an invalid schedule).
89
+ */
90
+ function parseField(expr, min, max) {
91
+ // Split on commas; filter empty strings (handles "1,,3" gracefully — skip empty)
92
+ const tokens = expr.split(",").filter((t) => t.length > 0);
93
+ if (tokens.length === 0)
94
+ return null;
95
+ const result = new Set();
96
+ for (const token of tokens) {
97
+ const vals = parseFieldToken(token, min, max);
98
+ if (vals === null)
99
+ return null; // propagate invalid token as parse failure
100
+ for (const v of vals)
101
+ result.add(v);
102
+ }
103
+ const arr = [...result].sort((a, b) => a - b);
104
+ return arr.length > 0 ? arr : null;
47
105
  }
48
106
  function parseCronFields(expression) {
49
107
  const parts = expression.trim().split(/\s+/);
50
108
  if (parts.length !== 5)
51
109
  return null;
52
110
  const [minExpr, hourExpr, dayExpr, monthExpr, weekdayExpr] = parts;
53
- return {
54
- minutes: parseField(minExpr, 0, 59),
55
- hours: parseField(hourExpr, 0, 23),
56
- days: parseField(dayExpr, 1, 31),
57
- months: parseField(monthExpr, 1, 12),
58
- weekdays: parseField(weekdayExpr, 0, 6),
59
- };
111
+ const minutes = parseField(minExpr, 0, 59);
112
+ const hours = parseField(hourExpr, 0, 23);
113
+ const days = parseField(dayExpr, 1, 31);
114
+ const months = parseField(monthExpr, 1, 12);
115
+ const weekdays = parseField(weekdayExpr, 0, 6);
116
+ // Any field returning null means the expression is invalid → reject it
117
+ if (!minutes || !hours || !days || !months || !weekdays)
118
+ return null;
119
+ return { minutes, hours, days, months, weekdays };
60
120
  }
61
121
  function nextCronRun(expression, after) {
62
122
  const fields = parseCronFields(expression);
@@ -17,13 +17,113 @@ import { prepareForExecution, handleStartupCatchup, calculateNextRunFrom, } from
17
17
  import { resolveJobByNameOrId } from "./cron-resolver.js";
18
18
  import { bootWasExpectedRestart } from "./watchdog.js";
19
19
  // ── Storage ─────────────────────────────────────────────
20
+ /** Allowed job types — must stay in sync with the JobType union above. */
21
+ const ALLOWED_JOB_TYPES = new Set(["reminder", "shell", "ai-query", "http", "message"]);
22
+ /**
23
+ * M4: Per-entry structural validation for a raw parsed cron-jobs.json entry.
24
+ *
25
+ * Rules:
26
+ * - Must be a non-null object
27
+ * - `id`, `name`, `schedule`, `createdBy` must be non-empty strings
28
+ * - `type` must be one of the allowed JobType values
29
+ * - `payload` must be an object (not null)
30
+ * - `target` must be an object with `platform` (string) and `chatId` (string)
31
+ * - `enabled` must be a boolean
32
+ * - `createdAt`, `runCount` must be numbers
33
+ * - `oneShot` must be a boolean
34
+ * - Nullable fields (lastRunAt, lastResult, lastError, nextRunAt) can be
35
+ * null or their expected types — lenient check, just must not be undefined
36
+ *
37
+ * Returns a validated CronJob (cast) on success, null on failure.
38
+ * Logs a calm warning for every skipped entry.
39
+ */
40
+ function validateCronJobEntry(raw, index) {
41
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
42
+ console.warn(`[cron] skipping entry #${index}: not an object`);
43
+ return null;
44
+ }
45
+ const e = raw;
46
+ if (typeof e.id !== "string" || !e.id) {
47
+ console.warn(`[cron] skipping entry #${index}: missing or invalid 'id'`);
48
+ return null;
49
+ }
50
+ if (typeof e.name !== "string" || !e.name) {
51
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): missing or invalid 'name'`);
52
+ return null;
53
+ }
54
+ if (typeof e.type !== "string" || !ALLOWED_JOB_TYPES.has(e.type)) {
55
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): unknown or missing job type '${e.type}'`);
56
+ return null;
57
+ }
58
+ if (typeof e.schedule !== "string" || !e.schedule) {
59
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): missing or invalid 'schedule'`);
60
+ return null;
61
+ }
62
+ if (!e.payload || typeof e.payload !== "object" || Array.isArray(e.payload)) {
63
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'payload' must be an object`);
64
+ return null;
65
+ }
66
+ if (!e.target || typeof e.target !== "object" || Array.isArray(e.target)) {
67
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'target' must be an object`);
68
+ return null;
69
+ }
70
+ const t = e.target;
71
+ if (typeof t.platform !== "string" || typeof t.chatId !== "string") {
72
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'target.platform' and 'target.chatId' must be strings`);
73
+ return null;
74
+ }
75
+ if (typeof e.enabled !== "boolean") {
76
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'enabled' must be a boolean`);
77
+ return null;
78
+ }
79
+ if (typeof e.createdAt !== "number") {
80
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'createdAt' must be a number`);
81
+ return null;
82
+ }
83
+ if (typeof e.runCount !== "number") {
84
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'runCount' must be a number`);
85
+ return null;
86
+ }
87
+ if (typeof e.oneShot !== "boolean") {
88
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'oneShot' must be a boolean`);
89
+ return null;
90
+ }
91
+ // Lenient nullable fields
92
+ if (e.createdBy !== undefined && typeof e.createdBy !== "string") {
93
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'createdBy' must be a string`);
94
+ return null;
95
+ }
96
+ return raw;
97
+ }
20
98
  function loadJobs() {
99
+ let raw;
21
100
  try {
22
- return JSON.parse(fs.readFileSync(CRON_FILE, "utf-8"));
101
+ raw = fs.readFileSync(CRON_FILE, "utf-8");
23
102
  }
24
103
  catch {
25
104
  return [];
26
105
  }
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(raw);
109
+ }
110
+ catch (err) {
111
+ console.warn("[cron] cron-jobs.json is not valid JSON — starting with empty job list:", err instanceof Error ? err.message : String(err));
112
+ return [];
113
+ }
114
+ if (!Array.isArray(parsed)) {
115
+ console.warn("[cron] cron-jobs.json root is not an array — starting with empty job list");
116
+ return [];
117
+ }
118
+ // M4: Per-entry validation — one bad entry must NOT crash the whole list
119
+ const jobs = [];
120
+ for (let i = 0; i < parsed.length; i++) {
121
+ const validated = validateCronJobEntry(parsed[i], i);
122
+ if (validated !== null) {
123
+ jobs.push(validated);
124
+ }
125
+ }
126
+ return jobs;
27
127
  }
28
128
  function saveJobs(jobs) {
29
129
  const dir = dirname(CRON_FILE);
@@ -54,27 +154,87 @@ function nextCronRun(expression, after = new Date()) {
54
154
  if (parts.length !== 5)
55
155
  return null;
56
156
  const [minExpr, hourExpr, dayExpr, monthExpr, weekdayExpr] = parts;
57
- function parseField(expr, min, max) {
58
- if (expr === "*")
59
- return Array.from({ length: max - min + 1 }, (_, i) => i + min);
60
- if (expr.includes("/")) {
61
- const [, step] = expr.split("/");
62
- const s = parseInt(step);
63
- return Array.from({ length: max - min + 1 }, (_, i) => i + min).filter(v => v % s === 0);
157
+ /**
158
+ * Parse a single cron field token (no commas — commas handled by parseField).
159
+ * Supports: `*`, `a`, `a-b`, `a-b/s`, `*\/s`, `a/s`.
160
+ * Returns null for invalid/garbage tokens.
161
+ */
162
+ function parseFieldToken(token, min, max) {
163
+ const fullRange = () => Array.from({ length: max - min + 1 }, (_, i) => i + min);
164
+ if (token.includes("/")) {
165
+ const slashIdx = token.indexOf("/");
166
+ const basePart = token.slice(0, slashIdx);
167
+ const stepPart = token.slice(slashIdx + 1);
168
+ const step = parseInt(stepPart, 10);
169
+ if (!Number.isFinite(step) || step <= 0)
170
+ return null;
171
+ let base;
172
+ if (basePart === "*") {
173
+ base = fullRange();
174
+ }
175
+ else if (basePart.includes("-")) {
176
+ const dashParts = basePart.split("-");
177
+ if (dashParts.length !== 2)
178
+ return null;
179
+ const a = parseInt(dashParts[0], 10);
180
+ const b = parseInt(dashParts[1], 10);
181
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a > b || a < min || b > max)
182
+ return null;
183
+ base = Array.from({ length: b - a + 1 }, (_, i) => i + a);
184
+ }
185
+ else {
186
+ const a = parseInt(basePart, 10);
187
+ if (!Number.isFinite(a) || a < min || a > max)
188
+ return null;
189
+ base = [a];
190
+ }
191
+ const baseStart = base[0];
192
+ return base.filter((v) => (v - baseStart) % step === 0);
64
193
  }
65
- if (expr.includes(","))
66
- return expr.split(",").map(Number);
67
- if (expr.includes("-")) {
68
- const [a, b] = expr.split("-").map(Number);
194
+ if (token === "*")
195
+ return fullRange();
196
+ if (token.includes("-")) {
197
+ const dashParts = token.split("-");
198
+ if (dashParts.length !== 2)
199
+ return null;
200
+ const a = parseInt(dashParts[0], 10);
201
+ const b = parseInt(dashParts[1], 10);
202
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a > b || a < min || b > max)
203
+ return null;
69
204
  return Array.from({ length: b - a + 1 }, (_, i) => i + a);
70
205
  }
71
- return [parseInt(expr)];
206
+ const v = parseInt(token, 10);
207
+ if (!Number.isFinite(v) || v < min || v > max)
208
+ return null;
209
+ return [v];
210
+ }
211
+ /**
212
+ * Parse a cron field expression (may contain commas) into a sorted array of valid integers.
213
+ * Returns null if any token is invalid/garbage (the expression is rejected).
214
+ */
215
+ function parseField(expr, min, max) {
216
+ const tokens = expr.split(",").filter((t) => t.length > 0);
217
+ if (tokens.length === 0)
218
+ return null;
219
+ const result = new Set();
220
+ for (const token of tokens) {
221
+ const vals = parseFieldToken(token, min, max);
222
+ if (vals === null)
223
+ return null;
224
+ for (const v of vals)
225
+ result.add(v);
226
+ }
227
+ const arr = [...result].sort((a, b) => a - b);
228
+ return arr.length > 0 ? arr : null;
72
229
  }
73
230
  const minutes = parseField(minExpr, 0, 59);
74
231
  const hours = parseField(hourExpr, 0, 23);
75
232
  const days = parseField(dayExpr, 1, 31);
76
233
  const months = parseField(monthExpr, 1, 12);
77
234
  const weekdays = parseField(weekdayExpr, 0, 6); // 0=Sun
235
+ // Any field returning null means the expression is invalid — reject it
236
+ if (!minutes || !hours || !days || !months || !weekdays)
237
+ return null;
78
238
  // Search forward up to 366 days
79
239
  const candidate = new Date(after);
80
240
  candidate.setSeconds(0, 0);
@@ -112,6 +272,10 @@ let notifyCallback = null;
112
272
  export function setNotifyCallback(fn) {
113
273
  notifyCallback = fn;
114
274
  }
275
+ /** @internal exported for unit-test use only */
276
+ export async function runJob(job) {
277
+ return executeJob(job);
278
+ }
115
279
  async function executeJob(job) {
116
280
  try {
117
281
  switch (job.type) {
@@ -162,11 +326,36 @@ async function executeJob(job) {
162
326
  const url = job.payload.url || "";
163
327
  const method = job.payload.method || "GET";
164
328
  const headers = job.payload.headers || {};
165
- const fetchOpts = { method, headers };
329
+ // M1: SSRF guard reject private/internal destinations before fetching.
330
+ // Runs even when EXEC_SECURITY=deny (it's a separate, independent control).
331
+ // We validate EVERY redirect hop manually (redirect:"manual") so a
332
+ // public host cannot 302 us into an internal address (post-redirect SSRF).
333
+ const { assertSsrfSafe, SsrfBlockedError: SsrfBlockedErrorCron } = await import("./ssrf-guard.js");
334
+ await assertSsrfSafe(url);
335
+ const baseOpts = { method, headers };
166
336
  if (job.payload.body && method !== "GET") {
167
- fetchOpts.body = job.payload.body;
337
+ baseOpts.body = job.payload.body;
338
+ }
339
+ const MAX_CRON_REDIRECTS = 10;
340
+ let currentUrl = url;
341
+ let res;
342
+ for (let hop = 0;; hop++) {
343
+ res = await fetch(currentUrl, { ...baseOpts, redirect: "manual" });
344
+ // Not a redirect — we have the final response
345
+ if (res.status < 300 || res.status >= 400)
346
+ break;
347
+ const loc = res.headers.get("location");
348
+ if (!loc)
349
+ break; // no Location header — treat as final response
350
+ if (hop >= MAX_CRON_REDIRECTS) {
351
+ throw new SsrfBlockedErrorCron(url, `too many redirects (> ${MAX_CRON_REDIRECTS})`);
352
+ }
353
+ const next = new URL(loc, currentUrl).href;
354
+ // Re-validate each redirect target before following — closes the
355
+ // post-redirect SSRF bypass.
356
+ await assertSsrfSafe(next);
357
+ currentUrl = next;
168
358
  }
169
- const res = await fetch(url, fetchOpts);
170
359
  const text = await res.text();
171
360
  const output = `HTTP ${res.status}: ${text.slice(0, 2000)}`;
172
361
  if (notifyCallback) {
@@ -10,6 +10,9 @@ import crypto from "crypto";
10
10
  import { DELIVERY_QUEUE_FILE } from "../paths.js";
11
11
  // ── State ───────────────────────────────────────────────
12
12
  let senders = {};
13
+ /** Re-entrancy guard: prevents overlapping processQueue() invocations.
14
+ * Mirrors the `runningJobs` Set pattern used by cron.ts. */
15
+ let inFlight = false;
13
16
  // ── File I/O ────────────────────────────────────────────
14
17
  function readQueue() {
15
18
  try {
@@ -67,8 +70,24 @@ export function enqueue(channel, chatId, content, options) {
67
70
  * Process all pending entries in the queue.
68
71
  * Respects exponential backoff and max attempts.
69
72
  * Returns counts of delivered, failed, and still-pending entries.
73
+ *
74
+ * Re-entrancy guard: if a prior processQueue() call is still in-flight
75
+ * (e.g. a slow sender blocks beyond the 30s tick), the second invocation
76
+ * returns immediately with zero counts. Mirrors the runningJobs Set
77
+ * pattern used by cron.ts to guard overlapping job executions.
70
78
  */
71
79
  export async function processQueue() {
80
+ if (inFlight)
81
+ return { delivered: 0, failed: 0, pending: 0 };
82
+ inFlight = true;
83
+ try {
84
+ return await _processQueueInner();
85
+ }
86
+ finally {
87
+ inFlight = false;
88
+ }
89
+ }
90
+ async function _processQueueInner() {
72
91
  const queue = readQueue();
73
92
  const now = Date.now();
74
93
  let delivered = 0;