Skip to content

fix: replace assert guards, add an exception message, and stop a password leak - #282

Merged
jaysonsantos merged 3 commits into
mainfrom
fix/protocol-defects
Sep 14, 2026
Merged

jaysonsantos merged 3 commits into
mainfrom
fix/protocol-defects

Conversation

@jaysonsantos

Copy link
Copy Markdown
Owner

What changed

Five defect fixes and seven new tests. One commit. Each fix is independent of
the others and of the Python 2 cleanup in #278.

  1. The wire protocol no longer relies on an assert. A bad magic byte in
    a response header now raises a MemcachedException. The code calls
    self.disconnect() before it raises.
  2. Input validation no longer relies on an assert. A CAS value of 0
    raises ValueError. An empty server list raises ValueError.
  3. MemcachedException has a usable message. __init__ calls
    super().__init__, and the class defines __str__.
  4. Protocol.__str__ no longer holds the password.
  5. bmemcached/client/distributed.py imports SOCKET_TIMEOUT from
    bmemcached.client.constants.

test/test_server_parsing.py::testNoServersSupplied expected AssertionError.
It now expects ValueError.

Why

python -O strips every assert. Three of these guards were the only check
on their path, so an optimized interpreter silently dropped them.

The magic byte guard was the worst of the three. It raised a bare
AssertionError, which the surrounding except socket.error did not catch.
The header was read and the body was not, so the socket stayed open in a
desynchronized state and the next call on that thread read the leftover body
as a header. A malformed header from a proxy wedged the client instead of
disconnecting and retrying.

The CAS guard was the second. The wire protocol reads cas=0 as "no CAS
check", so a stripped assert turned a caller's compare-and-swap into an
unconditional overwrite.

Separately, MemcachedException.__init__ never called super().__init__, and
the class defined no __str__. str(MemcachedException('boom', 1)) returned
"('boom', 1)". Every caller and log line that called str(exc) got a tuple
repr rather than the message. And Protocol.__str__ put the plaintext SASL
password into any log line, any repr of a server list, and any traceback that
printed a Protocol object.

Verification

nix develop --command bash -c 'pytest -q'

Result: 268 passed. The baseline on main is 261 passed. This pull
request adds seven tests and changes no existing count.

nix develop --command bash -c 'flake8'

Result: 0 errors.

The new tests, run alone:

nix develop --command bash -c 'pytest -q test/test_errors.py::TestMemcachedExceptionMessage test/test_errors.py::TestBadResponseHeader test/test_errors.py::TestInvalidCasValue test/test_auth.py::TestProtocolStrDoesNotLeakPassword'

Result: 7 passed.

The assert cases, under an optimized interpreter:

nix develop --command bash -c 'python -O -m pytest -q test/test_errors.py::TestInvalidCasValue test/test_server_parsing.py::TestServerParsing::testNoServersSupplied'

Result: 2 passed. Both fail on main under -O, because the interpreter
strips the guard.

Direct check of the exception message:

nix develop --command bash -c 'python -c "from bmemcached.exceptions import MemcachedException; print(repr(str(MemcachedException(\"boom\", 1))))"'

Result: 'boom (code 1)'. On main this returns "('boom', 1)".

Risks

A reviewer must check three points.

  1. Three exception types change. cas(key, value, 0) raised
    AssertionError and now raises ValueError. An empty server list did the
    same. A caller that catches AssertionError around either call needs an
    update. Neither was a documented contract, and python -O already made
    both unreliable.
  2. A bad magic byte now raises instead of returning. _get_response
    previously let the AssertionError escape uncaught. It now raises a
    MemcachedException and drops the socket first. Callers that already
    handle MemcachedException need no change. The one behavior difference a
    reviewer should confirm is the disconnect(): it is deliberate, because
    the body was never read and a reused socket would misread it as the next
    header.
  3. str(MemcachedException) changes format. It returns
    "boom (code 1)" in place of "('boom', 1)". Anything that parsed the old
    tuple repr breaks. exc.message and exc.code are unchanged.

Protocol.__str__ keeps the server and the username. Only the password is
gone.

Closes #273

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 7ca98215-fa4e-49aa-be05-80752f52d968

📥 Commits

Reviewing files that changed from the base of the PR and between 975de9d and 9a3f461.

