@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.
- package/lib/routes.js +156 -64
- 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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!isTrustedHost(u.hostname)) return
|
|
87
|
-
if (u.host
|
|
88
|
-
}
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
//
|
|
106
|
-
if (isLoopback(cleanRemote))
|
|
168
|
+
// 7. Verified loopback callers (local CLI, health checks, deploy.sh verify)
|
|
169
|
+
if (isLoopback(cleanRemote)) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
107
172
|
|
|
108
|
-
//
|
|
173
|
+
// Mock test requests without remoteAddress where host targets loopback
|
|
109
174
|
if (!cleanRemote && host) {
|
|
110
|
-
const hostName = host.split(
|
|
175
|
+
const hostName = host.split(':')[0];
|
|
111
176
|
if (isLoopback(hostName)) {
|
|
112
|
-
return
|
|
177
|
+
return undefined;
|
|
113
178
|
}
|
|
114
179
|
}
|
|
115
180
|
|
|
116
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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