Skip to content

Security review fixes: dev stack bind, login throttle, GDPR erasure, practice isolation - #545

Merged
dholbach merged 8 commits into
mainfrom
fix/security-audit-run-1
Oct 28, 2026
Merged

dholbach merged 8 commits into
mainfrom
fix/security-audit-run-1

Conversation

@dholbach

Copy link
Copy Markdown
Owner

Fixes from a source review of the views, settings, uploads and calendar import. Each commit is a self-contained fix with a regression test; every new test fails on the previous code.

Live issues

  • Dev stack bound to every interface: docker-compose.yml ran gunicorn on 0.0.0.0:8000 with network_mode: host and DEBUG on, so it was reachable from the local network over plain HTTP. It binds 127.0.0.1 now, like Postgres and prod's default, and a guardrail test checks it.
  • Login lockout bypass: the throttle key wasn't NFKC-normalised while Django's UsernameField is, so compatibility spellings of a username each got a fresh counter.

GDPR erasure

  • The source inquiry is anonymised and imported calendar events are deleted. Both only SET_NULL their link, so they kept the client's details.
  • GebüH Leistungserfassung rows are deleted before sessions. They're PROTECT, so the erasure used to roll back.
  • The notification email is sent only after the deletion commits.

Practice isolation (latent with one practice)

  • PracticeScoped{List,Update,Delete}View fail closed: a practice-FK model without the scoped manager raises ImproperlyConfigured. MarketingPeriod gets the manager; it could be edited and deleted across practices. A guardrail test walks every scoped view.
  • Calendar client matching requires the practice's clients (practice_clients()). Imports, the quick action, billing and the approval queue ignore stored matches from another practice.
  • get_active_token / save_token with practice=None mean the practice-less token, not every practice. google_calendar.py is no longer exempt from the fail-open guardrail.
  • PendingCalendarEvent is unique on (practice, google_event_id) (migration 0009), and every lookup in fetch_calendar_events includes the practice.

Upgrading: migration 0009 runs on start; existing rows already satisfy the new constraint. Restart the dev stack so gunicorn rebinds.

Testing: ./dev.py test --fast passes (2055 Django tests plus the JS tests); makemigrations --check and ./dev.py lint are clean.

🤖 Generated with Claude Code

dholbach and others added 8 commits October 28, 2026 12:14
docker-compose.yml runs django with network_mode: host and bound gunicorn
to 0.0.0.0:8000 with DEBUG on, so the dev stack (which mounts the real
data directories) was reachable from the local network over plain HTTP.
Bind 127.0.0.1 like the postgres service and the prod compose default,
and add a guardrail test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthenticationForm normalizes the username with NFKC before the account
lookup, but the lockout key only lowercased the raw input. Fullwidth and
other compatibility spellings therefore reached the same account under a
fresh counter, defeating the 5-attempt limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Anonymise the inquiry the client was converted from and delete their
  imported calendar events; both only SET_NULL their link and kept the
  client's details after the erasure.
- Delete GebueH service records before sessions (PROTECT), which made
  the erasure roll back for those clients.
- Send the notification email only after the transaction has committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PracticeScoped{List,Update,Delete}View only filtered by practice when the
model's queryset had for_current_practice, and silently returned every row
otherwise. MarketingPeriod has a practice FK but a plain manager, so its
edit and delete views resolved any practice's row by pk.

Give MarketingPeriod the PracticeScopedManager, make the mixins raise
ImproperlyConfigured for a practice-FK model without one, and add a
guardrail test over every scoped view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_active_token/get_service(None) returned the newest active token of any
practice, and save_token(practice=None) deactivated every practice's
token. A signed-in user without a practice could therefore read another
practice's calendar, rewrite its calendar_id, or break its sync. None now
selects only the practice-less fallback token, and the guardrail no longer
exempts google_calendar.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CalendarEventParser fell back to every client of every practice, and none
of the callers passed a scoped list. A client code in practice A's
calendar (anyone can send an invite) therefore matched practice B's
client: the fetch command booked sessions onto it, and the approval
queue showed it with B's draft invoices.

- clients is now a required argument; callers pass practice_clients().
- Importing, the quick action and the billing and approval views ignore
  a stored match that belongs to another practice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PendingCalendarEvent.google_event_id was unique across all practices and
fetch_calendar_events looked rows up by event id alone. When two
practices' calendars returned the same event id, the second practice's
sync got the first practice's row back and its cancel/reschedule
transitions rewrote that practice's Session. The key is now (practice,
google_event_id), and every lookup in the command includes the practice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dholbach
dholbach merged commit f8e9031 into main Oct 28, 2026
7 checks passed
@dholbach
dholbach deleted the fix/security-audit-run-1 branch October 28, 2026 11:27
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