aegis-bridge 2.5.3 → 2.5.4
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/dashboard/dist/assets/{index-DxAes2EQ.js → index-DIyuyrlO.js} +47 -47
- package/dashboard/dist/index.html +1 -1
- package/dist/auth.d.ts +3 -2
- package/dist/auth.js +42 -29
- package/dist/channels/webhook.d.ts +2 -0
- package/dist/channels/webhook.js +49 -13
- package/dist/dashboard/assets/{index-DxAes2EQ.js → index-DIyuyrlO.js} +47 -47
- package/dist/dashboard/index.html +1 -1
- package/dist/hook-settings.js +23 -3
- package/dist/jsonl-watcher.js +9 -2
- package/dist/pipeline.js +10 -0
- package/dist/server.js +32 -7
- package/dist/session.d.ts +5 -1
- package/dist/session.js +75 -20
- package/dist/sse-writer.js +1 -1
- package/dist/ssrf.d.ts +19 -2
- package/dist/ssrf.js +38 -6
- package/dist/tmux.d.ts +8 -7
- package/dist/tmux.js +51 -36
- package/dist/transcript.js +12 -8
- package/dist/validation.d.ts +4 -0
- package/dist/validation.js +3 -2
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Aegis Dashboard</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>" />
|
|
8
|
-
<script type="module" crossorigin src="/dashboard/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/dashboard/assets/index-DIyuyrlO.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-B7DYf7vF.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body class="bg-[#0a0a0f] text-gray-200 antialiased">
|
package/dist/auth.d.ts
CHANGED
|
@@ -74,9 +74,10 @@ export declare class AuthManager {
|
|
|
74
74
|
/**
|
|
75
75
|
* Validate and consume a short-lived SSE token.
|
|
76
76
|
* Returns true if valid (and marks it as used), false otherwise.
|
|
77
|
-
*
|
|
77
|
+
* #826: Async with mutex to prevent concurrent validation/generation from
|
|
78
|
+
* racing on shared state (sseTokens, sseTokenCounts).
|
|
78
79
|
*/
|
|
79
|
-
validateSSEToken(token: string): boolean
|
|
80
|
+
validateSSEToken(token: string): Promise<boolean>;
|
|
80
81
|
/** Remove expired SSE tokens and recount per-key outstanding. */
|
|
81
82
|
private cleanExpiredSSETokens;
|
|
82
83
|
}
|
package/dist/auth.js
CHANGED
|
@@ -107,8 +107,6 @@ export class AuthManager {
|
|
|
107
107
|
if (!key) {
|
|
108
108
|
return { valid: false, keyId: null, rateLimited: false };
|
|
109
109
|
}
|
|
110
|
-
// Update last used
|
|
111
|
-
key.lastUsedAt = Date.now();
|
|
112
110
|
// Rate limiting
|
|
113
111
|
const bucket = this.rateLimits.get(key.id) || { count: 0, windowStart: Date.now() };
|
|
114
112
|
const now = Date.now();
|
|
@@ -125,6 +123,8 @@ export class AuthManager {
|
|
|
125
123
|
if (bucket.count > key.rateLimit) {
|
|
126
124
|
return { valid: true, keyId: key.id, rateLimited: true };
|
|
127
125
|
}
|
|
126
|
+
// Issue #841: Only update lastUsedAt for accepted requests, not rate-limited ones
|
|
127
|
+
key.lastUsedAt = Date.now();
|
|
128
128
|
return { valid: true, keyId: key.id, rateLimited: false };
|
|
129
129
|
}
|
|
130
130
|
/** Hash a key with SHA-256. */
|
|
@@ -199,37 +199,50 @@ export class AuthManager {
|
|
|
199
199
|
/**
|
|
200
200
|
* Validate and consume a short-lived SSE token.
|
|
201
201
|
* Returns true if valid (and marks it as used), false otherwise.
|
|
202
|
-
*
|
|
202
|
+
* #826: Async with mutex to prevent concurrent validation/generation from
|
|
203
|
+
* racing on shared state (sseTokens, sseTokenCounts).
|
|
203
204
|
*/
|
|
204
|
-
validateSSEToken(token) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const keyId = entry.keyId;
|
|
221
|
-
this.sseTokens.delete(token);
|
|
222
|
-
// #357: Decrement outstanding count so generateSSEToken doesn't over-limit
|
|
223
|
-
const count = this.sseTokenCounts.get(keyId);
|
|
224
|
-
if (count !== undefined) {
|
|
225
|
-
if (count <= 1) {
|
|
226
|
-
this.sseTokenCounts.delete(keyId);
|
|
205
|
+
async validateSSEToken(token) {
|
|
206
|
+
// Acquire mutex — chain onto the previous operation
|
|
207
|
+
let release = () => { };
|
|
208
|
+
const lock = new Promise((resolve) => { release = resolve; });
|
|
209
|
+
const previous = this.sseMutex;
|
|
210
|
+
this.sseMutex = lock;
|
|
211
|
+
// #573: catch prior rejection so it doesn't propagate and block subsequent callers
|
|
212
|
+
try {
|
|
213
|
+
await previous.catch(() => { });
|
|
214
|
+
const entry = this.sseTokens.get(token);
|
|
215
|
+
if (!entry)
|
|
216
|
+
return false;
|
|
217
|
+
// Already used
|
|
218
|
+
if (entry.used) {
|
|
219
|
+
this.sseTokens.delete(token);
|
|
220
|
+
return false;
|
|
227
221
|
}
|
|
228
|
-
|
|
229
|
-
|
|
222
|
+
// Expired
|
|
223
|
+
if (Date.now() > entry.expiresAt) {
|
|
224
|
+
this.sseTokens.delete(token);
|
|
225
|
+
return false;
|
|
230
226
|
}
|
|
227
|
+
// Valid — consume it
|
|
228
|
+
entry.used = true;
|
|
229
|
+
const keyId = entry.keyId;
|
|
230
|
+
this.sseTokens.delete(token);
|
|
231
|
+
// #357: Decrement outstanding count so generateSSEToken doesn't over-limit
|
|
232
|
+
const count = this.sseTokenCounts.get(keyId);
|
|
233
|
+
if (count !== undefined) {
|
|
234
|
+
if (count <= 1) {
|
|
235
|
+
this.sseTokenCounts.delete(keyId);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
this.sseTokenCounts.set(keyId, count - 1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
release();
|
|
231
245
|
}
|
|
232
|
-
return true;
|
|
233
246
|
}
|
|
234
247
|
/** Remove expired SSE tokens and recount per-key outstanding. */
|
|
235
248
|
cleanExpiredSSETokens() {
|
|
@@ -46,6 +46,8 @@ export declare class WebhookChannel implements Channel {
|
|
|
46
46
|
static readonly BASE_DELAY_MS = 1000;
|
|
47
47
|
/** Exponential backoff with jitter: delay * (0.5 + Math.random() * 0.5). */
|
|
48
48
|
static backoff(attempt: number): number;
|
|
49
|
+
/** Redact sensitive session metadata from webhook payloads. */
|
|
50
|
+
private static redactPayload;
|
|
49
51
|
private fire;
|
|
50
52
|
/** Issue #25: Deliver webhook with retry + exponential backoff. */
|
|
51
53
|
private deliverWithRetry;
|
package/dist/channels/webhook.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Configure via AEGIS_WEBHOOKS (or legacy MANUS_WEBHOOKS) env var or config file.
|
|
6
6
|
*/
|
|
7
7
|
import { webhookEndpointSchema, getErrorMessage } from '../validation.js';
|
|
8
|
-
import { validateWebhookUrl } from '../ssrf.js';
|
|
8
|
+
import { validateWebhookUrl, resolveAndCheckIp, buildConnectionUrl } from '../ssrf.js';
|
|
9
9
|
import { redactSecretsFromText } from '../utils/redact-headers.js';
|
|
10
10
|
import { RetriableError } from './manager.js';
|
|
11
11
|
export class WebhookChannel {
|
|
@@ -73,15 +73,25 @@ export class WebhookChannel {
|
|
|
73
73
|
const base = WebhookChannel.BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
74
74
|
return base * (0.5 + Math.random() * 0.5);
|
|
75
75
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
/** Redact sensitive session metadata from webhook payloads. */
|
|
77
|
+
static redactPayload(payload) {
|
|
78
|
+
const { session, ...rest } = payload;
|
|
79
|
+
return {
|
|
80
|
+
...rest,
|
|
81
|
+
session: {
|
|
82
|
+
id: '[REDACTED]',
|
|
83
|
+
name: '[REDACTED]',
|
|
84
|
+
workDir: '[REDACTED]',
|
|
85
|
+
},
|
|
79
86
|
api: {
|
|
80
|
-
read:
|
|
81
|
-
send:
|
|
82
|
-
kill:
|
|
87
|
+
read: 'GET /sessions/[REDACTED]/read',
|
|
88
|
+
send: 'POST /sessions/[REDACTED]/send',
|
|
89
|
+
kill: 'DELETE /sessions/[REDACTED]',
|
|
83
90
|
},
|
|
84
|
-
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async fire(payload) {
|
|
94
|
+
const body = JSON.stringify(WebhookChannel.redactPayload(payload));
|
|
85
95
|
const promises = this.endpoints.map(async (ep) => {
|
|
86
96
|
// Skip if endpoint filters and this event isn't in the list
|
|
87
97
|
if (ep.events && ep.events.length > 0 && !ep.events.includes(payload.event))
|
|
@@ -104,14 +114,40 @@ export class WebhookChannel {
|
|
|
104
114
|
/** Issue #25: Deliver webhook with retry + exponential backoff. */
|
|
105
115
|
async deliverWithRetry(ep, body, event, maxRetries = WebhookChannel.MAX_RETRIES) {
|
|
106
116
|
let lastError = '';
|
|
117
|
+
const hostname = new URL(ep.url).hostname;
|
|
118
|
+
const bareHost = hostname.replace(/^\[|\]$/g, '');
|
|
107
119
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
108
120
|
try {
|
|
109
|
-
|
|
121
|
+
// DNS rebinding protection: resolve and validate IP before each fetch.
|
|
122
|
+
// Skip for literal IPs (already validated at config time).
|
|
123
|
+
let fetchUrl = ep.url;
|
|
124
|
+
let headers = {
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
...(ep.headers || {}),
|
|
127
|
+
};
|
|
128
|
+
if (bareHost !== '127.0.0.1' && bareHost !== '::1' && bareHost !== 'localhost') {
|
|
129
|
+
const dnsResult = await resolveAndCheckIp(bareHost);
|
|
130
|
+
if (dnsResult.error) {
|
|
131
|
+
lastError = dnsResult.error;
|
|
132
|
+
if (attempt < maxRetries) {
|
|
133
|
+
const delay = WebhookChannel.backoff(attempt);
|
|
134
|
+
console.warn(`Webhook ${ep.url} DNS check failed for ${event} (attempt ${attempt}/${maxRetries}): ${lastError}, retrying in ${Math.round(delay)}ms`);
|
|
135
|
+
await new Promise(r => setTimeout(r, delay));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
console.error(`Webhook ${ep.url} DNS check failed after ${maxRetries} attempts for ${event}: ${lastError}`);
|
|
139
|
+
this.addToDeadLetterQueue(ep.url, event, lastError, maxRetries);
|
|
140
|
+
throw new RetriableError(lastError);
|
|
141
|
+
}
|
|
142
|
+
if (dnsResult.resolvedIp) {
|
|
143
|
+
const { connectionUrl, hostHeader } = buildConnectionUrl(ep.url, dnsResult.resolvedIp);
|
|
144
|
+
fetchUrl = connectionUrl;
|
|
145
|
+
headers['Host'] = hostHeader;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const res = await fetch(fetchUrl, {
|
|
110
149
|
method: 'POST',
|
|
111
|
-
headers
|
|
112
|
-
'Content-Type': 'application/json',
|
|
113
|
-
...(ep.headers || {}),
|
|
114
|
-
},
|
|
150
|
+
headers,
|
|
115
151
|
body,
|
|
116
152
|
signal: AbortSignal.timeout(ep.timeoutMs || 5000),
|
|
117
153
|
});
|