@aikidosec/broker-client 1.0.3 → 1.0.5
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/app/client.js +30 -39
- package/app/resourceManager.js +67 -0
- package/package.json +2 -1
package/app/client.js
CHANGED
|
@@ -12,6 +12,7 @@ import { Address4, Address6 } from 'ip-address';
|
|
|
12
12
|
import dns from 'native-dns';
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import { ResourceManager } from './resourceManager.js';
|
|
15
|
+
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
15
16
|
|
|
16
17
|
// Configure logging
|
|
17
18
|
const log = {
|
|
@@ -36,9 +37,12 @@ const DNS_SERVERS = process.env.DNS_SERVERS
|
|
|
36
37
|
: null;
|
|
37
38
|
|
|
38
39
|
// Configure axios defaults
|
|
40
|
+
const MAX_RESPONSE_SIZE = 100 * 1024 * 1024; // 100 MB
|
|
39
41
|
const axiosConfig = {
|
|
40
42
|
timeout: 30000,
|
|
41
|
-
maxRedirects: 5
|
|
43
|
+
maxRedirects: 5,
|
|
44
|
+
maxContentLength: MAX_RESPONSE_SIZE,
|
|
45
|
+
maxBodyLength: MAX_RESPONSE_SIZE
|
|
42
46
|
};
|
|
43
47
|
|
|
44
48
|
// Create axios instance for internal requests
|
|
@@ -213,36 +217,9 @@ function isInternalUrl(url) {
|
|
|
213
217
|
}
|
|
214
218
|
}
|
|
215
219
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
function isResourceAllowed(resource) {
|
|
220
|
-
const registeredResources = resourceManager.getResources();
|
|
221
|
-
|
|
222
|
-
// If no resources registered yet, deny (customer must register resources first)
|
|
223
|
-
if (Object.keys(registeredResources).length === 0) {
|
|
224
|
-
log.warn("No resources registered - customer must register resources via setup mode");
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Normalize the target URL for comparison
|
|
229
|
-
const resourceParsed = new URL(resource);
|
|
230
|
-
const resourceNormalized = `${resourceParsed.protocol}//${resourceParsed.host}${resourceParsed.pathname || '/'}`;
|
|
231
|
-
|
|
232
|
-
// Check if the exact base_url is registered
|
|
233
|
-
for (const baseUrl of Object.keys(registeredResources)) {
|
|
234
|
-
const baseParsed = new URL(baseUrl);
|
|
235
|
-
const baseNormalized = `${baseParsed.protocol}//${baseParsed.host}${baseParsed.pathname || '/'}`;
|
|
236
|
-
|
|
237
|
-
// Allow if target URL starts with registered base URL
|
|
238
|
-
if (resourceNormalized.startsWith(baseNormalized)) {
|
|
239
|
-
log.info(`Resource ${resource} matches registered base_url ${baseUrl}`);
|
|
240
|
-
return true;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
log.warn(`Resource ${resource} not found in registered resources: ${Object.keys(registeredResources)}`);
|
|
245
|
-
return false;
|
|
220
|
+
let agent;
|
|
221
|
+
if (process.env.HTTPS_PROXY || process.env.ALL_PROXY) {
|
|
222
|
+
agent = new HttpsProxyAgent(process.env.HTTPS_PROXY || process.env.ALL_PROXY);
|
|
246
223
|
}
|
|
247
224
|
|
|
248
225
|
// Create Socket.IO client
|
|
@@ -250,13 +227,16 @@ const socket = io(SERVER_URL, {
|
|
|
250
227
|
auth: {
|
|
251
228
|
client_secret: CLIENT_SECRET
|
|
252
229
|
},
|
|
230
|
+
agent,
|
|
253
231
|
transports: ['websocket', 'polling'],
|
|
254
232
|
reconnection: true,
|
|
255
233
|
reconnectionAttempts: Infinity,
|
|
256
234
|
reconnectionDelay: 1000,
|
|
257
235
|
reconnectionDelayMax: 30000,
|
|
258
236
|
randomizationFactor: 0.5,
|
|
259
|
-
|
|
237
|
+
tryAllTransports: true, // if we don't, it won't try to fallback from websocket to polling
|
|
238
|
+
autoConnect: false, // Don't connect until after registration
|
|
239
|
+
withCredentials: true // make sure cookies work for sticky sessions
|
|
260
240
|
});
|
|
261
241
|
|
|
262
242
|
// Socket.IO event handlers
|
|
@@ -324,17 +304,17 @@ socket.on('forward_request', async (data, callback) => {
|
|
|
324
304
|
request_id: requestId,
|
|
325
305
|
status_code: 403,
|
|
326
306
|
headers: {},
|
|
327
|
-
body: 'Target URL is not an allowed internal resource'
|
|
307
|
+
body: formatMessageBody('Target URL is not an allowed internal resource')
|
|
328
308
|
});
|
|
329
309
|
return;
|
|
330
310
|
}
|
|
331
311
|
|
|
332
|
-
if (!
|
|
312
|
+
if (!(await resourceManager.isResourceAllowedWithSync(targetUrl))) {
|
|
333
313
|
callback({
|
|
334
314
|
request_id: requestId,
|
|
335
315
|
status_code: 403,
|
|
336
316
|
headers: {},
|
|
337
|
-
body: 'Target URL is not in the allowed resources list'
|
|
317
|
+
body: formatMessageBody('Target URL is not in the allowed resources list')
|
|
338
318
|
});
|
|
339
319
|
return;
|
|
340
320
|
}
|
|
@@ -361,30 +341,41 @@ socket.on('forward_request', async (data, callback) => {
|
|
|
361
341
|
url: resolvedUrl,
|
|
362
342
|
headers,
|
|
363
343
|
data: body,
|
|
364
|
-
validateStatus: () => true // Accept any status code
|
|
344
|
+
validateStatus: () => true, // Accept any status code
|
|
345
|
+
responseType: 'arraybuffer', // Get raw bytes, don't parse JSON
|
|
365
346
|
});
|
|
366
347
|
|
|
367
348
|
log.info(`Successfully forwarded request ${requestId} to ${targetUrl}, status: ${response.status}`);
|
|
368
349
|
|
|
369
350
|
// Return response via acknowledgement
|
|
351
|
+
// Send body as base64 to preserve binary data byte-for-byte (critical for Docker registry digests)
|
|
352
|
+
const responseBody = response.data ? Buffer.from(response.data).toString('base64') : null;
|
|
353
|
+
|
|
370
354
|
callback({
|
|
371
355
|
request_id: requestId,
|
|
372
356
|
status_code: response.status,
|
|
373
357
|
headers: response.headers,
|
|
374
|
-
body:
|
|
358
|
+
body: responseBody,
|
|
359
|
+
version: 2
|
|
375
360
|
});
|
|
376
361
|
|
|
377
362
|
} catch (error) {
|
|
378
|
-
log.error(`Error forwarding request ${requestId} to ${targetUrl}: ${error.message}`);
|
|
363
|
+
log.error(`Error forwarding request ${requestId} to ${targetUrl}: ${error?.response?.status || error.message}`);
|
|
364
|
+
const errorMessage = `Error reaching internal resource: ${error?.response?.status || error.message}`;
|
|
379
365
|
callback({
|
|
380
366
|
request_id: requestId,
|
|
381
367
|
status_code: 502,
|
|
382
368
|
headers: {},
|
|
383
|
-
body:
|
|
369
|
+
body: formatMessageBody(errorMessage),
|
|
370
|
+
version: 2
|
|
384
371
|
});
|
|
385
372
|
}
|
|
386
373
|
});
|
|
387
374
|
|
|
375
|
+
function formatMessageBody(message) {
|
|
376
|
+
return Buffer.from(message, 'utf-8').toString('base64');
|
|
377
|
+
}
|
|
378
|
+
|
|
388
379
|
/**
|
|
389
380
|
* Register this client with the broker server
|
|
390
381
|
*/
|
package/app/resourceManager.js
CHANGED
|
@@ -29,6 +29,8 @@ export class ResourceManager {
|
|
|
29
29
|
this._serverUrl = null;
|
|
30
30
|
this._clientId = null;
|
|
31
31
|
this._clientSecret = null;
|
|
32
|
+
this._lastResourceSync = 0;
|
|
33
|
+
this._syncThrottleMs = 30000; // 30 seconds
|
|
32
34
|
this._loadFromFile();
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -114,6 +116,70 @@ export class ResourceManager {
|
|
|
114
116
|
return { ...this._resources };
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Check if a resource URL is allowed (registered).
|
|
121
|
+
* @param {string} resource - The full URL to check
|
|
122
|
+
* @returns {boolean} True if allowed, false otherwise
|
|
123
|
+
*/
|
|
124
|
+
isResourceAllowed(resource) {
|
|
125
|
+
// If no resources registered yet, deny
|
|
126
|
+
if (Object.keys(this._resources).length === 0) {
|
|
127
|
+
log.warn("No resources registered - customer must register resources via setup mode");
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Normalize the target URL for comparison
|
|
132
|
+
const resourceParsed = new URL(resource);
|
|
133
|
+
const resourceNormalized = `${resourceParsed.protocol}//${resourceParsed.host}${resourceParsed.pathname || '/'}`;
|
|
134
|
+
|
|
135
|
+
// Check if the exact base_url is registered
|
|
136
|
+
for (const baseUrl of Object.keys(this._resources)) {
|
|
137
|
+
const baseParsed = new URL(baseUrl);
|
|
138
|
+
const baseNormalized = `${baseParsed.protocol}//${baseParsed.host}${baseParsed.pathname || '/'}`;
|
|
139
|
+
|
|
140
|
+
// Allow if target URL starts with registered base URL
|
|
141
|
+
if (resourceNormalized.startsWith(baseNormalized)) {
|
|
142
|
+
log.info(`Resource ${resource} matches registered base_url ${baseUrl}`);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
log.warn(`Resource ${resource} not found in registered resources: ${Object.keys(this._resources)}`);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a resource is allowed, with automatic sync retry if not found.
|
|
153
|
+
* Syncs from broker if resource not found and throttle period has passed.
|
|
154
|
+
* @param {string} resource - The full URL to check
|
|
155
|
+
* @returns {Promise<boolean>} True if allowed, false otherwise
|
|
156
|
+
*/
|
|
157
|
+
async isResourceAllowedWithSync(resource) {
|
|
158
|
+
// First check without syncing
|
|
159
|
+
if (this.isResourceAllowed(resource)) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Resource not found - try syncing from broker if enough time has passed
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
if (now - this._lastResourceSync > this._syncThrottleMs) {
|
|
166
|
+
log.info(`Resource ${resource} not found, syncing from broker...`);
|
|
167
|
+
this._lastResourceSync = now;
|
|
168
|
+
try {
|
|
169
|
+
await this.syncFromBroker();
|
|
170
|
+
// Check again after sync
|
|
171
|
+
if (this.isResourceAllowed(resource)) {
|
|
172
|
+
log.info(`Resource ${resource} found after sync`);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
log.error(`Failed to sync resources: ${error.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
117
183
|
/**
|
|
118
184
|
* Get resource_id for a base_url.
|
|
119
185
|
* @param {string} baseUrl - The base URL to look up
|
|
@@ -177,6 +243,7 @@ export class ResourceManager {
|
|
|
177
243
|
|
|
178
244
|
// Sync from broker response
|
|
179
245
|
this.syncFromCore(resources);
|
|
246
|
+
this._lastResourceSync = Date.now();
|
|
180
247
|
log.info(`✓ Synced ${Object.keys(resources).length} resources from broker`);
|
|
181
248
|
} else {
|
|
182
249
|
log.warn(`Failed to fetch resources: ${response.status} - ${response.data}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikidosec/broker-client",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Aikido Broker Client - Runs in customer network to forward requests to internal resources",
|
|
5
5
|
"main": "app/client.js",
|
|
6
6
|
"type": "module",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"axios": "1.13.2",
|
|
36
|
+
"https-proxy-agent": "^7.0.6",
|
|
36
37
|
"ip-address": "10.0.1",
|
|
37
38
|
"native-dns": "0.7.0",
|
|
38
39
|
"socket.io-client": "4.8.1"
|