@goodandready/dsh-cron 0.2.27 → 0.2.28

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/README.md CHANGED
@@ -517,7 +517,7 @@ Notes:
517
517
 
518
518
  ## 🔌 HTTP API Reference
519
519
 
520
- All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoints are open to the local UI; **mutating endpoints reject cross-origin requests** and accept bodies up to 1 MB. Creating `script`-type tasks over HTTP additionally requires the `x-dsh-cron-confirm: script` header, which forged cross-site posts cannot attach.
520
+ All endpoints are served by the DSH web server under `/dsh-cron/`. Endpoints are protected by a hardened HTTP source guard (`isTrustedRequest`): non-loopback remote callers require token authentication (`Authorization: Bearer <token>` or `x-dsh-cron-token`), and browser requests enforce strict `Host` and `Origin` matching, reject `Origin: null`, and restrict `Sec-Fetch-Site` to `same-origin` or `none`. Heartbeat ping routes strictly require the `POST` method. Task `GET` responses automatically redact sensitive fields (`env`, `httpHeaders`, `httpBody`) as `'[REDACTED]'`, and update operations preserve existing secrets when `'[REDACTED]'` is passed. Creating `script`-type tasks over HTTP additionally requires the `x-dsh-cron-confirm: script` header, which forged cross-site posts cannot attach. Body size is capped at 1 MB.
521
521
 
522
522
  | Method | Path | Description |
523
523
  |:---|:---|:---|
package/README.ru.md CHANGED
@@ -506,7 +506,7 @@ bash deploy.sh verify [exact-version]
506
506
 
507
507
  ## 🔌 HTTP API
508
508
 
509
- Все эндпоинты обслуживаются веб-сервером DSH под `/dsh-cron/`. Чтение открыто локальному интерфейсу; **мутирующие эндпоинты отклоняют cross-origin запросы** и принимают тела до 1 МБ. Для создания `script`-задач по HTTP дополнительно требуется заголовок `x-dsh-cron-confirm: script`, который подделанный межсайтовый запрос приложить не может.
509
+ Все эндпоинты обслуживаются веб-сервером DSH под `/dsh-cron/`. Эндпоинты защищены строгой проверкой источника (`isTrustedRequest`): удалённые не-loopback клиенты требуют аутентификацию токеном (`Authorization: Bearer <token>` или `x-dsh-cron-token`), а браузерные запросы проходят строгую валидацию совпадения `Host` и `Origin` с блокировкой `Origin: null` и ограничением `Sec-Fetch-Site` (`same-origin` или `none`). Маршруты heartbeat-ping строго требуют метод `POST`. В ответах `GET` чувствительные данные задач (`env`, `httpHeaders`, `httpBody`) маскируются как `'[REDACTED]'`, а при обновлении задач переданные плейсхолдеры `'[REDACTED]'` сохраняют оригинальные сохранённые секреты. Для создания `script`-задач по HTTP дополнительно требуется заголовок `x-dsh-cron-confirm: script`. Максимальный размер тела запроса ограничен 1 МБ.
510
510
 
511
511
  | Метод | Путь | Описание |
512
512
  |:---|:---|:---|
package/README.zh.md CHANGED
@@ -506,7 +506,7 @@ dsh-cron:
506
506
 
507
507
  ## 🔌 HTTP API 参考
508
508
 
509
- 所有端点由 DSH Web 服务器在 `/dsh-cron/` 下提供。读端点对本地 UI 开放;**变更端点拒绝跨域请求**且请求体最大 1 MB。通过 HTTP 创建 `script` 类型任务还需要 `x-dsh-cron-confirm: script` 请求头 —— 伪造的跨站请求无法附加该头。
509
+ 所有端点由 DSH Web 服务器在 `/dsh-cron/` 下提供。所有端点均受到强化的 HTTP 来源防护(`isTrustedRequest`):非回环远程客户端必须携带有效令牌(`Authorization: Bearer <token>` 或 `x-dsh-cron-token`),浏览器请求严格校验 `Host` 与 `Origin` 一致性并拒绝 `Origin: null`,同时限制 `Sec-Fetch-Site` 仅允许 `same-origin` 或 `none`。心跳 ping 端点严格要求 `POST` 方法。任务 `GET` 响应自动将敏感字段(`env`、`httpHeaders`、`httpBody`)掩码为 `'[REDACTED]'`,并在更新操作提交 `'[REDACTED]'` 时安全保留已有原密钥。通过 HTTP 创建 `script` 类型任务还需要 `x-dsh-cron-confirm: script` 请求头。请求体大小上限为 1 MB。
510
510
 
