Skip to content

Phase 1: multi-user login + roles + per-user mailboxes - #89

Merged
jaylfc merged 2 commits into
mainfrom
feat/multiuser-auth
Jun 20, 2026
Merged

Phase 1: multi-user login + roles + per-user mailboxes#89
jaylfc merged 2 commits into
mainfrom
feat/multiuser-auth

Conversation

@jaylfc

@jaylfc jaylfc commented Jun 20, 2026

Copy link
Copy Markdown
Owner

First slice of the PBX direction (the rest — extensions, transfer, conference, softphones — is Phase 2+). App layer only; no Twilio usage.

What

An optional multi-user account layer, fully backward-compatible with single-DASHBOARD_TOKEN installs (the Mac mini keeps working unchanged).

  • accounts.py — JSON-backed users store (werkzeug password hashing, no new deps), CRUD + verify, roles (admin/user), extension + mailbox fields, chmod 600, gitignored.
  • Auth — sessions now carry the user; _current_user() resolves session → user, or TOKEN_ADMIN for token/first-run. The Bearer/?token paths are gated on DASHBOARD_TOKEN so an install that adds users but no token can't be bypassed with an empty token.
  • RBAC_is_admin_only() gates user management, provider install, and settings writes (403 for non-admins).
  • /login — takes username+password; the dashboard token still works in the password field (blank username). Rate-limited.
  • EndpointsGET /api/me, GET/POST /api/users, PATCH/DELETE /api/users/<id> (admin).
  • Mailboxes/voicemails is scoped to the logged-in user's mailbox; admins (*) see all.

Verified

+12 tests (42 total): accounts store, password login, token back-compat, RBAC 403s, unauth 401, mailbox scoping. Full suite green.

Not in this PR

The user-facing Users admin section + user-aware UI land with the dashboard rebuild. This is the backend + login page only.

Summary by CodeRabbit

  • New Features
    • Multi-user account system with username and password authentication
    • Role-based access control with admin and user roles
    • User management capabilities to create, update, and delete accounts
    • Session-based authentication with improved security
    • Per-user mailbox scoping for voicemail access
    • Admin-only dashboard endpoints for settings and user management

Optional multi-user account layer on the dashboard, fully backward-compatible
with single-token installs.

- accounts.py: JSON-backed users store (werkzeug password hashing, no new deps),
  CRUD + verify, roles (admin/user), extension + mailbox fields, chmod 600.
