Skip to content

Commit fbfff93

Browse files
Merge pull request olivierlambert#9 from olivierlambert/target-1.0
Security hardening, structured logging & regression tests for 1.0
2 parents fa61db7 + f2a94c6 commit fbfff93

21 files changed

Lines changed: 1583 additions & 244 deletions

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6969
| Matrix-style initials | 0.17.1 | Two-letter avatar fallback (first+last name initials) across all pages |
7070
| Multiple availability windows | 0.18.0 | Define morning + afternoon slots with lunch breaks (multiple time windows per event type) |
7171
| Calendar reminders (VALARM) | 0.18.1 | ICS events include native calendar reminders (popup/notification) based on event type settings |
72+
| ICS timezone fix | 0.18.2 | ICS events use UTC times with Z suffix instead of floating times |
73+
| Version in sidebar | 0.18.2 | calrs version displayed in the dashboard sidebar |
74+
| CSRF protection | 1.0.0 | Double-submit cookie pattern on all 31 POST handlers |
75+
| Booking rate limiting | 1.0.0 | Per-IP rate limiting on all booking endpoints (10 req / 5 min) |
76+
| Input validation | 1.0.0 | Server-side validation on all user-submitted data |
77+
| Double-booking prevention | 1.0.0 | SQLite unique index + transactions prevent race conditions |
78+
| Crash-proof handlers | 1.0.0 | All web handler `.unwrap()` replaced with proper error handling |
79+
| Graceful shutdown | 1.0.0 | SIGINT/SIGTERM handling with in-flight request draining |
80+
| Structured logging | 1.0.0 | 50 tracing points across auth, bookings, CalDAV, admin, email |
81+
| Regression tests | 1.0.0 | 28 new tests (191 → 219) covering ICS, validation, CSRF |
7282

7383
## [Unreleased]
7484

85+
## [0.18.2] - 2026-03-12
86+
87+
### Fixed
88+
89+
- **ICS location field corruption** — LOCATION line in `.ics` calendar invites had trailing whitespace after CRLF, causing the ORGANIZER field to be interpreted as a continuation of LOCATION per RFC 5545 line folding rules. BlueMind and other strict CalDAV servers displayed the organizer info inside the location field.
90+
- **ICS floating times** — DTSTART/DTEND in `.ics` invites used floating times (no timezone) instead of UTC. Events appeared at the wrong time for guests in different timezones. Now converts to UTC with `Z` suffix via `convert_to_utc()`.
91+
- **Hardcoded UTC guest timezone**`confirm_booking` and `approve_booking_by_token` handlers passed `"UTC"` as guest timezone instead of the actual stored timezone, causing ICS times in approval emails to be wrong.
92+
- **Broken "Add source" link on dashboard overview** — pointed to `/dashboard/sources/add` instead of `/dashboard/sources/new`
93+
94+
### Added
95+
96+
- **Version display in sidebar** — calrs version shown at the bottom of the dashboard sidebar
97+
7598
## [0.18.1] - 2026-03-11
7699

77100
### Added

CLAUDE.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
| Auth (OIDC) | `openidconnect` 4.x | OpenID Connect SSO (Keycloak, etc.) with PKCE |
3232
| Sessions | `axum-extra` (cookies) | Server-side sessions in SQLite, HttpOnly cookies |
3333
| Email | `lettre` 0.11 | SMTP with STARTTLS, async tokio transport |
34+
| Logging | `tracing` + `tracing-subscriber` | Structured logging with env-filter |
35+
| HTTP tracing | `tower-http` 0.6 | TraceLayer for request-level observability |
3436
| Error handling | `anyhow` (app-level) + `thiserror` (lib-level) | Standard Rust pattern |
3537
| Config/paths | `directories` crate | XDG-compliant data dir: `$XDG_DATA_HOME/calrs` |
3638

