@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 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>`, or `?token=<token>`) against task `webhookToken` or master `apiToken`. Fail-closed security rejects unconfigured webhooks.
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`, Bearer или query-параметр, а также мастер-токена `apiToken`. Fail-closed архитектура отклоняет запросы к незащищенным задачам.
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 辅助函数、通知与工具契约):
@@ -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);
@@ -38,19 +38,19 @@ export function verifyHmacSignature(rawBuffer, secret, signatureHeader) {
38
38
  }
39
39
 
40
40
  /**
41
- * Verify token from header, Bearer auth, or query param.
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.headers || {};
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 queryToken = url?.searchParams?.get('token');
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
- * Validate that an incoming request originates from a trusted local caller or
81
- * an authenticated client (Issue #86).
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. 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).
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 isTrustedRequest(req, options = {}) {
92
- if (!req) return false;
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
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
96
- const hasRemote = Boolean(remote && typeof remote === 'string');
97
- const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
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
- // 1. Non-loopback socket callers (or callers with missing remoteAddress) require explicit token authentication (#86, #295)
104
- if (req.socket || req.connection || hasRemote) {
105
- if (!isLoopback && !isTokenAuthenticated) {
106
- return false;
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
- // 2. Validate Origin header if present: cannot be null, must match Host
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 false;
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 false;
203
+ if (!parsed.host || !host || parsed.host !== host) return true;
118
204
  } catch {
119
- return false;
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 false;
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
- if (!isTrustedRequest(req, options)) {
163
- sendJson(res, 403, { ok: false, error: 'Forbidden: untrusted or cross-origin request' });
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, null)
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, null)
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, null)
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, null)
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.11",
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",