- server.py: sessions carry the user; _current_user resolves session -> user (or
  TOKEN_ADMIN for token/first-run, gated on DASHBOARD_TOKEN so an install with
  users but no token can't be bypassed with an empty token). RBAC (_is_admin_only)
  gates user management, provider install, and settings writes. /login accepts
  username+password (dashboard token in the password field still works); login is
  rate-limited. New: /api/me, /api/users CRUD (admin). /voicemails is mailbox-scoped
  (admins see all). Login page gains a username field.
- DASHBOARD_TOKEN stays the admin break-glass; token-only installs are unchanged.
  users.json gitignored.
- Tests +12 (42 total): store, password login, token back-compat, RBAC 403s,
  unauth 401, mailbox scoping.

Telephony (extensions, transfer, conference, softphone) is Phase 2+; this is the
app-layer foundation only, no Twilio usage.
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jaylfc, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 52 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4a0291a6-032b-467d-88c8-e77ea7871bcc

📥 Commits

Reviewing files that changed from the base of the PR and between 06f5680 and d9fed8b.

📒 Files selected for processing (2)
  • server.py
  • tests/test_accounts.py
📝 Walkthrough

Walkthrough

A new accounts.py module implements a JSON-backed multi-user account store with CRUD operations, password hashing, role validation, and a verify function. server.py is extended with session cookies tied to user IDs, IP-based login rate limiting, admin-only route enforcement, and new /api/me, /api/users, and /api/users/<uid> endpoints. users.json is added to .gitignore.

Changes

Multi-user Account Store and Server Auth

Layer / File(s) Summary
accounts.py: storage config, helpers, and read accessors
accounts.py
Defines ACCOUNTS_FILE, ROLES, a module-level RLock, internal _load/_save (with chmod(0o600)) and _public helpers, and the count, list_users, get read-only accessors.
accounts.py: create, update, delete, verify
accounts.py
Implements create (required-field validation, case-insensitive uniqueness, password hashing, persist), update (selective field updates, conditional re-hash, role validation), delete (remove by id, boolean return), and verify (case-insensitive match, active check, hash check, returns public dict or None).
Test suite, server auth/RBAC, and gitignore
tests/test_accounts.py, .gitignore
_fresh fixture isolates per-test persistence; TestAccountsStore covers CRUD, verify, uniqueness, inactive users, and mailbox defaults; TestAuth covers token login back-compat, username/password login, 401 flows, admin RBAC enforcement, user management endpoints, and mailbox-scoped voicemail filtering. users.json added to .gitignore.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant server.py
  participant accounts.py
  participant users.json

  Client->>server.py: POST /login (username, password)
  server.py->>accounts.py: verify(username, password)
  accounts.py->>users.json: _load()
  users.json-->>accounts.py: user records
  accounts.py-->>server.py: public user dict or None
  server.py-->>Client: session cookie + 200 or 401

  Client->>server.py: GET /api/me (session cookie)
  server.py->>accounts.py: get(user_id)
  accounts.py-->>server.py: public user dict
  server.py-->>Client: {username, role, ...}

  Client->>server.py: POST /api/users (admin session)
  server.py->>accounts.py: create(username, password, role, ...)
  accounts.py->>users.json: _save()
  accounts.py-->>server.py: new public user dict
  server.py-->>Client: 201 + new user record
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 A new users.json hops into the warren,
With hashed passwords stored, no plaintext is borrowin'.
create, verify, delete — the CRUD dance is done,
Admin-only routes guard each privileged run.
The rabbit rejoices: multi-user is won! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Phase 1: multi-user login + roles + per-user mailboxes' directly and comprehensively summarizes the main changes across all modified files: the new accounts.py module (multi-user accounts), server.py updates (login, session handling, and role-based access control), and mailbox scoping by user.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/multiuser-auth

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 and usage tips.

Comment thread server.py
Comment on lines +933 to +946
username = (data.get("username") or "").strip()
password = data.get("password") or data.get("token") or ""
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "?").split(",")[0].strip()
if _login_locked(ip):
return jsonify({"status": "error", "detail": "too many attempts, try later"}), 429
user, uid = None, None
if username:
user = accounts.verify(username, password)
uid = user["id"] if user else None
elif password and DASHBOARD_TOKEN and hmac.compare_digest(password, DASHBOARD_TOKEN):
user = TOKEN_ADMIN # back-compat: dashboard token in the password field
if user is None:
_record_login_fail(ip)
return jsonify({"status": "error"}), 401

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Security: Login rate-limit bypassable via spoofed X-Forwarded-For

The dashboard login lockout in login() keys on request.headers.get("X-Forwarded-For", request.remote_addr or "?").split(",")[0] (server.py:935). The first X-Forwarded-For value is fully client-controlled, so an attacker brute-forcing a user's password or the DASHBOARD_TOKEN can send a different (random/rotating) X-Forwarded-For header on each request and never trip _login_locked, defeating the lockout entirely. The PIN tracker keys on caller which isn't spoofable, but this new path is.

Fix: only trust X-Forwarded-For when the request comes through a known/trusted reverse proxy; otherwise key the rate limiter on request.remote_addr (the real socket peer). If running behind a proxy, use a fixed trusted-proxy configuration (e.g. werkzeug ProxyFix with a known hop count) rather than blindly taking the leftmost XFF value.

Was this helpful? React with 👍 / 👎

