Security audit round 3 (one High, seven Mediums) plus a guest-side cancel/reschedule notice window. Also bundles two months of translation work merged from the long-lived i18n branch and a few self-hoster fixes that landed since 1.9.0.
Security
All eight items below were originally reported by @marcotama in a third-party audit; the High was fixed by the audit author themselves, the seven Mediums were addressed in this release.
- High — Login timing oracle leaked user existence (#77, fixed by @marcotama) —
login_handlershort-circuited to "Invalid email or password" before running Argon2 if the email wasn't registered, leaking a ~10ms vs ~microseconds gap that was usable to enumerate registered emails over the network. Fixed by always running Argon2 against a staticDUMMY_HASH(Argon2id withArgon2::default()parameters) when the user is missing or has no password set, so all three branches (user found + correct, user found + wrong, user not found) take the same time - Medium — OIDC client_secret stored in plaintext (#94) — CalDAV and SMTP credentials were already AES-256-GCM encrypted, but the OIDC client secret in
auth_configsat alongside them as plaintext. Newcrypto::encrypt_value/decrypt_valueAPI with anenc:v1:sentinel prefix unambiguously distinguishes encrypted values from plaintext at migration time (the existing base64 envelope can collide with plaintext OIDC secrets that happen to look like base64). Existing plaintext values are transparently re-encrypted on next startup; the migration is idempotent and uses try-decrypt as a belt-and-suspenders against the prefix-collision edge case - Medium — Rate limiter trusted leftmost X-Forwarded-For (#90) — all six rate-limited handlers extracted the leftmost XFF value, which is exactly the attacker-controlled one (each proxy in the chain appends to the right). Rotating the header per request bypassed per-IP rate limits entirely. Consolidated the six copy-pasted extractions into
client_ip_for_rate_limit()and switched to the rightmost value (the trusted proxy's view of its peer).X-Real-IPis intentionally not honoured because neither default Caddy nor default Nginx overwrite a client-supplied value, so trusting it would have been a worse footgun - Medium — Stored XSS via
company_linkjavascript:scheme (#93) — the admin-controlled company link is rendered as a clickable anchor on every public booking page; an admin (or attacker who took over an admin account) could setjavascript:alert(1)and land arbitrary script on every visitor. Newis_safe_company_link()allowlistshttp(s)://only and is enforced on both write (admin handler returns the user to/dashboard/admin?error=...) and read (silently drops bad values as defense in depth) - Medium — Internal errors leaked to clients (#91) — template render, database, and OIDC errors were
format!'d straight into HTTP response bodies, leaking template paths, schema hints, IdP URLs, and occasionally token contents. ~144 sites insrc/auth.rsandsrc/web/mod.rsnow route through one ofinternal_error_response/internal_error_html/internal_error_body, all of which log the underlying detail viatracing::error!and return a generic message. OIDC has its ownoidc_error_responsewith auth-flow-specific text. Operator-facing CalDAV source-test/sync feedback is intentionally preserved, since the only viewer is the admin debugging their own configuration - Medium — TOCTOU race in first-admin role assignment (#89) — three sites (registration handler, OIDC auto-register, CLI
user create) computed the first-user-is-admin role with a separatehas_any_users()SELECT before the INSERT, letting two concurrent registrations both observe an empty users table and both claim admin on a fresh DB. All three sites now compute the role atomically inside the INSERT viaCASE WHEN NOT EXISTS (SELECT 1 FROM users) THEN 'admin' ELSE 'user' END. Extractedauth::create_local_userso the web and CLI paths share one helper and the test exercises the production code path - Medium — Session tokens used userspace
thread_rng(#86) —generate_session_tokenusedrand::thread_rng()(a userspace ChaCha12 PRNG) for 30-day session secrets whilecrypto.rsalready usesOsRng(kernel CSPRNG viagetrandom) for AES-GCM keys/nonces. Switched toOsRng.fill_bytes, matching the existing pattern. Output shape unchanged (32 bytes hex-encoded → 64 chars) - Medium — CSRF token comparison was not constant-time (#87) —
verify_csrf_tokenusedString == String, which short-circuits on the first differing byte. Replaced withsubtle::ConstantTimeEq::ct_eqon the underlying byte slices. Risk in practice was low (network jitter dwarfs the leaked timing and CSRF tokens are UUID v4) but the fix is one extra direct dependency on a crate that was already transitively pulled in viaargon2
The remaining Lows and Informationals from the same audit are tracked in issue #85 as a punch list.
Added
- Minimum notice for guest cancel and reschedule (closes #95) — two new optional
event_typescolumns (cancel_notice_min,reschedule_notice_min);NULLor0keeps the previous behaviour of allowing cancel/reschedule at any time. Within the configured window, the four guest token endpoints (/booking/cancel/{token}and/booking/reschedule/{token}, GET + POST) render a friendlybooking_action_blockedpage showing the host's contact email instead of mutating booking state. Host- and admin-initiated cancellations from the dashboard are unaffected, since hosts often need to act on real-world emergencies on behalf of a guest. Policy is also surfaced inline on the confirmed page and in the localized confirmation email body so guests aren't surprised at click time. Form fields use a numeric input + minutes/hours/days unit selector - Admin user deletion (#70) — admins can permanently delete users from the admin panel with cascade rules and a confirmation prompt. Self-delete and last-admin delete are blocked; users with future bookings as host are blocked unless they are deleted via the dashboard with explicit acknowledgement
- Estonian locale (
et) — first community-language slot beyond the original four. Stub file is empty (runtime falls back to English on missing keys); new keys are added toi18n/en/main.ftlonly and Weblate picks them up at the next sync
Changed
- Clippy on tests in CI (#75) —
cargo clippy --all-targets -- -D warningsnow also covers test code, catching a class of regressions the previousclippystep missed
Fixed
- Event-type availability defaults respect the user's profile (closes #68, #69) — newly-created event types now seed their availability rules from the creator's per-user default working hours rather than a blanket Mon-Fri 9-17 fallback when the form is submitted without explicit windows
- CalDAV connection check falls back to PROPFIND when OPTIONS doesn't advertise
calendar-access(#71) — some CalDAV servers (notably some SOGo deployments) don't advertisecalendar-accessin theDAV:OPTIONS response header even though they support the protocol; calrs now retries with a PROPFIND probe before giving up, fixing connection-test failures for those backends - Various i18n context-plumbing fixes in
user_profile, public profile, settings, footer, and booking pages (translations rolled in via thei18n → mainmerge)
Internal
- 624 tests total (up from 575 in 1.9.0), all green on pre-commit
crypto::encrypt_value/decrypt_valueintroduced for fields where stored values can ambiguously look like plaintext; future credential additions should use these instead ofencrypt_passworddirectly when migration disambiguation matters- New
client_ip_for_rate_limit(),internal_error_response()(+_html/_body),oidc_error_response(),is_safe_company_link(),auth::create_local_user(),check_notice_window()helpers consolidate copy-pasted handler code - Two months of community translation work merged from the long-lived
i18nbranch (the standardi18n → maindirection;main → i18nremains an explicit anti-pattern)