@goodandready/dsh-goal 0.2.24 → 0.2.26

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.
Files changed (2) hide show
  1. package/lib/routes.js +187 -56
  2. package/package.json +1 -1
package/lib/routes.js CHANGED
@@ -8,73 +8,200 @@ import { parseIssueChecklist } from './engine-issue-sync.js';
8
8
 
9
9
  export function isLoopback(value) {
10
10
  if (!value) return false;
11
- const address = String(value).toLowerCase().replace(/^\[|\]$/g, '');
11
+ let address = String(value).toLowerCase().replace(/^\[|\]$/g, '');
12
+ if (address.includes(':')) {
13
+ const colons = address.split(':').length - 1;
14
+ if (colons === 1) address = address.split(':')[0];
15
+ }
16
+ address = address.replace(/^::ffff:/, '');
12
17
  return Boolean(
13
18
  address === 'localhost' ||
14
19
  address === 'localhost.' ||
15
20
  address === '::1' ||
16
- address.startsWith('127.') ||
17
- address.startsWith('::ffff:127.')
21
+ address.startsWith('127.')
18
22
  );
19
23
  }
20
24
 
21
- export function isTrustedCaller(req, authToken = process.env.DSH_GOAL_AUTH_TOKEN) {
22
- if (!req) return false;
23
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || "";
24
- const site = req.headers?.["sec-fetch-site"];
25
+ export function isTrustedHost(val) {
26
+ if (!val || typeof val !== 'string') return false;
27
+ let hostname = val.trim().toLowerCase();
28
+ if (hostname.startsWith('[')) {
29
+ const end = hostname.indexOf(']');
30
+ if (end > 0) hostname = hostname.slice(1, end);
31
+ } else if (hostname.includes(':')) {
32
+ const colons = hostname.split(':').length - 1;
33
+ if (colons === 1) hostname = hostname.split(':')[0];
34
+ }
35
+ const clean = hostname.replace(/^::ffff:/, '');
36
+ if (clean === 'localhost' || clean === 'localhost.' || clean.endsWith('.local') || clean.endsWith('.lan')) return true;
37
+ if (clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.')) return true;
38
+ if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true;
39
+ const match172 = clean.match(/^172\.(\d+)\./);
40
+ if (match172) {
41
+ const octet = parseInt(match172[1], 10);
42
+ if (octet >= 16 && octet <= 31) return true;
43
+ }
44
+ return false;
45
+ }
25
46
 
26
- // 1. Cross-site and same-site browser requests are rejected
27
- if (site === "cross-site" || site === "same-site") return false;
47
+ /**
48
+ * Safely extract DSH connection service from Cordis Context if available (#172).
49
+ * @param {any} ctx
50
+ * @returns {any}
51
+ */
52
+ export function getConnection(ctx) {
53
+ if (!ctx) return undefined;
54
+ try {
55
+ const fromCtx = ctx?.reflect?.get
56
+ ? (ctx.reflect.get('connection') || ctx?.connection)
57
+ : (ctx?.get ? (ctx.get('connection') || ctx?.connection) : Reflect.get(ctx, 'connection'));
58
+ if (fromCtx) return fromCtx;
59
+ if (ctx?.root) {
60
+ return getConnection(ctx.root);
61
+ }
62
+ return undefined;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
28
67
 
29
- // Optional authorization token check (supports x-dsh-auth-token or Authorization: Bearer <token>)
30
- if (authToken) {
31
- const headerVal = req.headers?.["x-dsh-auth-token"] || req.headers?.authorization;
32
- const cleanHeader = headerVal ? String(headerVal).replace(/^Bearer\s+/i, "").trim() : "";
33
- if (cleanHeader === authToken) {
34
- return true;
68
+ function resolveAuthOptions(optionsOrToken, maybeCtx) {
69
+ let authToken = process.env.DSH_GOAL_AUTH_TOKEN;
70
+ let ctx = null;
71
+ let connection = null;
72
+
73
+ if (typeof optionsOrToken === 'string') {
74
+ authToken = optionsOrToken;
75
+ ctx = maybeCtx;
76
+ } else if (optionsOrToken && typeof optionsOrToken === 'object') {
77
+ if (optionsOrToken.authToken !== undefined) authToken = optionsOrToken.authToken;
78
+ if (optionsOrToken.connection !== undefined) connection = optionsOrToken.connection;
79
+ if (optionsOrToken.ctx !== undefined) {
80
+ ctx = optionsOrToken.ctx;
81
+ } else if (optionsOrToken.webServer || optionsOrToken.reflect || optionsOrToken.connectionService) {
82
+ ctx = optionsOrToken;
35
83
  }
36
- // When authToken is explicitly configured, non-loopback callers without valid token are rejected
37
- if (!isLoopback(remote)) {
38
- return false;
84
+ }
85
+ if (!connection && ctx) {
86
+ connection = getConnection(ctx);
87
+ }
88
+ return { authToken, ctx, connection };
89
+ }
90
+
91
+ /**
92
+ * Evaluates whether an incoming HTTP request is trusted and authenticated (#172).
93
+ *
94
+ * Requirements:
95
+ * 1. Strict CSRF guard: rejects sec-fetch-site cross-site or same-site with 403.
96
+ * 2. DNS rebinding & WAN protection: validates Host and remoteAddress against isTrustedHost.
97
+ * 3. Origin & Referer headers must match Host and pass isTrustedHost.
98
+ * 4. Explicit auth token: valid X-DSH-Auth-Token or Authorization: Bearer bypasses session check.
99
+ * 5. Loopback callers (127.0.0.1, ::1) are admitted for local CLI, health checks, and monitoring.
100
+ * 6. Remote LAN callers must be authenticated via DSH connection service (connection.requestRejection).
101
+ * If connection service is absent or returns a rejection code, caller is rejected (fail-closed, 401/403).
102
+ *
103
+ * @param {import('node:http').IncomingMessage} req
104
+ * @param {string|object} [optionsOrToken]
105
+ * @param {any} [maybeCtx]
106
+ * @returns {number|undefined} Rejection HTTP status code (401, 403) or undefined if admitted.
107
+ */
108
+ export function getRequestRejection(req, optionsOrToken = process.env.DSH_GOAL_AUTH_TOKEN, maybeCtx = null) {
109
+ if (!req || !req.headers) return 403;
110
+
111
+ const { authToken, ctx, connection } = resolveAuthOptions(optionsOrToken, maybeCtx);
112
+
113
+ // 1. Cross-site and same-site browser requests are rejected (CSRF guard)
114
+ const site = req.headers['sec-fetch-site'];
115
+ if (site === 'cross-site' || site === 'same-site') return 403;
116
+
117
+ // 2. Host header validation (DNS rebinding guard)
118
+ const host = String(req.headers.host || '').trim().toLowerCase();
119
+ if (host && !isTrustedHost(host)) return 403;
120
+
121
+ // 3. Remote address validation if socket is present
122
+ const rawRemote = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
123
+ const cleanRemote = String(rawRemote).trim().toLowerCase().replace(/^::ffff:/, '');
124
+ if (cleanRemote && !isTrustedHost(cleanRemote)) return 403;
125
+
126
+ // 4. Origin header validation if present
127
+ const origin = req.headers.origin;
128
+ if (origin !== undefined) {
129
+ if (origin === 'null' || !origin) return 403;
130
+ try {
131
+ const u = new URL(origin);
132
+ if (!isTrustedHost(u.hostname)) return 403;
133
+ if (host && u.host.toLowerCase() !== host.toLowerCase()) return 403;
134
+ } catch {
135
+ return 403;
39
136
  }
40
137
  }
41
138
 
42
- // 2. Same-origin browser UI requests (operator UI on LAN or localhost)
43
- if (site === "same-origin") {
44
- const host = String(req.headers?.host || "").toLowerCase();
45
- const origin = String(req.headers?.origin || "").trim().toLowerCase();
46
- if (origin && host) {
47
- try {
48
- const u = new URL(origin);
49
- if (u.host === host) return true;
50
- } catch (err) {
51
- return false;
139
+ // 5. Referer header validation if present
140
+ const referer = req.headers.referer;
141
+ if (referer !== undefined && referer) {
142
+ try {
143
+ const u = new URL(referer);
144
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
145
+ if (!isTrustedHost(u.hostname)) return 403;
146
+ if (host && u.host.toLowerCase() !== host.toLowerCase()) return 403;
147
+ } else {
148
+ const isLocal = isLoopback(cleanRemote) || (host ? isLoopback(host.split(':')[0]) : false);
149
+ if (!isLocal) return 403;
52
150
  }
151
+ } catch {
152
+ return 403;
53
153
  }
54
- const referer = String(req.headers?.referer || "").trim().toLowerCase();
55
- if (referer && host) {
56
- try {
57
- const u = new URL(referer);
58
- if (u.host === host) return true;
59
- } catch (err) {
60
- return false;
61
- }
154
+ }
155
+
156
+ // 6. Explicit auth token check (supports x-dsh-auth-token or Authorization: Bearer <token>)
157
+ if (authToken) {
158
+ const headerVal = req.headers['x-dsh-auth-token'] || req.headers.authorization;
159
+ const cleanHeader = headerVal ? String(headerVal).replace(/^Bearer\s+/i, '').trim() : '';
160
+ if (cleanHeader && cleanHeader === authToken) {
161
+ return undefined;
162
+ }
163
+ if (cleanRemote && !isLoopback(cleanRemote)) {
164
+ return 401;
62
165
  }
63
- return false;
64
166
  }
65
167
 
66
- // 3. No declared site: only loopback remote clients (CLI, reverse proxy) are trusted
67
- if (isLoopback(remote)) return true;
168
+ // 7. Verified loopback callers (local CLI, health checks, deploy.sh verify)
169
+ if (isLoopback(cleanRemote)) {
170
+ return undefined;
171
+ }
68
172
 
69
- // 4. Fallback for test harnesses without remote socket where host header explicitly targets loopback
70
- if (!remote && req.headers?.host) {
71
- const hostName = String(req.headers.host).split(":")[0];
173
+ // Mock test requests without remoteAddress where host targets loopback
174
+ if (!cleanRemote && host) {
175
+ const hostName = host.split(':')[0];
72
176
  if (isLoopback(hostName)) {
73
- return true;
177
+ return undefined;
74
178
  }
75
179
  }
76
180
 
77
- return false;
181
+ // 8. Remote / LAN callers must be authenticated via DSH connection service (#172)
182
+ const conn = connection || (ctx && getConnection(ctx)) || req.connectionService;
183
+ if (conn && typeof conn.requestRejection === 'function') {
184
+ let rejection;
185
+ try {
186
+ rejection = conn.requestRejection(req);
187
+ } catch {
188
+ return 403;
189
+ }
190
+ if (rejection !== undefined) {
191
+ if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
192
+ return rejection;
193
+ }
194
+ return rejection === 401 ? 401 : 403;
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ // 9. Fail-closed: Remote/LAN caller without connection service or valid token is rejected
200
+ return 401;
201
+ }
202
+
203
+ export function isTrustedCaller(req, optionsOrToken = process.env.DSH_GOAL_AUTH_TOKEN, maybeCtx = null) {
204
+ return getRequestRejection(req, optionsOrToken, maybeCtx) === undefined;
78
205
  }
79
206
 
80
207
  export { isPathInsideOrEqual, getSafeCwd };
@@ -179,10 +306,11 @@ export function registerRoutes(ctx, {
179
306
 
180
307
  // GET /dsh-goal/events: Server-Sent Events realtime snapshot stream
181
308
  if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
182
- if (!isTrustedCaller(req)) {
183
- res.statusCode = 403;
309
+ const rejection = getRequestRejection(req, { ctx });
310
+ if (rejection !== undefined) {
311
+ res.statusCode = rejection;
184
312
  if (typeof res.setHeader === 'function') res.setHeader('Content-Type', 'application/json; charset=utf-8');
185
- return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
313
+ return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
186
314
  }
187
315
  const sid = sessionIdOf(req, 'default');
188
316
  if (typeof res.writeHead === 'function') {
@@ -226,9 +354,10 @@ export function registerRoutes(ctx, {
226
354
 
227
355
  // GET /dsh-goal/templates
228
356
  if (req.method === 'GET' && (pathname === '/dsh-goal/templates' || pathname === '/dsh-goal/templates/')) {
229
- if (!isTrustedCaller(req)) {
230
- res.statusCode = 403;
231
- return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
357
+ const rejection = getRequestRejection(req, { ctx });
358
+ if (rejection !== undefined) {
359
+ res.statusCode = rejection;
360
+ return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
232
361
  }
233
362
  res.statusCode = 200;
234
363
  return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
@@ -236,9 +365,10 @@ export function registerRoutes(ctx, {
236
365
 
237
366
  // GET /dsh-goal/state
238
367
  if (req.method === 'GET' && (pathname === '/dsh-goal/state' || pathname === '/dsh-goal/state/')) {
239
- if (!isTrustedCaller(req)) {
240
- res.statusCode = 403;
241
- return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
368
+ const rejection = getRequestRejection(req, { ctx });
369
+ if (rejection !== undefined) {
370
+ res.statusCode = rejection;
371
+ return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
242
372
  }
243
373
  const sid = sessionIdOf(req, 'default');
244
374
  res.statusCode = 200;
@@ -277,9 +407,10 @@ export function registerRoutes(ctx, {
277
407
  }
278
408
  }
279
409
 
280
- if (!isTrustedCaller(req)) {
281
- res.statusCode = 403;
282
- return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
410
+ const rejection = getRequestRejection(req, { ctx });
411
+ if (rejection !== undefined) {
412
+ res.statusCode = rejection;
413
+ return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
283
414
  }
284
415
 
285
416
  let body = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "Core Goal Addon (Token Budget, Milestones, Git Snapshots & Retrospective Reports) for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",