Comment thread server.py
Comment on lines +978 to +990
@dashboard_app.route("/api/users/<uid>", methods=["PATCH", "DELETE"])
def api_user(uid):
if request.method == "DELETE":
if uid == g.user.get("id"):
return jsonify({"error": "cannot delete your own account"}), 400
return jsonify({"status": "ok"}) if accounts.delete(uid) else (jsonify({"error": "not found"}), 404)
d = request.json or {}
allowed = {k: d[k] for k in ("display_name", "role", "extension", "mailbox", "active", "password") if k in d}
try:
user = accounts.update(uid, **allowed)
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify(user) if user else (jsonify({"error": "not found"}), 404)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Edge Case: Admin can lock out all admin access (last-admin demotion)

api_user (server.py:978-990) blocks deleting your own account, but it still allows an admin to PATCH their own role to user, set active=False on themselves, or demote/deactivate/delete the only other admin. accounts.update/delete perform no "at least one active admin must remain" check. If DASHBOARD_TOKEN is not set (a supported config once users exist — see _current_user), losing the last admin permanently disables user management, provider install, and settings writes with no recovery path.

Fix: before applying a PATCH that sets role!='admin' or active=False, or a DELETE, verify at least one other active admin remains, and reject self-demotion of the last admin with a 400. Consider enforcing this invariant inside accounts.update/accounts.delete so it can't be bypassed by future callers.

Was this helpful? React with 👍 / 👎

Comment thread accounts.py
Comment on lines +90 to +104
def update(user_id, **fields):
with _lock:
users = _load()
for u in users:
if u["id"] != user_id:
continue
pw = fields.pop("password", None)
if pw:
u["password_hash"] = generate_password_hash(pw)
if "role" in fields and fields["role"] not in ROLES:
raise ValueError("invalid role")
for k in ("display_name", "role", "extension", "mailbox", "active"):
if k in fields and fields[k] is not None:
u[k] = fields[k]
_save(users)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Bug: update() does not normalize mailbox like create()

accounts.create normalizes mailbox with (mailbox or username).strip().lower() (accounts.py:81), but accounts.update writes fields[k] verbatim for mailbox (accounts.py:101-103). Mailbox scoping in list_voicemails compares vm.get("mailbox", "general") == mailbox exactly (server.py:1025-1027). If an admin PATCHes a user's mailbox to e.g. "Alice" or " alice", scoping silently breaks and the user sees no voicemails. Normalize mailbox (and trim extension) on update the same way create does.

Was this helpful? React with 👍 / 👎

Comment thread accounts.py
Comment on lines +119 to +127
def verify(username, password):
"""Return the public user dict on valid credentials, else None."""
with _lock:
for u in _load():
if u["username"].lower() == (username or "").lower():
if u.get("active", True) and check_password_hash(u["password_hash"], password or ""):
return _public(u)
return None
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Security: Username enumeration via timing in verify()

accounts.verify (accounts.py:119-127) only calls check_password_hash when a matching username exists; for an unknown username it returns None immediately without doing any hashing work. The resulting timing difference lets an attacker enumerate valid usernames. To mitigate, perform a dummy check_password_hash against a constant fake hash when no user matches so the response time is comparable for valid and invalid usernames.

Was this helpful? React with 👍 / 👎

Comment thread accounts.py
Comment on lines +33 to +38
def _save(users):
ACCOUNTS_FILE.write_text(json.dumps(users, indent=2))
try:
ACCOUNTS_FILE.chmod(0o600) # contains password hashes
except OSError:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Bug: Non-atomic users.json write risks corruption/lost updates

_save (accounts.py:33-38) does ACCOUNTS_FILE.write_text(...) directly. The threading.RLock only serializes within a single process; if the dashboard ever runs with multiple workers/processes, concurrent read-modify-write cycles can drop updates, and a crash mid-write can leave a truncated/corrupt JSON file (which _load then silently treats as an empty user list, effectively losing all accounts). Write to a temp file and os.replace for atomicity, and consider file locking if multi-process is possible.

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 20, 2026

