@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 +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +1 -1
- package/lib/api-helpers.js +13 -3
- package/lib/api.js +65 -42
- package/lib/http-utils.js +186 -16
- package/lib/task-patch.js +3 -2
- package/package.json +1 -1
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/`.
|
|
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/`.
|
|
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/`
|
|
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
|
|:---|:---|:---|
|
package/lib/api-helpers.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
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'
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
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
|
|
112
|
+
if (origin === 'null' || !origin) return false;
|
|
61
113
|
try {
|
|
62
114
|
const parsed = new URL(origin);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
71
|
-
|
|
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 (
|
|
96
|
-
sendJson(res, 403, { ok: false, error: '
|
|
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
|
-
/**
|
|
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
|
|
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 });
|