@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.
- package/lib/routes.js +187 -56
- 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
|
-
|
|
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
|
|
22
|
-
if (!
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (u.host
|
|
50
|
-
}
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
//
|
|
67
|
-
if (isLoopback(
|
|
168
|
+
// 7. Verified loopback callers (local CLI, health checks, deploy.sh verify)
|
|
169
|
+
if (isLoopback(cleanRemote)) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
68
172
|
|
|
69
|
-
//
|
|
70
|
-
if (!
|
|
71
|
-
const hostName =
|
|
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
|
|
177
|
+
return undefined;
|
|
74
178
|
}
|
|
75
179
|
}
|
|
76
180
|
|
|
77
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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