Copy link
Copy Markdown

Note

Your trial team has used its Gitar budget, so automatic reviews are paused. Upgrade now to unlock full capacity. Comment "Gitar review" to trigger a review manually.
Learn more about usage limits

Code Review ⚠️ Changes requested 0 resolved / 5 findings

Implements multi-user authentication, roles, and scoped mailbox access with backward compatibility. Significant issues include a rate-limit bypass via X-Forwarded-For, a risk of total admin lockout, data corruption from non-atomic file writes, and potential timing-based username enumeration.

⚠️ Security: Login rate-limit bypassable via spoofed X-Forwarded-For

📄 server.py:933-946 📄 server.py:299-313

The dashboard login lockout in login() keys on request.headers.get("X-Forwarded-For", request.remote_addr or "?").split(",")[0] (server.py:935). The first X-Forwarded-For value is fully client-controlled, so an attacker brute-forcing a user's password or the DASHBOARD_TOKEN can send a different (random/rotating) X-Forwarded-For header on each request and never trip _login_locked, defeating the lockout entirely. The PIN tracker keys on caller which isn't spoofable, but this new path is.

Fix: only trust X-Forwarded-For when the request comes through a known/trusted reverse proxy; otherwise key the rate limiter on request.remote_addr (the real socket peer). If running behind a proxy, use a fixed trusted-proxy configuration (e.g. werkzeug ProxyFix with a known hop count) rather than blindly taking the leftmost XFF value.

⚠️ Edge Case: Admin can lock out all admin access (last-admin demotion)

📄 server.py:978-990 📄 accounts.py:90-104

api_user (server.py:978-990) blocks deleting your own account, but it still allows an admin to PATCH their own role to user, set active=False on themselves, or demote/deactivate/delete the only other admin. accounts.update/delete perform no "at least one active admin must remain" check. If DASHBOARD_TOKEN is not set (a supported config once users exist — see _current_user), losing the last admin permanently disables user management, provider install, and settings writes with no recovery path.

Fix: before applying a PATCH that sets role!='admin' or active=False, or a DELETE, verify at least one other active admin remains, and reject self-demotion of the last admin with a 400. Consider enforcing this invariant inside accounts.update/accounts.delete so it can't be bypassed by future callers.

💡 Bug: update() does not normalize mailbox like create()

📄 accounts.py:90-104 📄 accounts.py:64-78 📄 server.py:1024-1028

accounts.create normalizes mailbox with (mailbox or username).strip().lower() (accounts.py:81), but accounts.update writes fields[k] verbatim for mailbox (accounts.py:101-103). Mailbox scoping in list_voicemails compares vm.get("mailbox", "general") == mailbox exactly (server.py:1025-1027). If an admin PATCHes a user's mailbox to e.g. "Alice" or " alice", scoping silently breaks and the user sees no voicemails. Normalize mailbox (and trim extension) on update the same way create does.

💡 Security: Username enumeration via timing in verify()

📄 accounts.py:119-127

accounts.verify (accounts.py:119-127) only calls check_password_hash when a matching username exists; for an unknown username it returns None immediately without doing any hashing work. The resulting timing difference lets an attacker enumerate valid usernames. To mitigate, perform a dummy check_password_hash against a constant fake hash when no user matches so the response time is comparable for valid and invalid usernames.

💡 Bug: Non-atomic users.json write risks corruption/lost updates

📄 accounts.py:33-38 📄 accounts.py:26-30

_save (accounts.py:33-38) does ACCOUNTS_FILE.write_text(...) directly. The threading.RLock only serializes within a single process; if the dashboard ever runs with multiple workers/processes, concurrent read-modify-write cycles can drop updates, and a crash mid-write can leave a truncated/corrupt JSON file (which _load then silently treats as an empty user list, effectively losing all accounts). Write to a temp file and os.replace for atomicity, and consider file locking if multi-process is possible.

