T25: opt-in PostHog + security review pass #24

Merged
dzenda merged 6 commits from t25-posthog-security into t24-self-hosting 2026-11-14 17:49:42 +01:00
Owner

T25: Opt-in PostHog (no PII) + security review pass

Stacked on #23 (t24-self-hosting) → … → #1. Merge those first.

PostHog (opt-in, privacy-first)

  • Off unless POSTHOG_KEY is set (POSTHOG_HOST defaults to the EU cloud). No SDK, no autocapture, no cookies.
  • The browser sends only a route template from an allowlist (/, /login, /calendar, /connectors, /assistants, /account; everything else becomes /other, so no /authorize?… or /auth/callback?code=…) plus a random per-browser id. It honours Do Not Track / Global Privacy Control.
  • POST /api/telemetry (same origin, 512-byte streaming cap) forwards the event from the Worker, so PostHog never sees the user's IP or user agent. Events carry $ip: null and $process_person_profile: false.

Security headers (every response)

Referrer-Policy: no-referrer (OAuth codes and states never leak through Referer), X-Content-Type-Options, X-Frame-Options: DENY and frame-ancestors 'none', base-uri, object-src, Permissions-Policy, COOP, and HSTS on https. Route-specific headers (the consent page's strict CSP) take precedence. Verified: MCP 401/streaming, OAuth redirects and the dev server all still work.

Whole-repository security audit

Astra audited the entire repo, not a diff. No P0/P1. Fixed:

  1. (P2) ALLOWED_USERS wasn't enforced for MCP, so a removed user kept access through the AI. There's now one shared policy (src/lib/allowlist.ts) for website sign-in and MCP, and excluded users' grants are revoked on use.
  2. (P2) Telemetry buffered the whole body before its size check → readBounded 512-byte streaming cap (moved to src/lib/bounded.ts).
  3. (P2) Public dynamic client registration had no throttle (90-day KV writes) → Workers rate-limit bindings: 10/min per IP and 100/min instance-wide. They're config-only, so no resource is created. Verified locally: 10× 201, then 429.
  4. (P3) FIT API responses were fully buffered → 2 MB streaming cap. The Grades tree walk is now iterative and bounded (2,000 items, depth 32) with a truncated flag (that flag came from Astra's diff review).

It also confirmed as sound: user isolation, CSRF (including TanStack's default server-function protection), OAuth/PKCE/state, SSRF and token exfiltration, crypto, injection/XSS/open redirects, credential exposure, deletion, headers and cookies, and dependency advisories.

docs/security.md

Data inventory, a threat → control → test table, and an audit log.

Verified

  • bun run lint && bun run typecheck && bun run test: 285 tests. New ones cover telemetry (allowlisted paths, event validation, payload without IP), security headers (precedence, immutable redirects, HSTS only on https), the rate limiter, the allowlist policy, the oversized upstream body, and the bounded and truncated Grades tree.
  • bun run dev: headers present on pages and on the /mcp 401, the rate limit triggers after 10 registrations, telemetry is a no-op without a key, and the signed-in calendar still renders.

🤖 Generated with Claude Code

## T25: Opt-in PostHog (no PII) + security review pass **Stacked on #23 (`t24-self-hosting`) → … → #1.** Merge those first. ### PostHog (opt-in, privacy-first) - Off unless `POSTHOG_KEY` is set (`POSTHOG_HOST` defaults to the EU cloud). No SDK, no autocapture, no cookies. - The browser sends only a **route template from an allowlist** (`/`, `/login`, `/calendar`, `/connectors`, `/assistants`, `/account`; everything else becomes `/other`, so no `/authorize?…` or `/auth/callback?code=…`) plus a **random per-browser id**. It honours **Do Not Track / Global Privacy Control**. - `POST /api/telemetry` (same origin, 512-byte streaming cap) forwards the event **from the Worker**, so PostHog never sees the user's IP or user agent. Events carry `$ip: null` and `$process_person_profile: false`. ### Security headers (every response) `Referrer-Policy: no-referrer` (OAuth codes and states never leak through `Referer`), `X-Content-Type-Options`, `X-Frame-Options: DENY` and `frame-ancestors 'none'`, `base-uri`, `object-src`, `Permissions-Policy`, `COOP`, and HSTS on https. Route-specific headers (the consent page's strict CSP) take precedence. Verified: MCP 401/streaming, OAuth redirects and the dev server all still work. ### Whole-repository security audit Astra audited the entire repo, not a diff. **No P0/P1.** Fixed: 1. *(P2)* **`ALLOWED_USERS` wasn't enforced for MCP**, so a removed user kept access through the AI. There's now one shared policy (`src/lib/allowlist.ts`) for website sign-in and MCP, and excluded users' grants are revoked on use. 2. *(P2)* Telemetry buffered the whole body before its size check → `readBounded` 512-byte streaming cap (moved to `src/lib/bounded.ts`). 3. *(P2)* **Public dynamic client registration had no throttle** (90-day KV writes) → Workers rate-limit bindings: 10/min per IP and 100/min instance-wide. They're config-only, so no resource is created. Verified locally: 10× 201, then 429. 4. *(P3)* FIT API responses were fully buffered → 2 MB streaming cap. The Grades tree walk is now iterative and bounded (2,000 items, depth 32) with a `truncated` flag (that flag came from Astra's diff review). It also confirmed as sound: user isolation, CSRF (including TanStack's default server-function protection), OAuth/PKCE/state, SSRF and token exfiltration, crypto, injection/XSS/open redirects, credential exposure, deletion, headers and cookies, and dependency advisories. ### `docs/security.md` Data inventory, a threat → control → test table, and an audit log. ### Verified - `bun run lint && bun run typecheck && bun run test`: 285 tests. New ones cover telemetry (allowlisted paths, event validation, payload without IP), security headers (precedence, immutable redirects, HSTS only on https), the rate limiter, the allowlist policy, the oversized upstream body, and the bounded and truncated Grades tree. - `bun run dev`: headers present on pages and on the `/mcp` 401, the rate limit triggers after 10 registrations, telemetry is a no-op without a key, and the signed-in calendar still renders. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ALLOWED_USERS enforced for MCP too (shared policy); excluded users' grants revoked on use
- telemetry: streaming 512-byte cap before parsing
- POST /oauth/register rate-limited per IP and instance-wide (Workers rate-limit bindings)
- FIT API bodies capped at 2 MB (streaming); Grades tree walk iterative and bounded
- readBounded moved to src/lib/bounded.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dzenda merged commit 57da6de659 into t24-self-hosting 2026-11-14 17:49:42 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dzenda/os!24
No description provided.