Skip to content

fix(security): mutual replication auth + replica refuses unauthenticated primaries - #104

Closed
HelgeSverre wants to merge 1 commit into
kwhorne:mainfrom
HelgeSverre:fix/replica-mutual-auth
Closed

fix(security): mutual replication auth + replica refuses unauthenticated primaries#104
HelgeSverre wants to merge 1 commit into
kwhorne:mainfrom
HelgeSverre:fix/replica-mutual-auth

Conversation

@HelgeSverre

Copy link
Copy Markdown
Contributor

Summary

A replica applies everything its primary streams to it, but it never verified the primary's identity: auth_connect only proves the replica to the primary. Any host accepting TCP connections on the primary's address could impersonate it (the transport is plaintext unless cluster TLS is configured) and feed the replica arbitrary snapshot/write frames — fabricated tables, tampered rows, corrupted catalog state — which the replica then served to clients.

Demonstrated end-to-end against main (dd0ed82) with a ~60-line Python fake primary:

  1. Real primary seeded with repl_t(id, secret) = (1, 'REPLICATION_CANARY_SECRET'); snapshot captured from its replication port.
  2. Fake primary replays the captured snapshot with one value tampered in place.
  3. Stock replica bootstraps from it and serves the tampered row to clients:
== replica serves table repl_t with rows: ((1, 'XXXXXXXXXXXXXXXXXXXXXXXXX'),)
== ==> ARBITRARY DATA INJECTION INTO REPLICA CONFIRMED

Note this works even when ELYRASQL_CLUSTER_SECRET is set on the primary: the fake primary reads the replica's proof, ignores it, and streams data anyway — nothing ever checks the other direction.

Fix

  • New handshake steps auth_verify_peer (replica challenges, verifies constant-time) and auth_respond_to_peer (primary answers). Order: auth_connectauth_verify_peerHello → data. Bounded by a 5 s timeout so a stalling peer cannot hang the reconnect loop.
  • run_replica fails closed when no cluster secret is configured unless ELYRASQL_ALLOW_OPEN_AUTH=1.
  • Tests: duplex-pair handshake unit tests (shared secret OK / wrong secret rejected), startup refusal test, and an end-to-end primary→replica snapshot test over real TCP.

Breaking change

Primary and replica must be upgraded together (the handshake changed). Mixed versions fail closed at connect rather than silently degrading. Replicas now require ELYRASQL_CLUSTER_SECRET.

Verification

Re-ran the fake-primary attack against this branch:

=== A) replica WITHOUT secret: must refuse to start ===
   exit=75  refused-with-clear-error=True
=== B) replica WITH secret vs fake primary: must not apply data ===
   injected table visible via SQL: False
   injected bytes present in data file: False
=== RESULT: EXPLOIT BLOCKED ===
  • cargo test -p elyra-server — 222 passed (4 new)
  • cargo clippy -p elyra-server --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean

Depends conceptually on #103 (same guard philosophy); conflicts are trivial if ordering matters.

…ted primaries

A replica applies everything its primary streams to it, but it never
verified the primary's identity: auth_connect only proves the replica to
the primary. Any host accepting TCP connections on the primary's address
could impersonate it (the transport is plaintext unless cluster TLS is
configured) and feed the replica arbitrary snapshot/write frames --
fabricated tables, tampered rows, corrupted catalog state -- which the
replica then served to clients. Demonstrated end-to-end with a fake
primary against a stock replica.

- Add auth_verify_peer/auth_respond_to_peer: after the existing
  replica->primary proof, the primary must prove knowledge of
  ELYRASQL_CLUSTER_SECRET back to the replica before any data flows.
  Handshake is bounded by a 5s timeout so a stalling peer cannot hang
  the reconnect loop.
- run_replica fails closed when no cluster secret is configured unless
  ELYRASQL_ALLOW_OPEN_AUTH=1 explicitly opts in.

Primary and replica must be upgraded together (handshake changed).
Adds handshake unit tests plus an end-to-end primary/replica test.
kwhorne added a commit that referenced this pull request Aug 21, 2026
Three independent findings on the replication surface, sequenced and with their
mutual conflicts resolved. Supersedes #103, #104 and #105.

The replication endpoint handed a full copy of the database to any peer that
connected, with no handshake -- reproduced against 1.9.8 on a loopback bind,
where zero bytes sent returned the canary row. The old guard only covered
non-loopback binds.

A replica never verified its primary's identity, so anything answering on the
primary's address could inject fabricated rows, even with ELYRASQL_CLUSTER_SECRET
set: authentication ran in one direction only. Replication auth is now mutual,
and auth_accept runs before auth_respond_to_peer so the responder cannot be used
as an oracle by a peer that does not already know the secret.

`elyrasql replica` had no auth flags at all and always started its MySQL listener
with open authentication over replicated production data. It now takes
--user/--password/--auth like `serve` and refuses to start credential-less.

Verified: the endpoint refuses to start without a secret and its port does not
listen; with a secret, an unauthenticated peer receives the 16-byte challenge and
no data. 448/448 tests.

Co-authored-by: Helge Sverre <helge.sverre@gmail.com>
@kwhorne

kwhorne commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Merged as part of #107 (185cd7d) — your commit, your authorship, plus a Co-authored-by trailer on the squash.

Consolidated rather than merged one by one for two reasons.

Actions never ran on any of the three. total_count: 0 check-suites on each head commit, and nothing sitting in the approval queue — so there was no CI signal at all on a security change. Reopening them from a branch in this repo was the only way to get one. #107 is green on all 8 checks.

They conflicted with each other, not just with main. #103 and #104 both append a #[cfg(test)] mod tests to repl.rs with the same ENV_LOCK/set_env helpers; the production code merged clean, and the resolution keeps both suites over one copy of the helpers. docs/limitations.md and docs/security.md took #104's wording (a superset of #103's); docs/replication.md kept both paragraphs, since they cover different surfaces.

One genuine interaction needed a commit of its own: replica_auth.rs spawns elyrasql replica, which after #104 refuses to start without ELYRASQL_CLUSTER_SECRET. Both its tests now set one. That only fails with all three applied — each of your branches passes the suite alone (443, 443, 441).

I reproduced the #103 finding against 1.9.8 before touching anything: zero bytes sent to the replication port, 560 bytes back with the canary row in them. Verified closed both ways afterwards — without a secret the endpoint refuses to start and the port does not listen; with one, an unauthenticated peer gets the 16-byte challenge and nothing else.

Thanks — this was a real hole in a shipped release.

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.

2 participants