🤖 Prompt for agents
Code Review: Implements multi-user authentication, roles, and scoped mailbox access with backward compatibility. Significant issues include a rate-limit bypass via X-Forwarded-For, a risk of total admin lockout, data corruption from non-atomic file writes, and potential timing-based username enumeration.

1. ⚠️ Security: Login rate-limit bypassable via spoofed X-Forwarded-For
   Files: server.py:933-946, server.py:299-313

   The dashboard login lockout in `login()` keys on `request.headers.get("X-Forwarded-For", request.remote_addr or "?").split(",")[0]` (server.py:935). The first X-Forwarded-For value is fully client-controlled, so an attacker brute-forcing a user's password or the DASHBOARD_TOKEN can send a different (random/rotating) X-Forwarded-For header on each request and never trip `_login_locked`, defeating the lockout entirely. The PIN tracker keys on `caller` which isn't spoofable, but this new path is.
   
   Fix: only trust X-Forwarded-For when the request comes through a known/trusted reverse proxy; otherwise key the rate limiter on `request.remote_addr` (the real socket peer). If running behind a proxy, use a fixed trusted-proxy configuration (e.g. werkzeug ProxyFix with a known hop count) rather than blindly taking the leftmost XFF value.

2. ⚠️ Edge Case: Admin can lock out all admin access (last-admin demotion)
   Files: server.py:978-990, accounts.py:90-104

   `api_user` (server.py:978-990) blocks deleting your own account, but it still allows an admin to PATCH their own role to `user`, set `active=False` on themselves, or demote/deactivate/delete the only other admin. `accounts.update`/`delete` perform no "at least one active admin must remain" check. If DASHBOARD_TOKEN is not set (a supported config once users exist — see `_current_user`), losing the last admin permanently disables user management, provider install, and settings writes with no recovery path.
   
   Fix: before applying a PATCH that sets `role!='admin'` or `active=False`, or a DELETE, verify at least one other active admin remains, and reject self-demotion of the last admin with a 400. Consider enforcing this invariant inside `accounts.update`/`accounts.delete` so it can't be bypassed by future callers.

3. 💡 Bug: update() does not normalize mailbox like create()
   Files: accounts.py:90-104, accounts.py:64-78, server.py:1024-1028

   `accounts.create` normalizes mailbox with `(mailbox or username).strip().lower()` (accounts.py:81), but `accounts.update` writes `fields[k]` verbatim for `mailbox` (accounts.py:101-103). Mailbox scoping in `list_voicemails` compares `vm.get("mailbox", "general") == mailbox` exactly (server.py:1025-1027). If an admin PATCHes a user's mailbox to e.g. "Alice" or " alice", scoping silently breaks and the user sees no voicemails. Normalize mailbox (and trim extension) on update the same way create does.

4. 💡 Security: Username enumeration via timing in verify()
   Files: accounts.py:119-127

   `accounts.verify` (accounts.py:119-127) only calls `check_password_hash` when a matching username exists; for an unknown username it returns None immediately without doing any hashing work. The resulting timing difference lets an attacker enumerate valid usernames. To mitigate, perform a dummy `check_password_hash` against a constant fake hash when no user matches so the response time is comparable for valid and invalid usernames.

5. 💡 Bug: Non-atomic users.json write risks corruption/lost updates
   Files: accounts.py:33-38, accounts.py:26-30

   `_save` (accounts.py:33-38) does `ACCOUNTS_FILE.write_text(...)` directly. The `threading.RLock` only serializes within a single process; if the dashboard ever runs with multiple workers/processes, concurrent read-modify-write cycles can drop updates, and a crash mid-write can leave a truncated/corrupt JSON file (which `_load` then silently treats as an empty user list, effectively losing all accounts). Write to a temp file and `os.replace` for atomicity, and consider file locking if multi-process is possible.

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Important

