@goodandready/dsh-goal 0.2.25 → 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 +156 -64
  2. package/package.json +1 -1
package/lib/routes.js CHANGED
@@ -44,76 +44,164 @@ export function isTrustedHost(val) {
44
44
  return false;
45
45
  }
46
46
 
47
- export function isTrustedCaller(req, authToken = process.env.DSH_GOAL_AUTH_TOKEN) {
48
- if (!req) return false;
49
- const rawRemote = req.socket?.remoteAddress || req.connection?.remoteAddress || "";
50
- const cleanRemote = String(rawRemote).trim().toLowerCase().replace(/^::ffff:/, '');
51
- const site = req.headers?.["sec-fetch-site"];
52
-
53
- // 1. Cross-site and same-site browser requests are rejected
54
- if (site === "cross-site" || site === "same-site") return false;
55
-
56
- // 2. If remoteAddress is present, it must be a trusted local/private IP (never untrusted WAN)
57
- if (cleanRemote && !isTrustedHost(cleanRemote)) {
58
- 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;
59
65
  }
66
+ }
60
67
 
61
- // 3. If Host header is present, it must be a trusted local/private host (DNS rebinding guard)
62
- const host = String(req.headers?.host || "").trim().toLowerCase();
63
- if (host && !isTrustedHost(host)) {
64
- return false;
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;
83
+ }
65
84
  }
85
+ if (!connection && ctx) {
86
+ connection = getConnection(ctx);
87
+ }
88
+ return { authToken, ctx, connection };
89
+ }
66
90
 
67
- // 4. Optional authorization token check (supports x-dsh-auth-token or Authorization: Bearer <token>)
68
- if (authToken) {
69
- const headerVal = req.headers?.["x-dsh-auth-token"] || req.headers?.authorization;
70
- const cleanHeader = headerVal ? String(headerVal).replace(/^Bearer\s+/i, "").trim() : "";
71
- if (cleanHeader === authToken) {
72
- return true;
73
- }
74
- // When authToken is explicitly configured, non-loopback callers without valid token are rejected
75
- if (!isLoopback(cleanRemote)) {
76
- return false;
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;
77
136
  }
78
137
  }
79
138
 
80
- // 5. Same-origin browser UI requests (operator UI on LAN or localhost)
81
- if (site === "same-origin") {
82
- const origin = String(req.headers?.origin || "").trim().toLowerCase();
83
- if (origin && host) {
84
- try {
85
- const u = new URL(origin);
86
- if (!isTrustedHost(u.hostname)) return false;
87
- if (u.host === host) return true;
88
- } catch (err) {
89
- 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;
90
150
  }
151
+ } catch {
152
+ return 403;
91
153
  }
92
- const referer = String(req.headers?.referer || "").trim().toLowerCase();
93
- if (referer && host) {
94
- try {
95
- const u = new URL(referer);
96
- if (!isTrustedHost(u.hostname)) return false;
97
- if (u.host === host) return true;
98
- } catch (err) {
99
- return false;
100
- }
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;
101
165
  }
102
- return false;
103
166
  }
104
167
 
105
- // 6. No declared site: only loopback remote clients (CLI, reverse proxy) are trusted
106
- if (isLoopback(cleanRemote)) return true;
168
+ // 7. Verified loopback callers (local CLI, health checks, deploy.sh verify)
169
+ if (isLoopback(cleanRemote)) {
170
+ return undefined;
171
+ }
107
172
 
108
- // 7. Fallback for test harnesses without remote socket where host header explicitly targets loopback
173
+ // Mock test requests without remoteAddress where host targets loopback
109
174
  if (!cleanRemote && host) {
110
- const hostName = host.split(":")[0];
175
+ const hostName = host.split(':')[0];
111
176
  if (isLoopback(hostName)) {
112
- return true;
177
+ return undefined;
113
178
  }
114
179
  }
115
180
 
116
- 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;
117
205
  }
118
206
 
119
207
  export { isPathInsideOrEqual, getSafeCwd };
@@ -218,10 +306,11 @@ export function registerRoutes(ctx, {
218
306
 
219
307
  // GET /dsh-goal/events: Server-Sent Events realtime snapshot stream
220
308
  if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
221
- if (!isTrustedCaller(req)) {
222
- res.statusCode = 403;
309
+ const rejection = getRequestRejection(req, { ctx });
310
+ if (rejection !== undefined) {
311
+ res.statusCode = rejection;
223
312
  if (typeof res.setHeader === 'function') res.setHeader('Content-Type', 'application/json; charset=utf-8');
224
- 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' }));
225
314
  }
226
315
  const sid = sessionIdOf(req, 'default');
227
316
  if (typeof res.writeHead === 'function') {
@@ -265,9 +354,10 @@ export function registerRoutes(ctx, {
265
354
 
266
355
  // GET /dsh-goal/templates
267
356
  if (req.method === 'GET' && (pathname === '/dsh-goal/templates' || pathname === '/dsh-goal/templates/')) {
268
- if (!isTrustedCaller(req)) {
269
- res.statusCode = 403;
270
- 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' }));
271
361
  }
272
362
  res.statusCode = 200;
273
363
  return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
@@ -275,9 +365,10 @@ export function registerRoutes(ctx, {
275
365
 
276
366
  // GET /dsh-goal/state
277
367
  if (req.method === 'GET' && (pathname === '/dsh-goal/state' || pathname === '/dsh-goal/state/')) {
278
- if (!isTrustedCaller(req)) {
279
- res.statusCode = 403;
280
- 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' }));
281
372
  }
282
373
  const sid = sessionIdOf(req, 'default');
283
374
  res.statusCode = 200;
@@ -316,9 +407,10 @@ export function registerRoutes(ctx, {
316
407
  }
317
408
  }
318
409
 
319
- if (!isTrustedCaller(req)) {
320
- res.statusCode = 403;
321
- 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' }));
322
414
  }
323
415
 
324
416
  let body = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.25",
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",