Skip to content

fix(security): require authentication on the replication endpoint for every bind - #103

Closed
HelgeSverre wants to merge 1 commit into
kwhorne:mainfrom
HelgeSverre:fix/repl-endpoint-require-auth
Closed

fix(security): require authentication on the replication endpoint for every bind#103
HelgeSverre wants to merge 1 commit into
kwhorne:mainfrom
HelgeSverre:fix/repl-endpoint-require-auth

Conversation

@HelgeSverre

Copy link
Copy Markdown
Contributor

Summary

The replication endpoint hands a full copy of the database to every connecting peer. It previously refused only non-loopback binds without ELYRASQL_CLUSTER_SECRET — so a listener on 127.0.0.1 without a secret let any local process (or an SSRF payload able to open a TCP connection) exfiltrate the entire data set.

Demonstrated live against main (dd0ed82) on macOS/Linux-equivalent loopback:

$ elyrasql serve --data ... --listen 127.0.0.1:13309 \
    --replication-listen 127.0.0.1:13310 --auth root:adminpw:admin
$ printf '' | nc 127.0.0.1 13310 | strings | grep CANARY
REPLICATION_CANARY_SECRET        # <- entire keyspace dumped, zero bytes sent

No handshake is required: on EOF-without-Hello the server treats the peer as a fresh replica and streams the whole snapshot.

Fix

  • Require ELYRASQL_CLUSTER_SECRET on every bind address, loopback included.
  • Keep ELYRASQL_ALLOW_OPEN_AUTH=1 as the explicit opt-out (consistent with the existing guards).
  • Add unit tests pinning all three guard outcomes (refused / secret OK / explicit opt-in OK).
  • Update docs/security.md, docs/limitations.md, docs/configuration.md, CHANGELOG.

Breaking change

Deployments that relied on an unauthenticated loopback replication endpoint must now set ELYRASQL_CLUSTER_SECRET (recommended) or opt out explicitly. Primary and replicas should be upgraded together; mixed versions fail closed at the handshake rather than silently degrading.

Verification

  • cargo test -p elyra-server — 221 passed (3 new)
  • cargo clippy -p elyra-server --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean

… every bind

The replication endpoint hands a full copy of the database to every
connecting peer. It previously refused only non-loopback binds without
ELYRASQL_CLUSTER_SECRET, so a listener on 127.0.0.1 without a secret let
any local process (or an SSRF payload able to open a TCP connection)
exfiltrate the entire data set with a bare connection and zero bytes sent.

Require ELYRASQL_CLUSTER_SECRET on every bind address; keep
ELYRASQL_ALLOW_OPEN_AUTH=1 as the explicit opt-out. Add guard unit tests
and update security/configuration/limitations docs.
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.

@kwhorne kwhorne closed this Aug 21, 2026
kwhorne added a commit that referenced this pull request Aug 21, 2026
## Summary

**A security release.** Three independent holes in the replication
surface
(#107, superseding #103#105), all reachable in 1.9.8.

The first needs no credentials and no handshake: connecting to the
replication
port returned a full copy of the database. Reproduced against 1.9.8 —
zero bytes
sent, 560 bytes back with the canary row in them. The guard only covered
non-loopback binds, so `--replication-listen 127.0.0.1:...` without a
cluster
secret was readable by any local process.

## The upgrade note is a `danger` block, not a `warning`

Because the fixes are deliberately breaking and the exposure is real:

- a primary with `--replication-listen` and a replica **both** now
require
  `ELYRASQL_CLUSTER_SECRET`
- **primary and replica must be upgraded together** — the handshake
gained a
  step, so a 1.9.9 replica will not accept a 1.9.8 primary
- a replica now needs accounts (`--user`/`--password`/`--auth`)
- a refused endpoint **keeps the server running**, so `replication
endpoint
stopped` in the log is the only signal that replication is not happening
—
  the note says to watch for it rather than assume
- anyone who ran an exposed endpoint should rotate what the data would
have
  revealed: the port left no access log

`ELYRASQL_ALLOW_OPEN_AUTH=1` opts out of all four, described as the
honest way to
say "this port is on a network I control" rather than as a convenience.

## The three things the release workflow does not validate

Per `CONTRIBUTING.md`: `SERVER_VERSION` → `8.0.12-ElyraSQL-1.9.9`
(confirmed with
`elyrasql version`), `testbench/sql-dump/Cargo.lock` refreshed with
`cargo metadata --locked` green in both workspaces, and the version
strings in
README, installation, deployment and mysql-compatibility. The
`Upgrading to 1.9.8` block keeps its title, and the 1.9.8 changelog
section is
byte-identical (verified by digest).

## Testing

- [x] `cargo fmt --all --check`
- [x] `cargo clippy --workspace --all-targets --all-features --locked --
-D warnings`
- [x] `cargo nextest run --workspace --locked` — 448/448
- [x] `cargo metadata --locked` in both workspaces
- [x] Verified against a MySQL client

The fix itself was verified both ways before merging: without a secret
the
endpoint refuses to start and its port does not listen; with one, an
unauthenticated peer receives the 16-byte challenge and no data.

## Checklist

- [x] Docs updated under `docs/`
- [x] No internal engine/dependency names leak into SQL, errors, CLI, or
the wire handshake
- [x] Limitations documented honestly
- [x] Scoped to a single logical change
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