Your trial ends in 6 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

@coderabbitai coderabbitai 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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@accounts.py`:
- Around line 27-30: The _load() function currently catches both
FileNotFoundError and ValueError exceptions, treating both cases as empty
account lists. This masks malformed JSON files as if no users exist, which can
trigger unintended first-run admin initialization. Modify the exception handling
to only catch FileNotFoundError and return an empty list for that case, while
allowing ValueError (JSON parse errors) to propagate as exceptions. This way, a
missing file is treated as empty, but a corrupted users.json file will fail
explicitly rather than silently being interpreted as no configured users.
- Around line 101-103: The update() method is assigning raw values directly to
the user object for mailbox, extension, and active fields without applying the
same normalization and validation that the create() method performs. Review how
create() normalizes and validates these specific fields (mailbox, extension, and
active) to ensure consistent data handling, then apply the same normalization
and validation logic in the update() method before assigning the fields[k]
values to u[k] for these three fields.
- Around line 33-34: The _save function currently uses write_text() which is not
atomic and can result in a partially written or corrupted file if the process is
interrupted or crashes. Replace this with an atomic write pattern by first
writing the JSON data to a temporary file, then using atomic replacement
(os.replace or similar) to move the temp file to the final location. This
ensures that either the entire file is written successfully or the original file
remains untouched.

In `@server.py`:
- Around line 1024-1028: The `DELETE /voicemails/<sid>` and `GET
/voicemails/<sid>/audio` endpoints are missing the same mailbox authorization
check that was implemented in the `GET /voicemails` endpoint. For both
endpoints, retrieve the voicemail record by SID, then add a security check that
verifies the voicemail's mailbox matches the current user's mailbox (unless the
user is an admin with mailbox "*"). If the mailbox does not match, return a 403
Forbidden response before allowing the delete or audio retrieval operation to
proceed.
- Around line 932-935: Add input validation to check that the JSON payload is a
dictionary using isinstance(data, dict) before calling .get() methods on it, and
verify that the username value is a string before calling .strip() on it. This
prevents AttributeError exceptions when the JSON payload has an unexpected shape
(like a list or non-dict JSON) or when fields contain non-string values. Apply
this validation pattern consistently across all the affected request handlers in
the username extraction logic.
- Line 935: The IP extraction logic at line 935 for rate-limiting is vulnerable
because it directly trusts the client-controlled X-Forwarded-For header,
allowing attackers to bypass rate limits or poison lockouts. Replace the current
logic that prioritizes X-Forwarded-For with direct use of request.remote_addr
for rate-limit identity. Only use X-Forwarded-For if trusted proxy configuration
is explicitly implemented and validated in the application.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 957a3959-4c6e-45ac-98a6-55a7e2404587

📥 Commits

Reviewing files that changed from the base of the PR and between f9231ff and 06f5680.

📒 Files selected for processing (4)
  • .gitignore
  • accounts.py
  • server.py
  • tests/test_accounts.py

Comment thread accounts.py
Comment on lines +27 to +30
try:
return json.loads(ACCOUNTS_FILE.read_text())
except (FileNotFoundError, ValueError):
return []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fail-closed on malformed users.json instead of silently treating it as empty.

_load() currently converts JSON parse errors into []. That can cascade into unintended auth behavior because server.py uses accounts.count() == 0 as the first-run admin condition. A malformed store should not be interpreted as “no users configured”.

Suggested fix
 def _load():
     try:
         return json.loads(ACCOUNTS_FILE.read_text())
-    except (FileNotFoundError, ValueError):
+    except FileNotFoundError:
         return []
+    except ValueError as exc:
+        raise RuntimeError(f"Invalid accounts file: {ACCOUNTS_FILE}") from exc
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
return json.loads(ACCOUNTS_FILE.read_text())
except (FileNotFoundError, ValueError):
return []
def _load():
try:
return json.loads(ACCOUNTS_FILE.read_text())
except FileNotFoundError:
return []
except ValueError as exc:
raise RuntimeError(f"Invalid accounts file: {ACCOUNTS_FILE}") from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@accounts.py` around lines 27 - 30, The _load() function currently catches
both FileNotFoundError and ValueError exceptions, treating both cases as empty
account lists. This masks malformed JSON files as if no users exist, which can
trigger unintended first-run admin initialization. Modify the exception handling
to only catch FileNotFoundError and return an empty list for that case, while
allowing ValueError (JSON parse errors) to propagate as exceptions. This way, a
missing file is treated as empty, but a corrupted users.json file will fail
explicitly rather than silently being interpreted as no configured users.