📒 Files selected for processing (9)
  • .gitignore
  • bmemcached/client/distributed.py
  • bmemcached/client/mixin.py
  • bmemcached/exceptions.py
  • bmemcached/protocol.py
  • test/test_auth.py
  • test/test_distributed_client_hashing.py
  • test/test_errors.py
  • test/test_server_parsing.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-14T16:45:59.670562Z 9a3f461 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review


P1 Badge Remove committed Python bytecode artifacts

Remove this file and the other two added .pyo files under test/__pycache__; they are generated CPython 3.12/pytest bytecode rather than test sources, are interpreter-specific, and embed the developer's absolute local workspace path. Keeping them tracked adds repository churn and leaks local build metadata without contributing runnable source.

AGENTS.md reference: AGENTS.md:L12-L12

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bmemcached/protocol.py Outdated
return "{}_{}_{}".format(self.server, self._username, self._password)
# Never include the password. This string reaches log lines, the repr of
# a server list, and any traceback that prints a Protocol object.
return "{}_{}".format(self.server, self._username)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve existing distributed hash identities

For every DistributedClient, HashRing(self._servers) uses each Protocol object's string form as its node identity, so this changes every identity even without authentication (server_None_None becomes server_None). Upgrading a multi-server deployment therefore routes most existing keys to different servers, causing widespread cache misses and a potential backend load spike; keep the legacy hash identity internally while exposing a password-free representation.

AGENTS.md reference: AGENTS.md:L27-L27

Useful? React with 👍 / 👎.

@mergify

mergify Bot commented Sep 9, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

jaysonsantos and others added 3 commits September 14, 2026 16:40
…word leak

Five defects. Each one is small and independent.

Defect 1: an assert guarded the wire protocol. The magic byte check at
bmemcached/protocol.py was the only check on a memcached response header.
"python -O" strips an assert, so the client then processed a corrupt stream
as valid data. The assert also raised a bare AssertionError, which the
surrounding "except socket.error" did not catch. The header was read. The
body was not. The socket stayed open in a desynchronized state, and the next
call on that thread read the leftover body as a header. The check now raises
a MemcachedException and calls self.disconnect() first.

Defect 2: two asserts guarded input validation. A zero CAS value now raises
ValueError. The wire protocol reads cas=0 as "no CAS check", so a stripped
assert turned a compare-and-swap into an unconditional overwrite. An empty
server list now raises ValueError at construction, in place of an unclear
failure later.

Defect 3: MemcachedException had no usable message. __init__ never called
super().__init__, and the class defined no __str__, so str(exc) returned the
args tuple repr. str(MemcachedException('boom', 1)) returned "('boom', 1)".
It now returns "boom (code 1)". AuthenticationNotSupported and
InvalidCredentials inherit the fix.

Defect 4: Protocol.__str__ printed the SASL password. The plaintext password
reached any log line, any repr of a server list, and any traceback that
printed a Protocol object. The password is out of the string.

Defect 5: a fragile circular import. bmemcached/client/distributed.py read
SOCKET_TIMEOUT back off the partly initialized package module. The import
worked only because of the line order in bmemcached/client/__init__.py. A
reorder raised ImportError at import time, and no test caught it. The module
now imports from bmemcached.client.constants, which is the form
bmemcached/client/mixin.py already used.

test/test_server_parsing.py expected AssertionError for an empty server
list. It now expects ValueError.

Seven new tests cover the four behavior changes. Two of them also pass under
"python -O", which is the case the asserts failed.

Refs #273
Protocol.__str__ no longer holds the password. DistributedClient passed
Protocol objects to HashRing, and uhashring hashes str(node). The node
identity changed from "server_username_password" to "server_username".
About 65% of keys moved to a different server after the change, with or
without authentication.

DistributedClient now hashes Protocol._hash_ring_node. This property
keeps the legacy identity. A dict maps each identity back to its
Protocol object. The str() output still holds no password.

A new test compares key placement with a ring built from the legacy
identities. It checks 1000 keys with and without credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Protocol.__str__ removed the password, but the string gave no sign of a
password. A reader of a log line could not tell if authentication was on.

The string now ends with "password=set" or "password=unset". The password
value stays out of the string. The hash ring identity does not change.

A new test checks the "password=unset" form. The existing test also checks
the "password=set" form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jaysonsantos
jaysonsantos merged commit 1f36d3a into main Sep 14, 2026
9 checks passed
@jaysonsantos
jaysonsantos deleted the fix/protocol-defects branch September 14, 2026 22:03
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.

Fix five defects: assert guards, exception message, password leak, circular import

1 participant