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.
@@ -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() {
@@ -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,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
- async fire(payload) {
77
- const body = JSON.stringify({
78
- ...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
+ },
79
86
  api: {
80
- read: `GET /sessions/${payload.session.id}/read`,
81
- send: `POST /sessions/${payload.session.id}/send`,
82
- kill: `DELETE /sessions/${payload.session.id}`,
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
- 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, {
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
  });