511
511
  | 方法 | 路径 | 说明 |
512
512
  |:---|:---|:---|
@@ -4,6 +4,13 @@ import { sendJson, readBody, rejectCrossOrigin } from './http-utils.js';
4
4
  const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
5
5
 
6
6
  export function handleHeartbeatPing({ store, req, res, taskId }) {
7
+ if (req.method !== 'POST') {
8
+ sendJson(res, 405, NOT_ALLOWED);
9
+ return;
10
+ }
11
+ const apiToken = store?.getSettings?.()?.apiToken;
12
+ if (rejectCrossOrigin(req, res, { apiToken })) return;
13
+
7
14
  const task = store.get(taskId);
8
15
  if (!task) {
9
16
  sendJson(res, 404, { ok: false, error: 'Task not found' });
@@ -17,7 +24,10 @@ export function handleHeartbeatPing({ store, req, res, taskId }) {
17
24
  sendJson(res, 200, result);
18
25
  }
19
26
 
20
- export async function handleSchedulePreview({ req, res, url }) {
27
+ export async function handleSchedulePreview({ req, res, url, store }) {
28
+ const apiToken = store?.getSettings?.()?.apiToken;
29
+ if (rejectCrossOrigin(req, res, { apiToken })) return;
30
+
21
31
  let schedule = '';
22
32
  let timezone = '';
23
33
  let count = 5;
@@ -51,7 +61,8 @@ export async function handleSchedulePreview({ req, res, url }) {
51
61
  }
52
62
 
53
63
  export async function handleDryRunTask({ store, scheduler, req, res, id }) {
54
- if (rejectCrossOrigin(req, res)) return;
64
+ const apiToken = store?.getSettings?.()?.apiToken;
65
+ if (rejectCrossOrigin(req, res, { apiToken })) return;
55
66
  const task = store.get(id);
56
67
  if (!task) {
57
68
  sendJson(res, 404, { ok: false, error: 'Task not found' });
@@ -65,4 +76,3 @@ export async function handleDryRunTask({ store, scheduler, req, res, id }) {
65
76
  sendJson(res, 500, { ok: false, error: err.message });
66
77
  }
67
78
  }
68
-
package/lib/api.js CHANGED
@@ -11,7 +11,7 @@ import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
11
11
  import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
12
12
  import { applyTaskPatch } from './task-patch.js';
13
13
  import { MANAGED_BY_CONFIG, configOwnedMessage } from './config-jobs.js';
14
- import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER } from './http-utils.js';
14
+ import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER, redactTaskSecrets, restoreTaskSecrets } from './http-utils.js';
15
15
  import {
16
16
  validateTaskType,
17
17
  buildDuplicateTask,
@@ -64,9 +64,10 @@ function refuseConfigOwned(res, id) {
64
64
  export function createCronApiHandler(store, scheduler, collection) {
65
65
  return async function handleCronApi(req, res) {
66
66
  try {
67
+ const apiToken = store?.getSettings?.()?.apiToken;
67
68
  const url = new URL(req.url, 'http://127.0.0.1');
68
69
  const parts = url.pathname.split('/').filter(Boolean);
69
- const scope = parts[1]; // 'tasks' | 'action'
70
+ const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
70
71
  const id = parts[2];
71
72
  const action = parts[3];
72
73
 
@@ -75,22 +76,22 @@ export function createCronApiHandler(store, scheduler, collection) {
75
76
  return;
76
77
  }
77
78
 
78
- if (scope === 'heartbeat' && id) {
79
+ if ((scope === 'heartbeat' || scope === 'heartbeat-ping') && id) {
79
80
  handleHeartbeatPing({ store, req, res, taskId: id });
80
81
  return;
81
82
  }
82
83
 
83
84
  if (scope === 'schedule' && id === 'preview') {
84
- await handleSchedulePreview({ req, res, url });
85
+ await handleSchedulePreview({ req, res, url, store });
85
86
  return;
86
87
  }
87
88
 
88
89
  if (!id) {
89
- await handleTaskCollection({ store, scheduler, collection, req, res, url, scope });
90
+ await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken });
90
91
  return;
91
92
  }
92
93
 
93
- if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
94
+ if (rejectCrossOrigin(req, res, { apiToken })) return;
94
95
 
95
96
  // Export/import are collection operations that live under /tasks/:id/…,
96
97
  // so they are matched before the generic id handling below.
@@ -103,7 +104,7 @@ export function createCronApiHandler(store, scheduler, collection) {
103
104
  return;
104
105
  }
105
106
 
106
- await handleTaskItem({ store, scheduler, req, res, url, id, action });
107
+ await handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken });
107
108
  } catch (err) {
108
109
  sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
109
110
  }
@@ -112,18 +113,19 @@ export function createCronApiHandler(store, scheduler, collection) {
112
113
 
113
114
  // ------------------------------------------------------------- collection
114
115
 
115
- async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope }) {
116
+ async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken }) {
116
117
  // The alias family only exists for individual tasks.
117
118
  if (scope === 'action' || !collection) {
118
119
  sendJson(res, 405, NOT_ALLOWED);
119
120
  return;
120
121
  }
121
122
  if (req.method === 'GET') {
123
+ if (rejectCrossOrigin(req, res, { apiToken })) return;
122
124
  listTasks({ store, scheduler, collection, req, res, url });
123
125
  return;
124
126
  }
125
127
  if (req.method === 'POST') {
126
- await createOrUpdateTask({ store, scheduler, req, res });
128
+ await createOrUpdateTask({ store, scheduler, req, res, apiToken });
127
129
  return;
128
130
  }
129
131
  sendJson(res, 405, NOT_ALLOWED);
@@ -133,7 +135,7 @@ function listTasks({ store, scheduler, collection, req, res, url }) {
133
135
  const status = url.searchParams.get('status') || 'all';
134
136
  const query = url.searchParams.get('query') || '';
135
137
  const list = store.list({ status, query }).map((t) => ({
136
- ...t,
138
+ ...redactTaskSecrets(t),
137
139
  running: scheduler.isRunning(t.id),
138
140
  runningSince: scheduler.runningSince(t.id),
139
141
  }));
@@ -160,10 +162,12 @@ function listTasks({ store, scheduler, collection, req, res, url }) {
160
162
  sendJson(res, 200, payload);
161
163
  }
162
164
 
163
- async function createOrUpdateTask({ store, scheduler, req, res }) {
164
- if (rejectCrossOrigin(req, res)) return;
165
- const { body, error } = await readBody(req, res);
165
+ async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
166
+ if (rejectCrossOrigin(req, res, { apiToken })) return;
167
+ const { body: rawBody, error } = await readBody(req, res);
166
168
  if (error) return;
169
+ const existing = rawBody.id ? store.get(rawBody.id) : null;
170
+ const body = existing ? restoreTaskSecrets(rawBody, existing) : rawBody;
167
171
  if (!body.title || !body.schedule || !body.prompt) {
168
172
  sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
169
173
  return;
@@ -211,7 +215,7 @@ async function createOrUpdateTask({ store, scheduler, req, res }) {
211
215
  } else {
212
216
  scheduler.pauseTask(task.id);
213
217
  }
214
- sendJson(res, 200, { ok: true, task });
218
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
215
219
  }
216
220
 
217
221
  /** Merge a create/update payload onto the stored task, if any. */
@@ -249,8 +253,8 @@ function buildTaskRecord(store, body, taskType, parsed) {
249
253
 
250
254
  // ------------------------------------------------------------ single task
251
255
 
252
- async function handleTaskItem({ store, scheduler, req, res, url, id, action }) {
253
- if ((req.method === 'GET' || req.method === 'POST') && action === 'heartbeat') {
256
+ async function handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken }) {
257
+ if (action === 'heartbeat') {
254
258
  handleHeartbeatPing({ store, req, res, taskId: id });
255
259
  return;
256
260
  }
@@ -260,25 +264,44 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action }) {
260
264
  return;
261
265
  }
262
266
 
263
- if (req.method === 'GET' && action === 'archive') {
264
- const limit = parseInt(url.searchParams.get('limit') || '50', 10);
265
- const offset = parseInt(url.searchParams.get('offset') || '0', 10);
266
- const search = url.searchParams.get('search') || '';
267
- const result = typeof store.getArchivedRuns === 'function'
268
- ? store.getArchivedRuns(id, { limit, offset, search })
269
- : { runs: [], total: 0 };
270
- sendJson(res, 200, { ok: true, taskId: id, ...result });
271
- return;
272
- }
273
- if (req.method === 'GET' && action === 'stats') {
274
- const stats = typeof store.getTaskStats === 'function' ? store.getTaskStats(id) : null;
275
- sendJson(res, 200, { ok: true, taskId: id, stats });
276
- return;
277
- }
278
- if (req.method === 'GET' && (action === 'history' || !action)) {
279
- const limit = parseInt(url.searchParams.get('limit') || '20', 10);
280
- sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
281
- return;
267
+ if (req.method === 'GET') {
268
+ if (rejectCrossOrigin(req, res, { apiToken })) return;
269
+ if (action === 'archive') {
270
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
271
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10);
272
+ const search = url.searchParams.get('search') || '';
273
+ const result = typeof store.getArchivedRuns === 'function'
274
+ ? store.getArchivedRuns(id, { limit, offset, search })
275
+ : { runs: [], total: 0 };
276
+ sendJson(res, 200, { ok: true, taskId: id, ...result });
277
+ return;
278
+ }
279
+ if (action === 'stats') {
280
+ const stats = typeof store.getTaskStats === 'function' ? store.getTaskStats(id) : null;
281
+ sendJson(res, 200, { ok: true, taskId: id, stats });
282
+ return;
283
+ }
284
+ if (action === 'history') {
285
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
286
+ sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
287
+ return;
288
+ }
289
+ if (!action) {
290
+ const task = store.get(id);
291
+ if (!task) {
292
+ sendJson(res, 404, NOT_FOUND);
293
+ return;
294
+ }
295
+ sendJson(res, 200, {
296
+ ok: true,
297
+ task: {
298
+ ...redactTaskSecrets(task),
299
+ running: scheduler.isRunning(task.id),
300
+ runningSince: scheduler.runningSince(task.id),
301
+ },
302
+ });
303
+ return;
304
+ }
282
305
  }
283
306
  if (req.method === 'POST') {
284
307
  const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
@@ -303,7 +326,7 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
303
326
  return true;
304
327
  }
305
328
  await scheduler.triggerManualRun(id);
306
- sendJson(res, 200, { ok: true, task: store.get(id) });
329
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(store.get(id)) });
307
330
  return true;
308
331
  }
309
332
  if (action === 'duplicate') {
@@ -317,7 +340,7 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
317
340
  // A copy starts paused: it must never fire on its own before the user
318
341
  // reviews the schedule (a duplicated one-shot may point at a past time).
319
342
  scheduler.pauseTask(copy.id);
320
- sendJson(res, 200, { ok: true, task: copy });
343
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(copy) });
321
344
  return true;
322
345
  }