Comment thread accounts.py
Comment on lines +33 to +34
def _save(users):
ACCOUNTS_FILE.write_text(json.dumps(users, indent=2))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Persist users.json atomically to avoid account-store corruption.

Direct write_text() can leave a partially written file on interruption/crash. For auth data, write-to-temp + atomic replace is safer.

Suggested fix
 def _save(users):
-    ACCOUNTS_FILE.write_text(json.dumps(users, indent=2))
+    tmp = ACCOUNTS_FILE.with_suffix(".json.tmp")
+    tmp.write_text(json.dumps(users, indent=2))
+    tmp.replace(ACCOUNTS_FILE)
     try:
         ACCOUNTS_FILE.chmod(0o600)  # contains password hashes
     except OSError:
         pass
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _save(users):
ACCOUNTS_FILE.write_text(json.dumps(users, indent=2))
def _save(users):
tmp = ACCOUNTS_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(users, indent=2))
tmp.replace(ACCOUNTS_FILE)
try:
ACCOUNTS_FILE.chmod(0o600) # contains password hashes
except OSError:
pass
🧰 Tools
🪛 ast-grep (0.43.0)

[info] 33-33: use jsonify instead of json.dumps for JSON output
Context: json.dumps(users, indent=2)
Note: Security best practice.

(use-jsonify)

🪛 Ruff (0.15.17)

[warning] 33-33: Missing return type annotation for private function _save

Add return type annotation: None

(ANN202)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@accounts.py` around lines 33 - 34, The _save function currently uses
write_text() which is not atomic and can result in a partially written or
corrupted file if the process is interrupted or crashes. Replace this with an
atomic write pattern by first writing the JSON data to a temporary file, then
using atomic replacement (os.replace or similar) to move the temp file to the
final location. This ensures that either the entire file is written successfully
or the original file remains untouched.

Comment thread accounts.py
Comment on lines +101 to +103
for k in ("display_name", "role", "extension", "mailbox", "active"):
if k in fields and fields[k] is not None:
u[k] = fields[k]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize and validate updated fields the same way create() does.

update() currently writes raw values for mailbox, extension, and active. This can break downstream assumptions (e.g., mailbox matching and boolean active checks).

Suggested fix
-            for k in ("display_name", "role", "extension", "mailbox", "active"):
-                if k in fields and fields[k] is not None:
-                    u[k] = fields[k]
+            if "display_name" in fields and fields["display_name"] is not None:
+                u["display_name"] = str(fields["display_name"]).strip() or u["username"]
+            if "role" in fields and fields["role"] is not None:
+                u["role"] = fields["role"]
+            if "extension" in fields and fields["extension"] is not None:
+                u["extension"] = str(fields["extension"]).strip()
+            if "mailbox" in fields and fields["mailbox"] is not None:
+                u["mailbox"] = str(fields["mailbox"]).strip().lower()
+            if "active" in fields and fields["active"] is not None:
+                if not isinstance(fields["active"], bool):
+                    raise ValueError("active must be a boolean")
+                u["active"] = fields["active"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for k in ("display_name", "role", "extension", "mailbox", "active"):
if k in fields and fields[k] is not None:
u[k] = fields[k]
if "display_name" in fields and fields["display_name"] is not None:
u["display_name"] = str(fields["display_name"]).strip() or u["username"]
if "role" in fields and fields["role"] is not None:
u["role"] = fields["role"]
if "extension" in fields and fields["extension"] is not None:
u["extension"] = str(fields["extension"]).strip()
if "mailbox" in fields and fields["mailbox"] is not None:
u["mailbox"] = str(fields["mailbox"]).strip().lower()
if "active" in fields and fields["active"] is not None:
if not isinstance(fields["active"], bool):
raise ValueError("active must be a boolean")
u["active"] = fields["active"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@accounts.py` around lines 101 - 103, The update() method is assigning raw
values directly to the user object for mailbox, extension, and active fields
without applying the same normalization and validation that the create() method
performs. Review how create() normalizes and validates these specific fields
(mailbox, extension, and active) to ensure consistent data handling, then apply
the same normalization and validation logic in the update() method before
assigning the fields[k] values to u[k] for these three fields.

