aegis-bridge 2.5.2 → 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.
@@ -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-DxAes2EQ.js"></script>
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
- * Also cleans up expired tokens as a side effect.
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
- * Also cleans up expired tokens as a side effect.
202
+ * #826: Async with mutex to prevent concurrent validation/generation from
203
+ * racing on shared state (sseTokens, sseTokenCounts).
203
204
  */
204
- validateSSEToken(token) {
205
- const entry = this.sseTokens.get(token);
206
- if (!entry)
207
- return false;
208
- // Already used
209
- if (entry.used) {
210
- this.sseTokens.delete(token);
211
- return false;
212
- }
213
- // Expired
214
- if (Date.now() > entry.expiresAt) {
215
- this.sseTokens.delete(token);
216
- return false;
217
- }
218
- // Valid — consume it
219
- entry.used = true;
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
- else {
229
- this.sseTokenCounts.set(keyId, count - 1);
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() {
@@ -6,6 +6,14 @@
6
6
  * one broken channel never kills the bridge.
7
7
  */
8
8
  import type { Channel, SessionEventPayload, InboundHandler } from './types.js';
9
+ /**
10
+ * Thrown for retriable failures (5xx server errors, network timeouts).
11
+ * Only these increment the circuit breaker failure count.
12
+ * 4xx client errors are thrown as plain Error and do NOT trip the breaker.
13
+ */
14
+ export declare class RetriableError extends Error {
15
+ constructor(message: string);
16
+ }
9
17
  export declare class ChannelManager {
10
18
  private channels;
11
19
  private inboundHandler;
@@ -5,6 +5,17 @@
5
5
  * to every registered channel, swallowing per-channel errors so
6
6
  * one broken channel never kills the bridge.
7
7
  */
8
+ /**
9
+ * Thrown for retriable failures (5xx server errors, network timeouts).
10
+ * Only these increment the circuit breaker failure count.
11
+ * 4xx client errors are thrown as plain Error and do NOT trip the breaker.
12
+ */
13
+ export class RetriableError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = 'RetriableError';
17
+ }
18
+ }
8
19
  export class ChannelManager {
9
20
  channels = [];
10
21
  inboundHandler = null;
@@ -86,6 +97,10 @@ export class ChannelManager {
86
97
  }
87
98
  catch (e) {
88
99
  console.error(`Channel ${ch.name} error on ${payload.event}:`, e);
100
+ // Only count retriable errors (5xx, network) toward circuit breaker.
101
+ // 4xx client errors are non-retriable — the server is healthy.
102
+ if (!(e instanceof RetriableError))
103
+ return;
89
104
  const h = this.health.get(ch.name) ?? { failCount: 0, disabledUntil: 0 };
90
105
  h.failCount++;
91
106
  if (h.failCount >= ChannelManager.FAILURE_THRESHOLD) {
@@ -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;
@@ -5,8 +5,9 @@
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
+ import { RetriableError } from './manager.js';
10
11
  export class WebhookChannel {
11
12
  name = 'webhook';
12
13
  endpoints;
@@ -72,15 +73,25 @@ export class WebhookChannel {
72
73
  const base = WebhookChannel.BASE_DELAY_MS * Math.pow(2, attempt - 1);
73
74
  return base * (0.5 + Math.random() * 0.5);
74
75
  }
75
- async fire(payload) {
76
- const body = JSON.stringify({
77
- ...payload,
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
+ },
78
86
  api: {
79
- read: `GET /sessions/${payload.session.id}/read`,
80
- send: `POST /sessions/${payload.session.id}/send`,
81
- kill: `DELETE /sessions/${payload.session.id}`,
87
+ read: 'GET /sessions/[REDACTED]/read',
88
+ send: 'POST /sessions/[REDACTED]/send',
89
+ kill: 'DELETE /sessions/[REDACTED]',
82
90
  },
83
- });
91
+ };
92
+ }
93
+ async fire(payload) {
94
+ const body = JSON.stringify(WebhookChannel.redactPayload(payload));
84
95
  const promises = this.endpoints.map(async (ep) => {
85
96
  // Skip if endpoint filters and this event isn't in the list
86
97
  if (ep.events && ep.events.length > 0 && !ep.events.includes(payload.event))
@@ -103,14 +114,40 @@ export class WebhookChannel {
103
114
  /** Issue #25: Deliver webhook with retry + exponential backoff. */
104
115
  async deliverWithRetry(ep, body, event, maxRetries = WebhookChannel.MAX_RETRIES) {
105
116
  let lastError = '';
117
+ const hostname = new URL(ep.url).hostname;
118
+ const bareHost = hostname.replace(/^\[|\]$/g, '');
106
119
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
107
120
  try {
108
- const res = await fetch(ep.url, {
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, {
109
149
  method: 'POST',
110
- headers: {
111
- 'Content-Type': 'application/json',
112
- ...(ep.headers || {}),
113
- },
150
+ headers,
114
151
  body,
115
152
  signal: AbortSignal.timeout(ep.timeoutMs || 5000),
116
153
  });
@@ -141,8 +178,13 @@ export class WebhookChannel {
141
178
  console.error(`Webhook ${ep.url} failed after ${maxRetries} attempts for ${event}: ${lastError}`);
142
179
  this.addToDeadLetterQueue(ep.url, event, lastError, maxRetries);
143
180
  }
144
- // Final failure (HTTP or network) — throw so fire() can aggregate
145
- throw new Error(lastError);
181
+ // Final failure — throw so fire() can aggregate.
182
+ // Use RetriableError for 5xx/network (circuit breaker counts these),
183
+ // plain Error for 4xx (circuit breaker ignores these).
184
+ if (lastError.startsWith('HTTP ') && parseInt(lastError.slice(5)) < 500) {
185
+ throw new Error(lastError);
186
+ }
187
+ throw new RetriableError(lastError);
146
188
  }
147
189
  }
148
190
  /** Issue #89 L14: Add a failed delivery to the dead letter queue. */