323
346
  if (action === 'pause' || action === 'resume') {
@@ -330,7 +353,7 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
330
353
  sendJson(res, 404, NOT_FOUND);
331
354
  return true;
332
355
  }
333
- sendJson(res, 200, { ok: true, task });
356
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
334
357
  return true;
335
358
  }
336
359
  if (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle')) {
@@ -344,7 +367,7 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
344
367
  return true;
345
368
  }
346
369
  const task = scheduler.toggleTask(id);
347
- sendJson(res, 200, { ok: true, task });
370
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
348
371
  return true;
349
372
  }
350
373
  return false;
@@ -381,7 +404,7 @@ async function patchTask({ store, scheduler, req, res, id }) {
381
404
  });
382
405
  return;
383
406
  }
384
- sendJson(res, 200, { ok: true, task: result.task });
407
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(result.task) });
385
408
  }
386
409
 
387
410
  function deleteTask({ store, scheduler, res, id }) {
@@ -464,7 +487,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
464
487
  if (req.method === 'GET') {
465
488
  const list = store
466
489
  .list({ status: url.searchParams.get('status') || 'all', query: url.searchParams.get('query') || '' })
467
- .map((task) => ({ ...task, running: scheduler.isRunning(task.id) }));
490
+ .map((task) => ({ ...redactTaskSecrets(task), running: scheduler.isRunning(task.id) }));
468
491
  sendJson(res, 200, { ok: true, tasks: list });
469
492
  return;
470
493
  }
@@ -486,7 +509,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
486
509
  sendJson(res, 404, NOT_FOUND);
487
510
  return;
488
511
  }
