Skip to content

fix(security): require accounts on the replica MySQL listener - #105

Closed
HelgeSverre wants to merge 1 commit into
kwhorne:mainfrom
HelgeSverre:fix/cli-default-deny-auth
Closed

fix(security): require accounts on the replica MySQL listener#105
HelgeSverre wants to merge 1 commit into
kwhorne:mainfrom
HelgeSverre:fix/cli-default-deny-auth

Conversation

@HelgeSverre

Copy link
Copy Markdown
Contributor

Summary

elyrasql replica had no auth flags at all and always started its MySQL listener with open authentication — any username/password logged in as Admin, with full read access to the replicated (often production) data set:

WARN elyra_server: authentication is OPEN (no credentials required, every client gets Admin)
...
$ mysql -h 127.0.0.1 -P 13312 -u attacker -panything -e "SELECT * FROM production_table"
...succeeds...

serve mode defaults to 127.0.0.1 with a safe-by-default refusal for remote binds, but replicas are typically part of real deployments and there was no way to configure accounts at all.

Fix

  • Add --user/--password/--auth USER:PASS:ROLE to the replica subcommand, mirroring serve.
  • Refuse to start a credential-less replica listener unless ELYRASQL_ALLOW_OPEN_AUTH=1 explicitly opts in.
  • Make elyra_server::env_flag public so the CLI reuses the library's truthy-value parsing (1/true/yes/on).
  • New CLI test file pinning both outcomes (refusal with explanatory error; authenticated startup reaches a listening MySQL port).
  • Update docs/security.md, docs/replication.md, CHANGELOG.

Breaking change

Scripts starting replicas without accounts must add auth flags or set ELYRASQL_ALLOW_OPEN_AUTH=1. The refusal happens before any file I/O, so nothing is wiped or half-started.

Verification

  • cargo test -p elyra-cli — 5 passed (2 new: replica_refuses_credential_less_listener, replica_starts_with_configured_accounts)
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean

elyrasql replica had no auth flags at all and always started its MySQL
listener with open authentication: any username/password logged in as
Admin with full read access to the replicated (often production) data
set. serve mode already defaulted to 127.0.0.1 with a safe-by-default
refusal for remote binds; replicas are typically part of real
deployments, so a credential-less default there is a much sharper edge.

- Add --user/--password/--auth USER:PASS:ROLE to the replica subcommand,
  mirroring serve.
- Refuse to start a credential-less replica listener unless
  ELYRASQL_ALLOW_OPEN_AUTH=1 explicitly opts in.
- Make elyra_server::env_flag public so the CLI reuses the same truthy
  parsing as the library guards.
- Add CLI tests pinning both the refusal and authenticated startup.
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 kwhorne mentioned this pull request Aug 21, 2026
9 tasks
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