@goodandready/dsh-cron 0.3.11 → 0.3.12
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 +2 -1
- package/README.ru.md +2 -1
- package/README.zh.md +11 -0
- package/lib/api-helpers.js +9 -9
- package/lib/api-task-webhook.js +4 -4
- package/lib/api.js +18 -16
- package/lib/http-utils.js +116 -35
- package/lib/index.js +2 -2
- package/lib/routes.js +19 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -625,7 +625,8 @@ Connect to `GET /dsh-cron/tasks/:id/stream` using Server-Sent Events (SSE) to re
|
|
|
625
625
|
|
|
626
626
|
Execute tasks instantaneously on external events via incoming HTTP POST requests with enterprise-grade security and dynamic template interpolation:
|
|
627
627
|
|
|
628
|
-
- **Authentication & Verification**: Supports HMAC SHA-256 signatures (`x-hub-signature-256` or `x-signature-sha256`) using task-level `webhookSecret`, bearer tokens (`x-dsh-cron-webhook-token`, `Authorization: Bearer <token
|
|
628
|
+
- **Authentication & Verification**: Supports HMAC SHA-256 signatures (`x-hub-signature-256` or `x-signature-sha256`) using task-level `webhookSecret`, bearer tokens passed via headers (`x-dsh-cron-webhook-token`, `x-webhook-token`, `Authorization: Bearer <token>`) against task `webhookToken` or master `apiToken`. Query parameter token authentication (`?token=`) is strictly rejected to prevent secret leakage in proxy logs and browser history (#358). Fail-closed security rejects unconfigured webhooks.
|
|
629
|
+
- **Fail-Closed API Authentication**: All REST routes verify callers via DSH `ctx.connection.requestRejection(req)`, token verification, and loopback verification. Requests without socket/remoteAddress and without valid authentication are strictly rejected with 401/403, preventing remote unauthenticated state modification and RCE (#357).
|
|
629
630
|
- **Deep JSON Payload Interpolation**: Dynamic prompt variables resolve incoming payload fields using dot notation (`{webhook.body.repository.full_name}`, `{webhook.headers.x_github_event}`, `{webhook.query.ref}`, `{webhook.body}`).
|
|
630
631
|
- **Script Environment Injection**: Script and shell tasks automatically receive the raw request body and headers in `DSH_CRON_WEBHOOK_BODY` and `DSH_CRON_WEBHOOK_HEADERS`.
|
|
631
632
|
- **Anti-Spam Debouncing**: Configurable per-task `webhookDebounceMs` (default 500ms) guarantees protection against webhook storms and burst duplicate executions.
|
package/README.ru.md
CHANGED
|
@@ -577,7 +577,8 @@ bash deploy.sh verify [exact-version]
|
|
|
577
577
|
### 32. Входящие HTTP Webhook триггеры и естественно-языковой парсер расписаний (v0.3.11, #348, #349)
|
|
578
578
|
|
|
579
579
|
- **Входящие Webhook триггеры (`/dsh-cron/webhook/:id`)**: Моментальный запуск задач по внешним событиям (GitHub, Gitea, CI/CD, сторонние сервисы).
|
|
580
|
-
- **Аутентификация и безопасность**: Поддержка HMAC SHA-256 подписи (`x-hub-signature-256`, `x-signature-sha256`) через `webhookSecret`, индивидуальных токенов задачи (`webhookToken`) через `x-dsh-cron-webhook-token`,
|
|
580
|
+
- **Аутентификация и безопасность**: Поддержка HMAC SHA-256 подписи (`x-hub-signature-256`, `x-signature-sha256`) через `webhookSecret`, индивидуальных токенов задачи (`webhookToken`) исключительно через заголовки (`x-dsh-cron-webhook-token`, `x-webhook-token`, `Authorization: Bearer`), а также мастер-токена `apiToken`. Приём токенов из query-параметров (`?token=`) строго заблокирован для предотвращения утечки в логи прокси и историю браузера (#358).
|
|
581
|
+
- **Fail-closed защита API и RCE Guard (#357)**: Все маршруты плагина проверяются через DSH `ctx.connection.requestRejection(req)` для допуска браузерных сессий DSH, а также требуют валидный Bearer-токен или локальный loopback адрес. Запросы без валидной аутентификации отсекаются с 401/403 до парсинга тела, предотвращая несанкционированное создание задач и удалённое выполнение кода.
|
|
581
582
|
- **Глубокая интерполяция payload**: Шаблоны `{webhook.body.path.to.key}`, `{webhook.headers.key}`, `{webhook.query.param}` и `{webhook.body}` автоматически раскрываются в промпте задачи.
|
|
582
583
|
- **Проброс в окружение**: Скриптовым и shell-задачам передаются переменные `DSH_CRON_WEBHOOK_BODY` и `DSH_CRON_WEBHOOK_HEADERS`.
|
|
583
584
|
- **Дебаунс от штормов**: Настраиваемый `webhookDebounceMs` (по умолчанию 500 мс) защищает от дублирующих всплесков событий.
|
package/README.zh.md
CHANGED
|
@@ -574,6 +574,17 @@ dsh-cron:
|
|
|
574
574
|
|
|
575
575
|
---
|
|
576
576
|
|
|
577
|
+
### 32. 入站 HTTP Webhook 与接口鉴权加固 (v0.3.12, #348, #357, #358)
|
|
578
|
+
|
|
579
|
+
- **入站 HTTP Webhook 触发器 (`/dsh-cron/webhook/:id`)**:外部事件(GitHub、Gitea、CI/CD 及第三方服务)实时触发任务执行。
|
|
580
|
+
- **安全鉴权**:支持基于任务级 `webhookSecret` 的 HMAC SHA-256 签名(`x-hub-signature-256`、`x-signature-sha256`)、任务级 `webhookToken`(仅限标头:`x-dsh-cron-webhook-token`、`x-webhook-token`、`Authorization: Bearer`)以及全局 `apiToken` 鉴权。严格禁止从 URL 查询参数(`?token=`)传递令牌以防止日志泄露(#358)。
|
|
581
|
+
- **深度 JSON 负载插值**:提示词中通过 `{webhook.body.path.to.key}`、`{webhook.headers.key}`、`{webhook.query.param}` 及 `{webhook.body}` 自动解构提取入站数据。
|
|
582
|
+
- **环境注入**:脚本与 shell 任务自动注入 `DSH_CRON_WEBHOOK_BODY` 和 `DSH_CRON_WEBHOOK_HEADERS` 环境变量。
|
|
583
|
+
- **防抖保护**:可配置 `webhookDebounceMs`(默认 500ms)有效防止 Webhook 事件风暴与瞬态重复触发。
|
|
584
|
+
- **Fail-Closed 接口鉴权与 RCE 防御(#357)**:所有 REST 端点通过 DSH `ctx.connection.requestRejection(req)` 校验会话权限,并对无有效令牌及非本地回环地址的请求实行 Fail-closed 严格拦截(401/403),杜绝未授权远程任务创建与代码执行。
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
577
588
|
## 🧪 测试与预检门禁 (Preflight)
|
|
578
589
|
|
|
579
590
|
运行全套自动化测试(调度表达式解析、调度器引擎、原子存储、HTTP 辅助函数、通知与工具契约):
|
package/lib/api-helpers.js
CHANGED
|
@@ -4,13 +4,13 @@ import { sendJson, readBody, rejectCrossOrigin } from './http-utils.js';
|
|
|
4
4
|
|
|
5
5
|
const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
|
|
6
6
|
|
|
7
|
-
export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explicitToken }) {
|
|
7
|
+
export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explicitToken, ctx }) {
|
|
8
8
|
if (req.method !== 'POST') {
|
|
9
9
|
sendJson(res, 405, NOT_ALLOWED);
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
12
|
const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
|
|
13
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
13
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
14
14
|
|
|
15
15
|
const task = store.get(taskId);
|
|
16
16
|
if (!task) {
|
|
@@ -25,9 +25,9 @@ export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explici
|
|
|
25
25
|
sendJson(res, 200, result);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export async function handleSchedulePreview({ req, res, url, store, apiToken: explicitToken }) {
|
|
28
|
+
export async function handleSchedulePreview({ req, res, url, store, apiToken: explicitToken, ctx }) {
|
|
29
29
|
const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
|
|
30
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
30
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
31
31
|
|
|
32
32
|
let schedule = '';
|
|
33
33
|
let timezone = '';
|
|
@@ -61,9 +61,9 @@ export async function handleSchedulePreview({ req, res, url, store, apiToken: ex
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export async function handleDryRunTask({ store, scheduler, req, res, id }) {
|
|
65
|
-
const apiToken = store?.getSettings?.()?.apiToken;
|
|
66
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
64
|
+
export async function handleDryRunTask({ store, scheduler, req, res, id, apiToken: explicitToken, ctx }) {
|
|
65
|
+
const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
|
|
66
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
67
67
|
const task = store.get(id);
|
|
68
68
|
if (!task) {
|
|
69
69
|
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
@@ -78,9 +78,9 @@ export async function handleDryRunTask({ store, scheduler, req, res, id }) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export async function handleScheduleTimeline({ req, res, url, store, scheduler, apiToken: explicitToken }) {
|
|
81
|
+
export async function handleScheduleTimeline({ req, res, url, store, scheduler, apiToken: explicitToken, ctx }) {
|
|
82
82
|
const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
|
|
83
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
83
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
84
84
|
|
|
85
85
|
if (req.method !== 'GET') {
|
|
86
86
|
sendJson(res, 405, NOT_ALLOWED);
|
package/lib/api-task-webhook.js
CHANGED
|
@@ -38,19 +38,19 @@ export function verifyHmacSignature(rawBuffer, secret, signatureHeader) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Verify token from header
|
|
41
|
+
* Verify token from header or Bearer auth.
|
|
42
|
+
* Query parameter authentication is strictly rejected to prevent token leakage in proxy logs and browser history (#358).
|
|
42
43
|
*/
|
|
43
44
|
export function verifyToken(expectedToken, req, url) {
|
|
44
45
|
if (!expectedToken) return false;
|
|
45
|
-
const headers = req
|
|
46
|
+
const headers = req?.headers || {};
|
|
46
47
|
const headerToken = headers['x-dsh-cron-webhook-token'] || headers['x-webhook-token'];
|
|
47
48
|
const authHeader = headers['authorization'];
|
|
48
49
|
let bearerToken = '';
|
|
49
50
|
if (authHeader && /^Bearer\s+/i.test(authHeader)) {
|
|
50
51
|
bearerToken = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
51
52
|
}
|
|
52
|
-
const
|
|
53
|
-
const candidate = headerToken || bearerToken || queryToken;
|
|
53
|
+
const candidate = headerToken || bearerToken;
|
|
54
54
|
if (!candidate) return false;
|
|
55
55
|
return safeCompare(expectedToken, candidate);
|
|
56
56
|
}
|
package/lib/api.js
CHANGED
|
@@ -71,9 +71,11 @@ function refuseConfigOwned(res, id) {
|
|
|
71
71
|
* deeper path unrouted (#96). Exported for unit tests.
|
|
72
72
|
*/
|
|
73
73
|
export function createCronApiHandler(store, scheduler, collection) {
|
|
74
|
+
const ctx = collection?.ctx;
|
|
74
75
|
return async function handleCronApi(req, res) {
|
|
75
76
|
try {
|
|
76
77
|
const apiToken = store?.getSettings?.()?.apiToken;
|
|
78
|
+
const authOptions = { apiToken, ctx };
|
|
77
79
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
78
80
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
79
81
|
const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
|
|
@@ -90,25 +92,25 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
if ((scope === 'heartbeat' || scope === 'heartbeat-ping') && id) {
|
|
93
|
-
handleHeartbeatPing({ store, req, res, taskId: id });
|
|
95
|
+
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
94
96
|
return;
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
if (scope === 'schedule' && id === 'preview') {
|
|
98
|
-
await handleSchedulePreview({ req, res, url, store });
|
|
100
|
+
await handleSchedulePreview({ req, res, url, store, apiToken, ctx });
|
|
99
101
|
return;
|
|
100
102
|
}
|
|
101
103
|
if (scope === 'schedule' && id === 'timeline-24h') {
|
|
102
|
-
await handleScheduleTimeline({ req, res, url, store, scheduler });
|
|
104
|
+
await handleScheduleTimeline({ req, res, url, store, scheduler, apiToken, ctx });
|
|
103
105
|
return;
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
if (!id) {
|
|
107
|
-
await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken });
|
|
109
|
+
await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx });
|
|
108
110
|
return;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
113
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
112
114
|
|
|
113
115
|
// Export/import are collection operations that live under /tasks/:id/…,
|
|
114
116
|
// so they are matched before the generic id handling below.
|
|
@@ -129,7 +131,7 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
129
131
|
return;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
await handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken });
|
|
134
|
+
await handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken, ctx });
|
|
133
135
|
} catch (err) {
|
|
134
136
|
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
135
137
|
}
|
|
@@ -138,19 +140,19 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
138
140
|
|
|
139
141
|
// ------------------------------------------------------------- collection
|
|
140
142
|
|
|
141
|
-
async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken }) {
|
|
143
|
+
async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx }) {
|
|
142
144
|
// The alias family only exists for individual tasks.
|
|
143
145
|
if (scope === 'action' || !collection) {
|
|
144
146
|
sendJson(res, 405, NOT_ALLOWED);
|
|
145
147
|
return;
|
|
146
148
|
}
|
|
147
149
|
if (req.method === 'GET') {
|
|
148
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
150
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
149
151
|
listTasks({ store, scheduler, collection, req, res, url });
|
|
150
152
|
return;
|
|
151
153
|
}
|
|
152
154
|
if (req.method === 'POST') {
|
|
153
|
-
await createOrUpdateTask({ store, scheduler, req, res, apiToken });
|
|
155
|
+
await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
|
|
154
156
|
return;
|
|
155
157
|
}
|
|
156
158
|
sendJson(res, 405, NOT_ALLOWED);
|
|
@@ -187,8 +189,8 @@ function listTasks({ store, scheduler, collection, req, res, url }) {
|
|
|
187
189
|
sendJson(res, 200, payload);
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
|
|
191
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
192
|
+
async function createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx }) {
|
|
193
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
192
194
|
const { body: rawBody, error } = await readBody(req, res);
|
|
193
195
|
if (error) return;
|
|
194
196
|
const existing = rawBody.id ? store.get(rawBody.id) : null;
|
|
@@ -291,14 +293,14 @@ function buildTaskRecord(store, body, taskType, parsed) {
|
|
|
291
293
|
|
|
292
294
|
// ------------------------------------------------------------ single task
|
|
293
295
|
|
|
294
|
-
async function handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken }) {
|
|
296
|
+
async function handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken, ctx }) {
|
|
295
297
|
if (action === 'heartbeat') {
|
|
296
|
-
handleHeartbeatPing({ store, req, res, taskId: id });
|
|
298
|
+
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
297
299
|
return;
|
|
298
300
|
}
|
|
299
301
|
|
|
300
302
|
if (req.method === 'POST' && action === 'dry-run') {
|
|
301
|
-
await handleDryRunTask({ store, scheduler, req, res, id });
|
|
303
|
+
await handleDryRunTask({ store, scheduler, req, res, id, apiToken, ctx });
|
|
302
304
|
return;
|
|
303
305
|
}
|
|
304
306
|
|
|
@@ -307,7 +309,7 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
|
|
|
307
309
|
logStreamer.handleSseRequest(id, req, res);
|
|
308
310
|
return;
|
|
309
311
|
}
|
|
310
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
312
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
311
313
|
if (action === 'archive') {
|
|
312
314
|
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
313
315
|
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
@@ -706,7 +708,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
|
|
|
706
708
|
return;
|
|
707
709
|
}
|
|
708
710
|
if (req.method === 'POST') {
|
|
709
|
-
await createOrUpdateTask({ store, scheduler, req, res, apiToken });
|
|
711
|
+
await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
|
|
710
712
|
return;
|
|
711
713
|
}
|
|
712
714
|
sendJson(res, 405, NOT_ALLOWED);
|
package/lib/http-utils.js
CHANGED
|
@@ -77,65 +77,142 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
*
|
|
81
|
-
|
|
80
|
+
* Safe helper to get the DSH connection service from context or request (#357).
|
|
81
|
+
*/
|
|
82
|
+
export function getConnection(ctx) {
|
|
83
|
+
if (!ctx) return null;
|
|
84
|
+
return Reflect.get(ctx, 'connection') || ctx.connection || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getDshConnection(req, options = {}) {
|
|
88
|
+
if (options.connection) return options.connection;
|
|
89
|
+
if (options.ctx) {
|
|
90
|
+
const conn = getConnection(options.ctx);
|
|
91
|
+
if (conn) return conn;
|
|
92
|
+
}
|
|
93
|
+
if (req) {
|
|
94
|
+
if (req.connectionService) return req.connectionService;
|
|
95
|
+
if (req.ctx) {
|
|
96
|
+
const conn = getConnection(req.ctx);
|
|
97
|
+
if (conn) return conn;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Determine if an incoming request should be rejected, and with which HTTP status (401 or 403).
|
|
105
|
+
* Returns undefined if the request is admitted, or 401 / 403 if rejected (#357).
|
|
82
106
|
*
|
|
83
107
|
* Rules:
|
|
84
|
-
* 1.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
108
|
+
* 1. If DSH connection service is available, consult its admission first.
|
|
109
|
+
* - Connection service rejection 403 (untrusted Host / Origin / CSRF) is strictly rejected.
|
|
110
|
+
* - Connection service admission (undefined rejection) admits the request (browser session / Peer).
|
|
111
|
+
* 2. Token authentication: valid Bearer token matching options.apiToken, DSH_AUTH_TOKEN or DSH_TOKEN.
|
|
112
|
+
* 3. Local loopback caller: 127.0.0.1 or ::1 when connection service is not bound (standalone/CLI/test).
|
|
113
|
+
* 4. FAIL-CLOSED: if connection service is bound, request must have DSH admission or valid API token.
|
|
114
|
+
* If connection service is not bound, request must have valid API token or verified loopback IP.
|
|
115
|
+
* Requests with missing socket / missing remoteAddress and no token are rejected (401).
|
|
116
|
+
* 5. Origin and Sec-Fetch-Site: cannot be null, Origin host must match Host, Sec-Fetch-Site must be same-origin or none.
|
|
90
117
|
*/
|
|
91
|
-
export function
|
|
92
|
-
if (!req) return
|
|
118
|
+
export function getRequestRejection(req, options = {}) {
|
|
119
|
+
if (!req) return 403;
|
|
120
|
+
|
|
121
|
+
// 1. Validate Origin header if present: cannot be null, must match Host (CSRF guard)
|
|
93
122
|
const headers = req.headers || {};
|
|
123
|
+
const origin = headers.origin;
|
|
124
|
+
const host = headers.host || '';
|
|
125
|
+
if (origin !== undefined) {
|
|
126
|
+
if (origin === 'null' || !origin) return 403;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = new URL(origin);
|
|
129
|
+
if (!parsed.host || !host || parsed.host !== host) return 403;
|
|
130
|
+
} catch {
|
|
131
|
+
return 403;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
94
134
|
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
135
|
+
// 2. Validate Sec-Fetch-Site header if present
|
|
136
|
+
const site = headers['sec-fetch-site'];
|
|
137
|
+
if (site !== undefined) {
|
|
138
|
+
if (site !== 'same-origin' && site !== 'none') {
|
|
139
|
+
return 403;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
98
142
|
|
|
143
|
+
// 3. Check DSH connection service if available (#357)
|
|
144
|
+
const conn = getDshConnection(req, options);
|
|
145
|
+
let connRejection;
|
|
146
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
147
|
+
connRejection = conn.requestRejection(req);
|
|
148
|
+
if (connRejection === 403) {
|
|
149
|
+
return 403;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 4. Check token authentication
|
|
99
154
|
const token = extractBearerToken(req);
|
|
100
155
|
const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
|
|
101
156
|
const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
|
|
102
157
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
158
|
+
// If DSH connection service admitted the request (undefined rejection)
|
|
159
|
+
const isConnectionAdmitted = Boolean(conn && connRejection === undefined);
|
|
160
|
+
|
|
161
|
+
// Check loopback address
|
|
162
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
163
|
+
const hasRemote = Boolean(remote && typeof remote === 'string');
|
|
164
|
+
const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
|
|
165
|
+
|
|
166
|
+
// FAIL-CLOSED AUTHENTICATION CHECK (#357):
|
|
167
|
+
if (conn) {
|
|
168
|
+
if (!isConnectionAdmitted && !isTokenAuthenticated) {
|
|
169
|
+
return connRejection === 403 ? 403 : 401;
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
if (!isTokenAuthenticated && !isLoopback) {
|
|
173
|
+
if (token || !hasRemote) {
|
|
174
|
+
return 401;
|
|
175
|
+
}
|
|
176
|
+
return 403;
|
|
107
177
|
}
|
|
108
178
|
}
|
|
109
179
|
|
|
110
|
-
|
|
180
|
+
return undefined; // Admitted
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate that an incoming request originates from a trusted local caller,
|
|
185
|
+
* an admitted DSH browser session, or an authenticated client (Issue #86, #357).
|
|
186
|
+
*/
|
|
187
|
+
export function isTrustedRequest(req, options = {}) {
|
|
188
|
+
return getRequestRejection(req, options) === undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Backward-compatible helper for checking cross-origin browser headers (Origin / Sec-Fetch-Site).
|
|
193
|
+
*/
|
|
194
|
+
export function isCrossOrigin(req, options) {
|
|
195
|
+
if (!req) return true;
|
|
196
|
+
const headers = req.headers || {};
|
|
111
197
|
const origin = headers.origin;
|
|
112
198
|
const host = headers.host || '';
|
|
113
199
|
if (origin !== undefined) {
|
|
114
|
-
if (origin === 'null' || !origin) return
|
|
200
|
+
if (origin === 'null' || !origin) return true;
|
|
115
201
|
try {
|
|
116
202
|
const parsed = new URL(origin);
|
|
117
|
-
if (!parsed.host || !host || parsed.host !== host) return
|
|
203
|
+
if (!parsed.host || !host || parsed.host !== host) return true;
|
|
118
204
|
} catch {
|
|
119
|
-
return
|
|
205
|
+
return true;
|
|
120
206
|
}
|
|
121
207
|
}
|
|
122
208
|
|
|
123
|
-
// 3. Validate Sec-Fetch-Site header if present
|
|
124
209
|
const site = headers['sec-fetch-site'];
|
|
125
210
|
if (site !== undefined) {
|
|
126
211
|
if (site !== 'same-origin' && site !== 'none') {
|
|
127
|
-
return
|
|
212
|
+
return true;
|
|
128
213
|
}
|
|
129
214
|
}
|
|
130
|
-
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Backward-compatible wrapper for checking cross-origin / untrusted requests.
|
|
136
|
-
*/
|
|
137
|
-
export function isCrossOrigin(req, options) {
|
|
138
|
-
return !isTrustedRequest(req, options);
|
|
215
|
+
return false;
|
|
139
216
|
}
|
|
140
217
|
|
|
141
218
|
/**
|
|
@@ -158,9 +235,13 @@ export function sendJson(res, statusCode, data) {
|
|
|
158
235
|
res.end(JSON.stringify(data));
|
|
159
236
|
}
|
|
160
237
|
|
|
161
|
-
export function rejectCrossOrigin(req, res, options) {
|
|
162
|
-
|
|
163
|
-
|
|
238
|
+
export function rejectCrossOrigin(req, res, options = {}) {
|
|
239
|
+
const rejection = getRequestRejection(req, options);
|
|
240
|
+
if (rejection !== undefined) {
|
|
241
|
+
const errorMsg = rejection === 401
|
|
242
|
+
? 'Unauthorized: authentication required'
|
|
243
|
+
: 'Forbidden: untrusted or cross-origin request';
|
|
244
|
+
sendJson(res, rejection, { ok: false, error: errorMsg });
|
|
164
245
|
return true;
|
|
165
246
|
}
|
|
166
247
|
return false;
|
package/lib/index.js
CHANGED
|
@@ -307,7 +307,7 @@ export function apply(ctx, rawConfig) {
|
|
|
307
307
|
try {
|
|
308
308
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
309
309
|
const apiToken = settings.apiToken;
|
|
310
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
310
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
311
311
|
|
|
312
312
|
if (req.method === 'GET') {
|
|
313
313
|
const unwrapped = {
|
|
@@ -324,7 +324,7 @@ export function apply(ctx, rawConfig) {
|
|
|
324
324
|
return;
|
|
325
325
|
}
|
|
326
326
|
if (req.method === 'POST') {
|
|
327
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
327
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
328
328
|
const { body, error } = await readBody(req, res);
|
|
329
329
|
if (error) return;
|
|
330
330
|
const payload = sanitizeSettingsPayload(body);
|
package/lib/routes.js
CHANGED
|
@@ -20,20 +20,20 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
20
20
|
ctx.effect(() => ctx.webServer.register({
|
|
21
21
|
kind: 'prefix',
|
|
22
22
|
path: '/dsh-cron/tasks',
|
|
23
|
-
handler: createCronApiHandler(store, scheduler, { recommendations })
|
|
23
|
+
handler: createCronApiHandler(store, scheduler, { recommendations, ctx })
|
|
24
24
|
}), 'dsh-cron: /tasks[...]');
|
|
25
25
|
|
|
26
26
|
// Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
|
|
27
27
|
ctx.effect(() => ctx.webServer.register({
|
|
28
28
|
kind: 'prefix',
|
|
29
29
|
path: '/dsh-cron/action',
|
|
30
|
-
handler: createCronApiHandler(store, scheduler,
|
|
30
|
+
handler: createCronApiHandler(store, scheduler, { ctx })
|
|
31
31
|
}), 'dsh-cron: /action/:id/:action');
|
|
32
32
|
|
|
33
33
|
ctx.effect(() => ctx.webServer.register({
|
|
34
34
|
kind: 'prefix',
|
|
35
35
|
path: '/dsh-cron/telegram',
|
|
36
|
-
handler: createCronApiHandler(store, scheduler,
|
|
36
|
+
handler: createCronApiHandler(store, scheduler, { ctx })
|
|
37
37
|
}), 'dsh-cron: /telegram[...]');
|
|
38
38
|
|
|
39
39
|
// Incoming HTTP Webhook triggers (#348)
|
|
@@ -46,13 +46,13 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
46
46
|
ctx.effect(() => ctx.webServer.register({
|
|
47
47
|
kind: 'prefix',
|
|
48
48
|
path: '/dsh-cron/heartbeat-ping',
|
|
49
|
-
handler: createCronApiHandler(store, scheduler,
|
|
49
|
+
handler: createCronApiHandler(store, scheduler, { ctx })
|
|
50
50
|
}), 'dsh-cron: /heartbeat-ping[...]');
|
|
51
51
|
|
|
52
52
|
ctx.effect(() => ctx.webServer.register({
|
|
53
53
|
kind: 'prefix',
|
|
54
54
|
path: '/dsh-cron/schedule',
|
|
55
|
-
handler: createCronApiHandler(store, scheduler,
|
|
55
|
+
handler: createCronApiHandler(store, scheduler, { ctx })
|
|
56
56
|
}), 'dsh-cron: /schedule[...]');
|
|
57
57
|
|
|
58
58
|
|
|
@@ -60,14 +60,14 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
60
60
|
ctx.effect(() => ctx.webServer.register({
|
|
61
61
|
kind: 'prefix',
|
|
62
62
|
path: '/dsh-cron/chains',
|
|
63
|
-
handler: (req, res) => handleChainRoutes(store, scheduler, req, res)
|
|
63
|
+
handler: (req, res) => handleChainRoutes(store, scheduler, req, res, ctx)
|
|
64
64
|
}), 'dsh-cron: /chains[...]');
|
|
65
65
|
|
|
66
66
|
// Run artifacts listing, download and safe preview (#321)
|
|
67
67
|
ctx.effect(() => ctx.webServer.register({
|
|
68
68
|
kind: 'prefix',
|
|
69
69
|
path: '/dsh-cron/runs',
|
|
70
|
-
handler: (req, res) => handleRunArtifactRoutes(store, req, res)
|
|
70
|
+
handler: (req, res) => handleRunArtifactRoutes(store, req, res, ctx)
|
|
71
71
|
}), 'dsh-cron: /runs[...]');
|
|
72
72
|
|
|
73
73
|
// GET /dsh-cron/metrics (#53) — Prometheus text exposition
|
|
@@ -98,7 +98,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
98
98
|
handler: async (req, res) => {
|
|
99
99
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
100
100
|
const apiToken = settings.apiToken;
|
|
101
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
101
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
102
102
|
await getModelsHandler(ctx, req, res, sendJson);
|
|
103
103
|
}
|
|
104
104
|
}), 'dsh-cron: /models');
|
|
@@ -108,7 +108,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
108
108
|
kind: 'exact',
|
|
109
109
|
path: '/dsh-cron/chat/start',
|
|
110
110
|
handler: async (req, res) => {
|
|
111
|
-
if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
|
|
111
|
+
if (req.method === 'POST' && rejectCrossOrigin(req, res, { ctx })) return;
|
|
112
112
|
await chatStartHandler(ctx, req, res, parseJsonBody, sendJson, randomUUID);
|
|
113
113
|
}
|
|
114
114
|
}), 'dsh-cron: /chat/start');
|
|
@@ -121,7 +121,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
121
121
|
try {
|
|
122
122
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
123
123
|
const apiToken = settings.apiToken;
|
|
124
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
124
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
125
125
|
|
|
126
126
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
127
127
|
const path = url.pathname;
|
|
@@ -169,7 +169,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
if (req.method === 'POST') {
|
|
172
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
172
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
173
173
|
const { body } = await readBody(req, res);
|
|
174
174
|
const days = parseInt(body?.days || url.searchParams.get('days') || '7', 10);
|
|
175
175
|
const tasks = store.list ? store.list() : Object.values(store.tasks || {});
|
|
@@ -227,7 +227,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
227
227
|
}
|
|
228
228
|
const settings = store.getSettings ? store.getSettings() : {};
|
|
229
229
|
const apiToken = settings.apiToken;
|
|
230
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
230
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
231
231
|
const categories = recipesByCategory();
|
|
232
232
|
const recipes = listVerifiedRecipes(settings);
|
|
233
233
|
sendJson(res, 200, { ok: true, categories, recipes });
|
|
@@ -249,7 +249,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
249
249
|
}
|
|
250
250
|
const settings = store.getSettings ? store.getSettings() : {};
|
|
251
251
|
const apiToken = settings.apiToken;
|
|
252
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
252
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
253
253
|
|
|
254
254
|
const tasks = store.list({ status: 'active' });
|
|
255
255
|
const lastRuns = tasks.map((t) => t.lastRunAt || 0);
|
|
@@ -276,7 +276,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
276
276
|
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
277
277
|
return;
|
|
278
278
|
}
|
|
279
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
279
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
280
280
|
const { body, error } = await readBody(req, res);
|
|
281
281
|
if (error) return;
|
|
282
282
|
const settings = store.getSettings();
|
|
@@ -308,7 +308,7 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
308
308
|
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
309
309
|
return;
|
|
310
310
|
}
|
|
311
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
311
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
312
312
|
const { body, error } = await readBody(req, res);
|
|
313
313
|
if (error) return;
|
|
314
314
|
const settings = store.getSettings();
|
|
@@ -339,10 +339,10 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
339
339
|
/**
|
|
340
340
|
* Handler for chain step retries (#319)
|
|
341
341
|
*/
|
|
342
|
-
export async function handleChainRoutes(store, scheduler, req, res) {
|
|
342
|
+
export async function handleChainRoutes(store, scheduler, req, res, ctx) {
|
|
343
343
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
344
344
|
const apiToken = settings.apiToken;
|
|
345
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
345
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
346
346
|
const url = new URL(req.url, 'http://localhost');
|
|
347
347
|
|
|
348
348
|
// GET /dsh-cron/chains/:chainRunId (#334)
|
|
@@ -386,7 +386,7 @@ export async function handleChainRoutes(store, scheduler, req, res) {
|
|
|
386
386
|
/**
|
|
387
387
|
* Handler for run artifacts (#321)
|
|
388
388
|
*/
|
|
389
|
-
export async function handleRunArtifactRoutes(store, req, res) {
|
|
389
|
+
export async function handleRunArtifactRoutes(store, req, res, ctx) {
|
|
390
390
|
const url = new URL(req.url, 'http://localhost');
|
|
391
391
|
const m = url.pathname.match(/^\/dsh-cron\/runs\/([^\/]+)\/artifacts(?:\/([^\/]+)(?:\/(preview))?)?$/);
|
|
392
392
|
if (!m) {
|
|
@@ -399,7 +399,7 @@ export async function handleRunArtifactRoutes(store, req, res) {
|
|
|
399
399
|
}
|
|
400
400
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
401
401
|
const apiToken = settings.apiToken;
|
|
402
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
402
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
403
403
|
|
|
404
404
|
const [, runId, artifactId, action] = m;
|
|
405
405
|
if (!artifactId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|