489
- sendJson(res, 200, { ok: true, task: { ...task, running: scheduler.isRunning(id) } });
512
+ sendJson(res, 200, { ok: true, task: { ...redactTaskSecrets(task), running: scheduler.isRunning(id) } });
490
513
  return;
491
514
  }
492
515
  if (req.method === 'DELETE' && !action) {
package/lib/http-utils.js CHANGED
@@ -6,6 +6,35 @@
6
6
 
7
7
  export const MAX_BODY_BYTES = 1024 * 1024;
8
8
  export const SCRIPT_CONFIRM_HEADER = 'x-dsh-cron-confirm';
9
+ export const REDACTED_SECRET = '[REDACTED]';
10
+
11
+ /**
12
+ * Check if an IP address is a local loopback address.
13
+ */
14
+ export function isLoopbackAddress(addr) {
15
+ if (!addr || typeof addr !== 'string') return false;
16
+ return (
17
+ addr === '127.0.0.1' ||
18
+ addr === '::1' ||
19
+ addr === '::ffff:127.0.0.1' ||
20
+ addr === 'localhost'
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Extract bearer authorization or custom token from request headers.
26
+ */
27
+ export function extractBearerToken(req) {
28
+ const auth = req?.headers?.authorization;
29
+ if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
30
+ return auth.slice(7).trim();
31
+ }
32
+ const custom = req?.headers?.['x-dsh-cron-token'];
33
+ if (typeof custom === 'string') {
34
+ return custom.trim();
35
+ }
36
+ return '';
37
+ }
9
38
 
10
39
  /**
11
40
  * Parse a JSON request body with a hard byte size cap.
@@ -48,27 +77,63 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
48
77
  }
49
78
 
50
79
  /**
51
- * Detect cross-origin (drive-by/CSRF) requests.
52
- * Browsers attach Origin to every non-GET request and Sec-Fetch-Site on
53
- * modern fetches; plain local API clients (curl, server-side calls) send
54
- * neither and stay allowed.
80
+ * Validate that an incoming request originates from a trusted local caller or
81
+ * an authenticated client (Issue #86).
82
+ *
83
+ * Rules:
84
+ * 1. Non-loopback remote callers are rejected unless authenticated with a valid token.
85
+ * 2. Host header must be present and non-empty.
86
+ * 3. Origin header (if present) cannot be null and its host must match Host.
87
+ * 4. Sec-Fetch-Site (if present) must be same-origin or none (cross-site/same-site rejected).
88
+ * 5. Mutating requests (POST, PUT, PATCH, DELETE) must provide browser proof (same-origin / matching Origin)
89
+ * or an explicit capability header (confirm header, token, or update header).
55
90
  */
56
- export function isCrossOrigin(req) {
91
+ export function isTrustedRequest(req, options = {}) {
92
+ if (!req) return false;
57
93
  const headers = req.headers || {};
94
+
95
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
96
+ const hasRemote = Boolean(remote && typeof remote === 'string');
97
+ const isLoopback = !hasRemote || isLoopbackAddress(remote);
98
+
99
+ const token = extractBearerToken(req);
100
+ const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
101
+ const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
102
+
103
+ // 1. Non-loopback remote addresses require explicit token authentication (#86)
104
+ if (hasRemote && !isLoopback && !isTokenAuthenticated) {
105
+ return false;
106
+ }
107
+
108
+ // 2. Validate Origin header if present: cannot be null, must match Host
58
109
  const origin = headers.origin;
110
+ const host = headers.host || '';
59
111
  if (origin !== undefined) {
60
- if (origin === 'null') return true;
112
+ if (origin === 'null' || !origin) return false;
61
113
  try {
62
114
  const parsed = new URL(origin);
63
- const host = headers.host || '';
64
- if (parsed.host && parsed.host !== host) return true;
65
- } catch (err) {
66
- return true;
115
+ if (!parsed.host || !host || parsed.host !== host) return false;
116
+ } catch {
117
+ return false;
67
118
  }
68
119
  }
120
+
121
+ // 3. Validate Sec-Fetch-Site header if present
69
122
  const site = headers['sec-fetch-site'];
70
- if (site && site !== 'same-origin' && site !== 'none') return true;
71
- return false;
123
+ if (site !== undefined) {
124
+ if (site !== 'same-origin' && site !== 'none') {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ return true;
130
+ }
131
+
132
+ /**
133
+ * Backward-compatible wrapper for checking cross-origin / untrusted requests.
134
+ */
135
+ export function isCrossOrigin(req, options) {
136
+ return !isTrustedRequest(req, options);
72
137
  }
73
138
 
74
139
  /**
@@ -91,9 +156,9 @@ export function sendJson(res, statusCode, data) {
91
156
  res.end(JSON.stringify(data));
92
157
  }
93
158
 
94
- export function rejectCrossOrigin(req, res) {
95
- if (isCrossOrigin(req)) {
96
- sendJson(res, 403, { ok: false, error: 'Cross-origin request rejected' });
159
+ export function rejectCrossOrigin(req, res, options) {
160
+ if (!isTrustedRequest(req, options)) {
161
+ sendJson(res, 403, { ok: false, error: 'Forbidden: untrusted or cross-origin request' });
97
162
  return true;
98
163
  }
99
164
  return false;
@@ -108,4 +173,109 @@ export async function readBody(req, res) {
108
173
  }
109
174
  }
110
175
 
111
- /** Settings keys synchronized between the store and the settings service. */
176
+ /**
177
+ * Redact sensitive fields (env, httpHeaders, httpBody) from task records before returning in GET responses (Issue #86).
178
+ */
179
+ export function redactTaskSecrets(task) {
180
+ if (!task || typeof task !== 'object') return task;
181
+ const clone = { ...task };
182
+
183
+ if (clone.env && typeof clone.env === 'object') {
184
+ const redactedEnv = {};
185
+ for (const [k, v] of Object.entries(clone.env)) {
186
+ redactedEnv[k] = v ? REDACTED_SECRET : v;
187
+ }
188
+ clone.env = redactedEnv;
189
+ }
190
+
191
+ if (clone.httpHeaders) {
192
+ if (typeof clone.httpHeaders === 'object' && clone.httpHeaders !== null) {
193
+ const redactedHeaders = {};
194
+ for (const [k, v] of Object.entries(clone.httpHeaders)) {
195
+ redactedHeaders[k] = v ? REDACTED_SECRET : v;
196
+ }
197
+ clone.httpHeaders = redactedHeaders;
198
+ } else if (typeof clone.httpHeaders === 'string') {
199
+ try {
200
+ const parsed = JSON.parse(clone.httpHeaders);
201
+ if (parsed && typeof parsed === 'object') {
202
+ const redactedHeaders = {};
203
+ for (const [k, v] of Object.entries(parsed)) {
204
+ redactedHeaders[k] = v ? REDACTED_SECRET : v;
205
+ }
206
+ clone.httpHeaders = JSON.stringify(redactedHeaders);
207
+ } else {
208
+ clone.httpHeaders = REDACTED_SECRET;
209
+ }
210
+ } catch {
211
+ clone.httpHeaders = REDACTED_SECRET;
212
+ }
213
+ }
214
+ }
215
+
216
+ if (clone.httpBody) {
217
+ clone.httpBody = REDACTED_SECRET;
218
+ }
219
+
220
+ return clone;
221
+ }
222
+
223
+ /**
224
+ * Restore redacted secret fields from the existing record when updating a task (Issue #86).
225
+ */
226
+ export function restoreTaskSecrets(incoming, current) {
227
+ if (!incoming || typeof incoming !== 'object' || !current || typeof current !== 'object') {
228
+ return incoming;
229
+ }
230
+ const restored = { ...incoming };
231
+
232
+ // Restore env values
233
+ if (restored.env && typeof restored.env === 'object' && current.env && typeof current.env === 'object') {
234
+ const mergedEnv = { ...restored.env };
235
+ for (const [k, v] of Object.entries(mergedEnv)) {
236
+ if (v === REDACTED_SECRET && current.env[k] !== undefined) {
237
+ mergedEnv[k] = current.env[k];
238
+ }
239
+ }
240
+ restored.env = mergedEnv;
241
+ }
242
+
243
+ // Restore httpHeaders values
244
+ if (restored.httpHeaders && current.httpHeaders) {
245
+ if (typeof restored.httpHeaders === 'object' && typeof current.httpHeaders === 'object') {
246
+ const mergedHeaders = { ...restored.httpHeaders };
247
+ for (const [k, v] of Object.entries(mergedHeaders)) {
248
+ if (v === REDACTED_SECRET && current.httpHeaders[k] !== undefined) {
249
+ mergedHeaders[k] = current.httpHeaders[k];
250
+ }
251
+ }
252
+ restored.httpHeaders = mergedHeaders;
253
+ } else if (typeof restored.httpHeaders === 'string') {
254
+ try {
255
+ const parsedRestored = JSON.parse(restored.httpHeaders);
256
+ const parsedCurrent = typeof current.httpHeaders === 'object'
257
+ ? current.httpHeaders
258
+ : JSON.parse(current.httpHeaders);
259
+ if (parsedRestored && typeof parsedRestored === 'object' && parsedCurrent && typeof parsedCurrent === 'object') {
260
+ for (const [k, v] of Object.entries(parsedRestored)) {
261
+ if (v === REDACTED_SECRET && parsedCurrent[k] !== undefined) {
262
+ parsedRestored[k] = parsedCurrent[k];
263
+ }
264
+ }
265
+ restored.httpHeaders = JSON.stringify(parsedRestored);
266
+ }
267
+ } catch {
268
+ if (restored.httpHeaders === REDACTED_SECRET) {
269
+ restored.httpHeaders = current.httpHeaders;
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ // Restore httpBody value
276
+ if (restored.httpBody === REDACTED_SECRET && current.httpBody !== undefined) {
277
+ restored.httpBody = current.httpBody;
278
+ }
279
+
280
+ return restored;
281
+ }
package/lib/task-patch.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { parseScheduleExpression } from './scheduler.js';
12
12
  import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
13
13
  import { PATCHABLE_TASK_FIELDS, validateTaskType } from './task-transfer.js';
14
- import { pickPatchableFields } from './http-utils.js';
14
+ import { pickPatchableFields, restoreTaskSecrets } from './http-utils.js';
15
15
  import { unknownChannelIds } from './channels.js';
16
16
  import { isConfigOwned, configOwnedMessage } from './config-jobs.js';
17
17
 
@@ -28,7 +28,8 @@ export function isCodeTypeSwitch(current, nextType) {
28
28
  * Returns `{ ok: false, error }` when the merged task would be invalid.
29
29
  */
30
30
  export function buildTaskPatch(current, body) {
31
- const patch = pickPatchableFields(body || {}, PATCHABLE_TASK_FIELDS);
31
+ const mergedBody = current ? restoreTaskSecrets(body || {}, current) : (body || {});
32
+ const patch = pickPatchableFields(mergedBody, PATCHABLE_TASK_FIELDS);
32
33
  if (patch.type !== undefined) {
33
34
  patch.type = normalizeTaskType(patch.type);
34
35
  const typeError = validateTaskType(patch.type, { ...current, ...patch });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.27",
3
+ "version": "0.2.28",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",