Comment thread server.py Outdated
Comment on lines +932 to +935
data = request.json or {}
token = data.get("token", "")
if check_auth(token):
resp = jsonify({"status": "ok"})
resp.set_cookie(AUTH_COOKIE, _new_session(), httponly=True,
samesite="Strict", secure=request.is_secure, max_age=SESSION_TTL)
return resp
return jsonify({"status": "error"}), 401
username = (data.get("username") or "").strip()
password = data.get("password") or data.get("token") or ""
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "?").split(",")[0].strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate JSON payload shape before using .get(...) and string operations.

These handlers assume object payloads and string fields. Non-object JSON (or non-string username) can raise runtime exceptions and return 500s.

Suggested fix pattern
-        data = request.json or {}
-        username = (data.get("username") or "").strip()
+        data = request.get_json(silent=True)
+        if not isinstance(data, dict):
+            return jsonify({"status": "error", "detail": "invalid JSON body"}), 400
+        username_raw = data.get("username", "")
+        if username_raw is not None and not isinstance(username_raw, str):
+            return jsonify({"status": "error", "detail": "username must be a string"}), 400
+        username = (username_raw or "").strip()

Apply the same isinstance(data, dict) guard in /api/users and /api/users/<uid>.

As per coding guidelines, server.py: "Focus on security, input validation, and error handling. Check for hardcoded secrets."

Also applies to: 967-968, 984-985

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.py` around lines 932 - 935, Add input validation to check that the
JSON payload is a dictionary using isinstance(data, dict) before calling .get()
methods on it, and verify that the username value is a string before calling
.strip() on it. This prevents AttributeError exceptions when the JSON payload
has an unexpected shape (like a list or non-dict JSON) or when fields contain
non-string values. Apply this validation pattern consistently across all the
affected request handlers in the username extraction logic.

Source: Coding guidelines

Comment thread server.py Outdated
Comment thread server.py Outdated
Security review flagged two issues in the multi-user code:
- [HIGH] IDOR: mailbox scoping was only on GET /voicemails, not the per-sid
  handlers. A non-admin could DELETE or stream audio of any voicemail by SID,
  and /export/* dumped every mailbox. Centralized _vm_visible() and applied it
  to delete, audio, and both export routes (404 on mismatch, no existence leak).
- [MEDIUM] Login lockout keyed on attacker-controlled X-Forwarded-For, so an
  attacker could rotate the header to evade it. Now keyed per-account
  ("user:<username>", un-spoofable) for password logins; for token logins, on the
  client IP derived from XFF only via a trusted local proxy.
- Tests +4 (46 total): cross-mailbox delete/audio/export denied; account lockout
  survives IP/header rotation.
@jaylfc
jaylfc merged commit e796602 into main Jun 20, 2026
5 of 6 checks passed
@jaylfc
jaylfc deleted the feat/multiuser-auth branch June 20, 2026 17:37
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