@@ -59,7 +61,8 @@ calrs/
5961
│ ├── 012_reminders.sql ← reminder_minutes on event_types, reminder_sent_at on bookings
6062
│ ├── 013_booking_email.sql ← booking_email on users
6163
│ ├── 014_team_links.sql ← team_links, team_link_members, team_link_bookings tables
62-
│ └── 015_user_profile.sql ← title, bio, avatar_path on users
64+
│ ├── 015_user_profile.sql ← title, bio, avatar_path on users
65+
│ └── 016_booking_unique.sql ← partial unique index for double-booking prevention
6366
├── templates/
6467
│ ├── base.html ← base layout + CSS (light/dark mode)
6568
│ ├── dashboard_base.html ← sidebar layout (extends base.html, all dashboard pages extend this)
@@ -237,6 +240,18 @@ File: `src/web/mod.rs`, templates in `templates/`
237240

238241
**CalDAV write-back:** Confirmed bookings are pushed to the host's CalDAV calendar (if `write_calendar_href` is configured on the source). On cancellation, the event is deleted from CalDAV.
239242

243+
**Security hardening (1.0):**
244+
- **CSRF protection** — double-submit cookie pattern on all 31 POST handlers via `csrf_cookie_middleware`. Client-side JS injects `_csrf` hidden field. Multipart forms use query parameter.
245+
- **Booking rate limiting** — per-IP (10 req / 5 min) on all 4 booking handlers. Uses `X-Forwarded-For`.
246+
- **Input validation** — server-side on all booking forms (name 1–255, email format, notes max 5000, date max 365 days), registration, settings, avatar upload (content-type whitelist).
247+
- **Double-booking prevention** — partial unique index `idx_bookings_no_overlap` on `(event_type_id, start_at)` + `BEGIN IMMEDIATE` transactions.
248+
- **Crash-proof handlers** — all `.unwrap()` in web handlers replaced with proper error responses.
249+
250+
**Observability (1.0):**
251+
- **Structured logging**`tracing` crate with 50 log points across auth, bookings, CalDAV, admin, email, DB migrations. Configurable via `RUST_LOG` env var (default: `calrs=info,tower_http=info`).
252+
- **HTTP request tracing**`tower-http` `TraceLayer` logs every request (method, path, status, latency).
253+
- **Graceful shutdown** — SIGINT/SIGTERM handling with `with_graceful_shutdown()`, drains in-flight requests.
254+
240255
---
241256

242257
## CLI UX conventions

Cargo.lock

