Skip to content

fix(matrix-bridge): security + spec audit pass#18

Merged
replicas-connector[bot] merged 2 commits into
mainfrom
fix/matrix-security-spec-audit
May 30, 2026
Merged

fix(matrix-bridge): security + spec audit pass#18
replicas-connector[bot] merged 2 commits into
mainfrom
fix/matrix-security-spec-audit

Conversation

@replicas-connector

@replicas-connector replicas-connector Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Second-round audit, fresh angle (security / Matrix spec compliance / agent integration boundaries). Four high/medium findings shipped; four low-severity items deferred with rationale.

Findings shipped

HIGH — Admin/debug auth gate (src/index.ts)

  • Every /admin/*, /debug/*, /dispatch, and /start-listener route was unauthenticated.
  • /admin/vault/reset could DoS all E2EE state. /debug/vault/keystore leaked Megolm session counts per room. /dispatch let anyone spawn replicas + burn Anthropic credits.
  • Now gated behind ADMIN_TOKEN with timing-safe compare. Falls back to warn-and-allow when ADMIN_TOKEN is unset (migration mode) so existing operator curl flows keep working until the secret is set.

MED — m.replace edit dedup (src/listener.ts)

  • When a user edited a prior message the homeserver echoed a fresh m.room.message whose body was the fallback * <new content> string. Listener treated it as a new prompt and spawned a turn whose body literally started with * .
  • Fixed in both the unencrypted and decrypted-Megolm paths by skipping events whose m.relates_to.rel_type is m.replace.

MED — Member-count fail-closed (src/listener.ts)

  • cachedRoomMemberCount returned 2 ("treat as DM") on cold-cache + transient lookup failure, causing the bot to dispatch on every message in a 50-person room until the next successful lookup.
  • Now returns 100 (sentinel "definitely a group") so the mention gate stays active until membership is genuinely known.

LOW — Content-Type on Replicas POSTs (src/dispatch.ts, src/poller.ts)

  • replicasHeaders omitted Content-Type: application/json. Servers were auto-detecting JSON but explicit is correct.

Deferred

  • m.forwarded_room_key sender validation — Matrix E2EE spec recommends verifying the forwarder is a known device in the room. Currently any Olm-paired sender can plant Megolm keys for any room. Needs careful spec work to avoid breaking auto-recovery.
  • usage:org KV read-modify-write race — concurrent Done frames across rooms can drop entries. Cosmetic data only. Proper fix is routing through a Singleton DO.
  • Olm sessions cap=8 silent drop.slice(-8) evicts oldest. Bump to 32 + log evictions.
  • /admin/recover-room missing-senderKey — add a warning log when senderKey is missing so the operator can investigate.

Validation

  • npm run typecheck
  • npm test ✅ 97/97 tests pass
  • Deployed to https://replicas-matrix-bridge.jadengarza.workers.dev (version 0645cb26-2cfb-4250-bd3b-ae503543c395)

Test plan

  • Typecheck clean
  • Existing test suite passes
  • Deploy succeeds
  • After merge: wrangler secret put ADMIN_TOKEN to lock down endpoints
  • Smoke: send a test message, confirm normal turn flow still works
  • Smoke: edit a Matrix message, confirm bot does NOT respawn a turn
  • Smoke: hit /debug/listener without auth header → should log warning + allow (until ADMIN_TOKEN is set)

Created by Jaden (jadengarza@pm.me) with Replicas
Workspace  ·  Slack Thread

Open in Devin Review

@capy-ai

capy-ai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

if (!shouldDispatch) continue;
await this.dispatchMessage(roomId, queued.event_id, decrypted.body);
}
await this.state.storage.delete(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 drainPendingForSession unconditionally deletes queue even when items failed to decrypt

The drainPendingForSession method iterates over queued encrypted events and attempts to decrypt each. When decryption fails (line 543-544), it logs "leaving in queue" and continues — implying the entry should persist for future retry. However, after the loop exits, line 552 unconditionally deletes the entire queue key (await this.state.storage.delete(key)), permanently discarding any events that still couldn't be decrypted. This silently drops user messages that the log claims are being preserved.

Prompt for agents
In replicas-matrix-bridge/src/listener.ts, the drainPendingForSession method at line 552 unconditionally deletes the pending-decrypt queue after iterating through it, even though some entries may have failed to decrypt (logged as 'leaving in queue' at line 544). The fix should track which entries were successfully processed (dispatched or definitively skipped) versus which still failed to decrypt, and only delete the queue key if all entries were handled. Otherwise, write back the remaining undecryptable entries so they survive for the next drain attempt when a newer key might arrive. The simplest approach: maintain a 'stillPending' array, push items that fail to decrypt into it, and at the end either delete the key (if stillPending is empty) or write stillPending back to storage.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 drainPendingForSession unconditionally deletes queue even when items failed to decrypt

The drainPendingForSession method iterates over queued encrypted events and attempts to decrypt each. When decryption fails (line 543-544), it logs "leaving in queue" and continues — implying the entry should persist for future retry. However, after the loop exits, line 552 unconditionally deletes the entire queue key (await this.state.storage.delete(key)), permanently discarding any events that still couldn't be decrypted. This silently drops user messages that the log claims are being preserved.

Prompt for agents
In replicas-matrix-bridge/src/listener.ts, the drainPendingForSession method at line 552 unconditionally deletes the pending-decrypt queue after iterating through it, even though some entries may have failed to decrypt (logged as 'leaving in queue' at line 544). The fix should track which entries were successfully processed (dispatched or definitively skipped) versus which still failed to decrypt, and only delete the queue key if all entries were handled. Otherwise, write back the remaining undecryptable entries so they survive for the next drain attempt when a newer key might arrive. The simplest approach: maintain a 'stillPending' array, push items that fail to decrypt into it, and at the end either delete the key (if stillPending is empty) or write stillPending back to storage.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration[bot] This repository is not connected to a Replicas organization.

To enable Replicas on this repository:

  1. Go to GitHub integration settings
  2. Add this repository to your organization

Note: Each repository can only be connected to one Replicas organization.

continue;
}
if (decrypted.msgtype !== "m.text" || !decrypted.body) continue;
const shouldDispatch = await this.shouldHandleMessage(roomId, synthetic);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 drainPendingForSession passes encrypted wrapper to shouldHandleMessage instead of decrypted content

At line 548, shouldHandleMessage(roomId, synthetic) is called with synthetic containing the encrypted wrapper (algorithm, session_id, ciphertext, sender_key) — not the decrypted inner content. shouldHandleMessage (replicas-matrix-bridge/src/listener.ts:560-571) checks content["m.mentions"] and content.formatted_body for mention detection in group rooms (>2 members). Since the encrypted wrapper never contains these fields, mention detection always fails and messages in encrypted group rooms are silently dropped during queue drain.

The same bug was already identified and fixed in the recover-room path

At replicas-matrix-bridge/src/listener.ts:124-131, "Audit finding #8" explicitly addresses this: const synthetic = { content: inner.content ?? {} } — passing the DECRYPTED inner content. But drainPendingForSession at line 548 was not updated with the same fix.

Prompt for agents
In replicas-matrix-bridge/src/listener.ts drainPendingForSession(), line 548 passes the encrypted outer `synthetic` object to shouldHandleMessage(). The synthetic object has content = {algorithm, session_id, ciphertext, sender_key} which doesn't contain m.mentions or formatted_body, so mention detection in group rooms (>2 members) always fails.

The fix should mirror the recover-room path (line 131, audit finding #8): after tryDecrypt succeeds and returns decrypted content, construct a synthetic event using the DECRYPTED inner content for the shouldHandleMessage call. The tryDecrypt method returns {msgtype, body} but doesn't return the full inner content object. You'll need to either: (a) change tryDecrypt to also return the full inner content, or (b) reconstruct a minimal synthetic with the known fields, or (c) for 2-person rooms (the common case for DMs) this already works because shouldHandleMessage returns true for memberCount <= 2. The bug only affects encrypted group rooms during the drain path.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 drainPendingForSession passes encrypted wrapper to shouldHandleMessage instead of decrypted content

At line 548, shouldHandleMessage(roomId, synthetic) is called with synthetic containing the encrypted wrapper (algorithm, session_id, ciphertext, sender_key) — not the decrypted inner content. shouldHandleMessage (replicas-matrix-bridge/src/listener.ts:560-571) checks content["m.mentions"] and content.formatted_body for mention detection in group rooms (>2 members). Since the encrypted wrapper never contains these fields, mention detection always fails and messages in encrypted group rooms are silently dropped during queue drain.

The same bug was already identified and fixed in the recover-room path

At replicas-matrix-bridge/src/listener.ts:124-131, "Audit finding #8" explicitly addresses this: const synthetic = { content: inner.content ?? {} } — passing the DECRYPTED inner content. But drainPendingForSession at line 548 was not updated with the same fix.

Prompt for agents
In replicas-matrix-bridge/src/listener.ts drainPendingForSession(), line 548 passes the encrypted outer `synthetic` object to shouldHandleMessage(). The synthetic object has content = {algorithm, session_id, ciphertext, sender_key} which doesn't contain m.mentions or formatted_body, so mention detection in group rooms (>2 members) always fails.

The fix should mirror the recover-room path (line 131, audit finding #8): after tryDecrypt succeeds and returns decrypted content, construct a synthetic event using the DECRYPTED inner content for the shouldHandleMessage call. The tryDecrypt method returns {msgtype, body} but doesn't return the full inner content object. You'll need to either: (a) change tryDecrypt to also return the full inner content, or (b) reconstruct a minimal synthetic with the known fields, or (c) for 2-person rooms (the common case for DMs) this already works because shouldHandleMessage returns true for memberCount <= 2. The bug only affects encrypted group rooms during the drain path.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration[bot] This repository is not connected to a Replicas organization.

To enable Replicas on this repository:

  1. Go to GitHub integration settings
  2. Add this repository to your organization

Note: Each repository can only be connected to one Replicas organization.

@replicas-connector

Copy link
Copy Markdown
Contributor Author

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

@devin-ai-integration[bot] This repository is not connected to a Replicas organization.

To enable Replicas on this repository:

  1. Go to GitHub integration settings
  2. Add this repository to your organization

Note: Each repository can only be connected to one Replicas organization.

Second-round audit, fresh angle (security / Matrix spec / agent integration).
Four high/medium findings shipped:

1. **Admin/debug auth gate** — every /admin/*, /debug/*, /dispatch, and
   /start-listener route was unauthenticated. /admin/vault/reset alone
   could DoS all E2EE state; /debug/vault/keystore leaked Megolm session
   counts; /dispatch let anyone spawn replicas + burn Anthropic credits.
   Now gated behind ADMIN_TOKEN with timing-safe compare. Falls back to
   warn-and-allow when ADMIN_TOKEN is unset (migration mode) so existing
   operator curl flows keep working until the secret is set.

2. **m.replace edit dedup** — when a user edited a prior message the
   homeserver echoed a fresh m.room.message whose body was the fallback
   `* <new content>` string. Listener treated it as a new prompt and
   spawned a turn whose body literally started with `* `. Fixed in both
   the unencrypted and decrypted-Megolm paths by skipping events whose
   m.relates_to.rel_type is m.replace.

3. **Member-count fail-closed** — `cachedRoomMemberCount` returned 2
   ("treat as DM") on cold-cache + transient lookup failure, causing the
   bot to dispatch on every message in a 50-person room until the next
   successful lookup. Now returns 100 (sentinel "definitely a group") so
   the mention gate stays active until membership is genuinely known.

4. **Content-Type on Replicas POSTs** — replicasHeaders omitted
   Content-Type entirely. Servers were auto-detecting JSON but explicit
   is correct. Added in both src/dispatch.ts and src/poller.ts.

Deferred (low severity / requires deeper spec work):
- m.forwarded_room_key sender validation (Matrix E2EE spec recommends
  verifying the forwarder is a known device in the room — currently any
  Olm-paired sender can plant Megolm keys for any room)
- usage:org KV read-modify-write race across rooms (cosmetic data only)
- Olm sessions cap=8 silent drop (bump to 32 + log evictions)
- /admin/recover-room missing-senderKey warning log

Typecheck clean. 97/97 tests pass.

Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@replicas-connector replicas-connector Bot force-pushed the fix/matrix-security-spec-audit branch from 1add214 to baafc70 Compare May 30, 2026 16:40
@replicas-connector replicas-connector Bot merged commit 4234f25 into main May 30, 2026
1 check failed
replicas-connector Bot added a commit that referenced this pull request May 30, 2026
Closes out the deferred-with-rationale items from rounds 1–3 (commits
2313473, PR #18, PR #19). Every item now has a real code fix.

**Round 1 (correctness/lifecycle) — 4 items**

#1. pending-decrypt:* prune cron — listener alarm now runs a 30-min
periodic prune that drops queue entries older than 7 days and deletes
empty queues. Bounds long-term DO storage growth from sessions whose
forwarders went offline before responding.

#2. seen:* KV race redesign — dispatch dedupe was using KV's eventual
read-modify-write, letting two concurrent reads both see "absent" and
both proceed. New /dedup-claim endpoint on MatrixListener wraps the
check + write in blockConcurrencyWhile for genuine put-if-absent
semantics. Periodic prune handles cleanup (1h TTL); KV fallback
preserved for the case where the listener DO is unreachable.

#3. /admin/listener/reload-keys endpoint — clears the in-memory
cachedKeys + cachedCurve25519 so the operator can rotate Megolm
session keys (MATRIX_MEGOLM_KEYS_JSON) without a full redeploy.

#4. parsePlan hijack tightening — only accept Plan(d/t) headers
BEFORE any tool calls and before any prior plan has been parsed.
Prevents a confused/adversarial agent from overwriting in-progress
view by emitting `Plan (5/5)` mid-turn to fake completion.

**Round 2 (security/spec/integration) — 4 items**

#5. m.forwarded_room_key validation — vault now tags captured Megolm
keys as `forwarded` vs `live`; listener checks `key-request:*`
prefix to confirm we previously asked for the (room, session). If
not, calls new vault `/keystore-delete` to evict the unsolicited
key. Closes the "any Olm-paired sender can plant Megolm keys for any
room" hole.

#6. usage:org KV race — routed through OlmVault singleton DO via
new /usage-bump (write, atomic) and /usage-read (read) endpoints.
Concurrent Done frames across rooms can no longer drop entries via
read-modify-write race.

#7. Olm session cap bump — was .slice(-8), now .slice(-32). Verified
multi-device senders rotate fast enough that 8 was occasionally
dropping legitimate live sessions. Evictions are now logged so the
operator can spot pathological churn.

#8. /admin/recover-room missing-senderKey warning — surface bridges
that strip the outer sender_key field (m.room_key_request routing
degrades to `*` when missing, may not land on the originating device).

Typecheck clean. 102/102 tests pass.

Rolling tally tonight: 19 findings · 19 shipped · 0 deferred.

Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
replicas-connector Bot added a commit that referenced this pull request May 30, 2026
Closes out the deferred-with-rationale items from rounds 1–3 (commits
2313473, PR #18, PR #19). Every item now has a real code fix.

**Round 1 (correctness/lifecycle) — 4 items**

#1. pending-decrypt:* prune cron — listener alarm now runs a 30-min
periodic prune that drops queue entries older than 7 days and deletes
empty queues. Bounds long-term DO storage growth from sessions whose
forwarders went offline before responding.

#2. seen:* KV race redesign — dispatch dedupe was using KV's eventual
read-modify-write, letting two concurrent reads both see "absent" and
both proceed. New /dedup-claim endpoint on MatrixListener wraps the
check + write in blockConcurrencyWhile for genuine put-if-absent
semantics. Periodic prune handles cleanup (1h TTL); KV fallback
preserved for the case where the listener DO is unreachable.

#3. /admin/listener/reload-keys endpoint — clears the in-memory
cachedKeys + cachedCurve25519 so the operator can rotate Megolm
session keys (MATRIX_MEGOLM_KEYS_JSON) without a full redeploy.

#4. parsePlan hijack tightening — only accept Plan(d/t) headers
BEFORE any tool calls and before any prior plan has been parsed.
Prevents a confused/adversarial agent from overwriting in-progress
view by emitting `Plan (5/5)` mid-turn to fake completion.

**Round 2 (security/spec/integration) — 4 items**

#5. m.forwarded_room_key validation — vault now tags captured Megolm
keys as `forwarded` vs `live`; listener checks `key-request:*`
prefix to confirm we previously asked for the (room, session). If
not, calls new vault `/keystore-delete` to evict the unsolicited
key. Closes the "any Olm-paired sender can plant Megolm keys for any
room" hole.

#6. usage:org KV race — routed through OlmVault singleton DO via
new /usage-bump (write, atomic) and /usage-read (read) endpoints.
Concurrent Done frames across rooms can no longer drop entries via
read-modify-write race.

#7. Olm session cap bump — was .slice(-8), now .slice(-32). Verified
multi-device senders rotate fast enough that 8 was occasionally
dropping legitimate live sessions. Evictions are now logged so the
operator can spot pathological churn.

#8. /admin/recover-room missing-senderKey warning — surface bridges
that strip the outer sender_key field (m.room_key_request routing
degrades to `*` when missing, may not land on the originating device).

Typecheck clean. 102/102 tests pass.

Rolling tally tonight: 19 findings · 19 shipped · 0 deferred.

Co-authored-by: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>
Co-authored-by: itsablabla <itsablabla@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
replicas-connector Bot added a commit that referenced this pull request May 30, 2026
PRs touching replicas-matrix-bridge/ now run npm typecheck + npm test
(previously these were only run locally before each deploy).

On merges to main, also probe the deployed worker to assert:
- /admin/listener/reset returns 401 without ADMIN_TOKEN
- /debug/listener returns 401 without ADMIN_TOKEN
- /health stays 200 (must remain public for uptime checks)

Prevents regression of the security gate added in PR #18 — if a future
change accidentally drops the ADMIN_TOKEN check on any of the protected
paths, this job fails and the deploy is flagged.

Co-authored-by: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>
Co-authored-by: itsablabla <itsablabla@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant