Skip to content

πŸ”’ SEC-#50: Clear password references on close and mask in repr - #59

Merged
FernandoCelmer merged 2 commits into
developfrom
feature/50
Apr 16, 2026
Merged

FernandoCelmer merged 2 commits into
developfrom
feature/50

Conversation

@FernandoCelmer

Copy link
Copy Markdown
Member

Summary

  • Clear self.password = None in ImapClient.close() so the password string is not retained after disconnection
  • Clear self._password = None in SmtpClient.close() for the same reason
  • Add __repr__ and __str__ to Credentials dataclass that mask the password field, preventing accidental exposure in logs or tracebacks

Closes #50

Test plan

  • All 149 existing tests pass (pytest tests/ -x -q)
  • Verify repr(Credentials(...)) shows password='***' instead of the real value
  • Verify password attribute is None after calling close() on both IMAP and SMTP clients

@FernandoCelmer FernandoCelmer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

πŸ” Code Review

Code issues found: 1

See inline comment below.

self.password = None

def noop(self) -> None:
self.require()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Blocking]

Problem β€” After close(), self.password is set to None. The connect() method calls client.login(user=self.user, password=self.password). If a user calls close() then connect() again (a common reconnection pattern), imaplib will coerce None to the string "None" and send it as the literal password, causing a confusing authentication failure.

Failure scenario β€”

with Email.from_env() as app:
    app.sync()
# After __exit__, close() runs -> password = None

# Later, trying to reconnect:
app.connect()  # login(user='me@x.com', password=None)
# -> imaplib sends 'None' as password -> auth failure
# -> confusing error: 'Authentication failed' (wrong password)

More critically, sync.py and restore.py create worker connections using self._session.password. If the main session is closed while workers are still spawning, they read password=None.

Fix β€” Instead of nulling the password attribute, only clear the connection handle. If secure clearing is important, capture the password in a local before threading:

# Option A: Don't null password in close()
def close(self) -> None:
    if self.client:
        try:
            self.client.logout()
        finally:
            self.client = None
            self.mailboxes = {}
            # self.password stays intact for reconnection

# Option B: Capture password before threading in sync/restore
def sync_one(name, retries=3):
    password = self._session.password  # capture before any close()
    session = ImapClient(password=password, ...)

@FernandoCelmer
FernandoCelmer merged commit 7bd3f02 into develop Apr 16, 2026
11 checks passed
@FernandoCelmer
FernandoCelmer deleted the feature/50 branch April 16, 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.

1 participant