Lines changed: 77 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ lettre = { version = "0.11", default-features = false, features = ["tokio1-rustl
5353
axum = { version = "0.8", features = ["macros", "multipart"] }
5454
axum-extra = { version = "0.10", features = ["cookie", "form"] }
5555
minijinja = { version = "2", features = ["loader"] }
56+
tower-http = { version = "0.6", features = ["trace"] }
57+
58+
# Logging
59+
tracing = "0.1"
60+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
5661

5762
# Encryption (credential storage)
5863
aes-gcm = "0.10"

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
- **SQLite storage** — single-file WAL-mode database, zero ops
136136
- **CLI** — full command set for headless operation (init, source, sync, event-type, booking, config, user)
137137
- **Single binary** — no runtime dependencies beyond the binary itself
138+
- **Structured logging**`tracing` + `tower-http` for request-level observability, configurable via `RUST_LOG`
138139

139140
## Install
140141

@@ -335,6 +336,50 @@ Get certificates with certbot: `sudo certbot --nginx -d cal.example.com`.
335336

336337
> **Important:** Set `CALRS_BASE_URL` to your public URL (e.g. `https://cal.example.com`) so that OIDC redirect URIs and email links point to the right host.
337338

339+
## Observability
340+
341+
calrs uses structured logging via the `tracing` crate. All log output goes to stderr and is captured by systemd journal, Docker logs, or any log aggregator.
342+
343+
### Configuration
344+
345+
Set the log level via the `RUST_LOG` environment variable:
346+
347+
```bash
348+
# Default (recommended for production)
349+
RUST_LOG=calrs=info,tower_http=info
350+
351+
# Verbose (debug HTTP requests + internal events)
352+
RUST_LOG=calrs=debug,tower_http=debug
353+
354+
# Quiet (errors only)
355+
RUST_LOG=calrs=error
356+
```
357+
358+
### Log categories
359+
360+
| Category | Level | Events |
361+
|----------|-------|--------|
362+
| **Auth** | info/warn | Login success/failure, registration, logout, OIDC login |
363+
| **Bookings** | info | Created, cancelled, approved, declined, guest self-cancel, reminder sent |
364+
| **CalDAV** | info/error | Sync started/completed, write-back success/failure, source added/removed |
365+
| **Admin** | info/warn | Role changes, user enable/disable, auth/OIDC config updates, impersonation |
366+
| **Email** | debug/error | Delivery success, send failures |
367+
| **HTTP** | info | Every request via `tower-http` TraceLayer (method, path, status, latency) |
368+
| **Database** | info | Migration applied on startup |
369+
| **Server** | info | Startup, shutdown |
370+
371+
### Example output
372+
373+
```
374+
2026-03-12T14:30:00Z INFO calrs: calrs server listening on 127.0.0.1:3000
375+
2026-03-12T14:30:05Z INFO calrs::auth: user login email=alice@example.com ip=192.168.1.1
376+
2026-03-12T14:31:00Z INFO calrs::web: booking created booking_id=a1b2c3 event_type=intro guest=bob@example.com
377+
2026-03-12T14:31:01Z INFO tower_http::trace: response{method=POST path="/u/alice/intro/book" status=200 latency="45ms"}
378+
2026-03-12T14:31:02Z ERROR calrs::web: CalDAV write-back failed uid=a1b2c3@calrs error="connection refused"
379+
2026-03-12T14:32:00Z WARN calrs::auth: login failed email=eve@example.com ip=10.0.0.5
380+
2026-03-12T15:00:00Z WARN calrs::web: rate limited ip=10.0.0.5
381+
```
382+
338383
## CLI reference
339384
340385
```

docs/src/architecture.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ calrs/
2121
│ ├── 011_event_type_calendars.sql
2222
│ ├── 012_reminders.sql
2323
│ ├── 013_booking_email.sql
24-
│ └── 014_team_links.sql
24+
│ ├── 014_team_links.sql
25+
│ ├── 015_user_profile.sql
26+
│ └── 016_booking_unique.sql
2527
├── templates/ Minijinja HTML templates
2628
│ ├── base.html Base layout + CSS (light/dark mode)
2729
│ ├── auth/ Login, registration
@@ -109,6 +111,13 @@ calrs/
109111
| `/dashboard/team-links/*` | Team link management |
110112
| `/t/{token}` | Team link public slot picker + booking |
111113

114+
### Middleware
115+
116+
| Layer | Purpose |
117+
|---|---|
118+
| `TraceLayer` | Logs every HTTP request (method, path, status, latency) |
119+
| `csrf_cookie_middleware` | Sets `calrs_csrf` cookie on responses for CSRF protection |
120+
112121
## CalDAV client
113122

114123
Minimal RFC 4791 implementation:
@@ -128,7 +137,7 @@ Handles absolute and relative hrefs, BlueMind/Apple namespace prefixes, tags wit
128137
- CSS custom properties for theming
129138
- Dark mode via `prefers-color-scheme`
130139
- Responsive layout
131-
- No JavaScript framework — vanilla JS only where needed (timezone detection, provider presets)
140+
- No JavaScript framework — vanilla JS only where needed (timezone detection, provider presets, CSRF token injection)
132141

133142
## Email
134143

@@ -160,7 +169,7 @@ The approval request email includes Approve and Decline action buttons (table-ba
160169

161170
## Testing
162171

163-
calrs has an automated test suite with 147+ tests, run on every push and pull request via [GitHub Actions](https://github.com/olivierlambert/calrs/actions/workflows/ci.yml).
172+
calrs has an automated test suite with 219 tests, run on every push and pull request via [GitHub Actions](https://github.com/olivierlambert/calrs/actions/workflows/ci.yml).
164173

165174
**What's tested:**
166175

@@ -173,6 +182,8 @@ calrs has an automated test suite with 147+ tests, run on every push and pull re
173182
| Availability engine | Free/busy computation, buffer times, minimum notice, conflict detection |
174183
| Web server | Rate limiter (allow/block/reset/per-IP isolation) |
175184
| Authentication | Argon2 password hashing roundtrip, hash uniqueness |
185+
| Input validation | Booking name/email/notes/date validation, CSRF token verification |
186+
| ICS regression | UTC timezone suffix, location field integrity, convert_to_utc |
176187

177188
```bash
178189
# Run the full suite
@@ -199,3 +210,5 @@ Key crates:
199210
| `argon2` | Password hashing |
200211
| `openidconnect` | OIDC client |
201212
| `icalendar` | ICS parsing |
213+
| `tracing` + `tracing-subscriber` | Structured logging |
214+
| `tower-http` | HTTP request tracing (TraceLayer) |

docs/src/booking-flow.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
|---|---|
2222
| `confirmed` | Booking is active. Slot is blocked. Emails sent. |
2323
| `pending` | Awaiting host approval (when `requires_confirmation` is on). |
24-
| `cancelled` | Cancelled by host. Slot is freed. |
24+
| `cancelled` | Cancelled by host or guest. Slot is freed. |
2525
| `declined` | Declined by host (pending booking rejected). |
2626

2727
## Confirmation mode
@@ -48,6 +48,17 @@ From the dashboard, click **Cancel** on an upcoming booking:
4848
3. Both guest and host receive cancellation emails with a `METHOD:CANCEL` `.ics` attachment
4949
4. If the booking was pushed to CalDAV, the event is deleted from the calendar
5050

51+
### Guest self-cancellation
52+
53+
Guests can cancel their own bookings via a link in the confirmation email:
54+
55+
1. Click the "Cancel booking" link in the email
56+
2. Optionally enter a reason
57+
3. Confirm the cancellation
58+
4. Both guest and host are notified
59+
60+
The cancellation email correctly attributes who cancelled (host vs guest).
61+
5162
## Conflict detection
5263

5364
Before a booking is accepted, calrs checks for conflicts:
@@ -57,6 +68,8 @@ Before a booking is accepted, calrs checks for conflicts:
5768
- **Buffer times** — the buffer before/after is included in the conflict window
5869
- **Minimum notice** — slots too close to the current time are rejected
5970

71+
Additionally, a database-level unique index prevents two bookings from occupying the same slot, even if two guests submit simultaneously.
72+
6073
## CalDAV write-back
6174

6275
When a booking is confirmed (either directly or via approval), calrs can push the event to the host's CalDAV calendar. See [CalDAV Integration > Write-back](./caldav.md#caldav-write-back) for setup.
@@ -71,6 +84,7 @@ If SMTP is configured, calrs sends emails at these moments:
7184
| Booking pending | "Awaiting confirmation" notice | Approval request with Approve/Decline buttons |
7285
| Booking declined | Decline notice (with optional reason) ||
7386
| Booking cancelled | Cancellation + `.ics` CANCEL | Cancellation + `.ics` CANCEL |
87+
| Booking reminder | Reminder with cancel button | Reminder with details |
7488

7589
All emails are sent as **HTML with plain text fallback**. They include event title, date, time, timezone, location, and notes. The HTML templates are responsive and support dark mode in email clients that honor `prefers-color-scheme`.
7690

0 commit comments

Comments
 (0)