Security Fixes — 2026-04-22
Audit type: Full OWASP Top 10 + HIPAA multi-tenant security reviewTotal issues fixed: 21 (5 Critical, 7 High, 3 Medium, 4 Low + 2 Low combined)
Deployed: 2026-04-22
CRITICAL Fixes
C-1 — patient role bypassed multi-tenant clinic isolation
File: server/src/middleware/clinic-context.ts lines 82, 91, 173What was wrong:
patient was listed alongside superadmin in the fallback that allows a user to access a clinic without a verified userClinicAssignments record. A patient whose primaryClinicId pointed to any clinic could pass the clinic context check and read/write that clinic’s PHI.Fix: Removed
|| user.role === 'patient' from all three bypass checks. Only superadmin retains platform-wide access. Patients must now have a verified active assignment record like all other roles.Impact prevented: Cross-tenant PHI access — HIPAA breach.
C-2 — File uploads trusted client-controlled MIME type (no magic byte check)
Files:server/src/lib/r2.ts, server/src/routes/files.tsWhat was wrong:
validateFileType() only checked the Content-Type string sent by the browser. An attacker could upload malware.exe with Content-Type: application/pdf and it would be accepted, stored, and served to other users as a PDF.Fix: Added
validateFileMagicBytes(buffer, mimeType) to R2Service that reads the first 12 bytes of the actual file and compares them against known magic numbers for all 10 allowed MIME types (JPEG, PNG, GIF, WebP, PDF, DOCX, XLSX, DOC, XLS, plain text). Both upload handlers (patient files and message attachments) now call this check after reading the buffer.Impact prevented: Malicious file storage and serving; potential malware delivery to clinic users.
C-3 — Hardcoded encryption key used for all non-production environments
File:server/src/lib/encryption.ts lines 26-35What was wrong: When
ENCRYPTION_KEY env var was missing, the code fell back to a hardcoded 32-byte key (odontox-dev-encryption-key-32-bytes-long!!!) for any environment not explicitly set to NODE_ENV=production. Staging and QA environments running with real patient data were effectively unencrypted — anyone with source code access could decrypt all PHI.Fix: Removed the entire fallback block. The function now throws immediately in ALL environments without the key:
throw new Error('ENCRYPTION_KEY environment variable is required...'). Every environment (dev, staging, prod) must have a real key set.Impact prevented: PHI decryptable by any developer or attacker with repo access. HIPAA encryption-at-rest violation.
C-4 — Rate limiting was in-memory only — completely ineffective on Cloudflare Workers
File:server/src/middleware/rate-limit.ts lines 87-103What was wrong:
createRateLimitMiddleware() checked for the MY_RATE_LIMITER Cloudflare binding and then called await next() without doing anything — the binding was never actually used. The in-memory rateLimitStore object is local to each V8 isolate. On CF Workers edge, each request may hit a different global instance, so counters were never shared and limits were never enforced.Fix: When
MY_RATE_LIMITER binding is present, the middleware now calls env.MY_RATE_LIMITER.limit({ key: routeKey }) and checks result.success. The route key includes both user ID and path prefix, providing per-user per-route counting across all edge instances.Impact prevented: Auth brute-force, credential stuffing, MFA bypass — all rate limits were silent no-ops in production.
C-5 — No CSRF protection on state-changing endpoints
File:server/src/api.ts lines 220-252What was wrong: The API accepted POST/PUT/PATCH/DELETE requests with no validation of the
Origin header. Combined with credentials: true on CORS, a page from any origin could attempt cross-site mutations.Fix: Added an
Origin header validation middleware applied to all non-GET/HEAD/OPTIONS requests. Requests without an Origin header (server-to-server, mobile, curl) pass through. Requests with an Origin that doesn’t match odontox.io, *.odontox.io, *.odontox.pages.dev, or localhost are rejected with HTTP 403 INVALID_ORIGIN.Impact prevented: Cross-site request forgery against authenticated clinic sessions.
HIGH Fixes
H-1 — patient role skipped all RBAC permission checks
File: server/src/middleware/permissions.ts lines 30, 69What was wrong: Both
requirePermission() and requireAnyPermission() had an early-return if (user.role === 'superadmin' || user.role === 'patient') return next(). Patient accounts bypassed the entire permission matrix and could invoke admin/staff-only endpoints.Fix: Removed
|| user.role === 'patient' from both short-circuits. Only superadmin bypasses the permission check.Impact prevented: Patient accounts invoking billing, staff management, and clinical data mutations.
H-2 — Open redirect via endsWith domain bypass on logout
File: server/src/routes/auth.ts around line 3130What was wrong: The logout redirect validation used
url.hostname.endsWith('odontox.io'). This passes for evilodontox.io. Also allowed any *.pages.dev subdomain — attackers could create phishing.pages.dev on Cloudflare and redirect logged-out users there for credential harvesting.Fix: Changed to exact checks:
hostname === 'odontox.io' || hostname.endsWith('.odontox.io'). Removed the pages.dev wildcard entirely. Also blocked double-slash relative redirects (//evil.com).Impact prevented: Post-logout phishing redirects to attacker-controlled sites.
H-3 — Unsanitized filename in Content-Disposition header (header injection)
File: server/src/routes/files.ts lines 353, 503What was wrong:
'Content-Disposition': \attachment; filename=”{file.fileName}"\`` where `file.fileName` came from user upload input. A filename containing `\r\n` injects arbitrary HTTP response headers (HTTP response splitting). **Fix:** Filenames are now sanitized with `replace(/[\r\n"\\;]/g, '_')` and encoded using RFC 5987 format: `filename*=UTF-8''`. This correctly handles unicode filenames and prevents header injection.Impact prevented: HTTP response splitting, cache poisoning, content-type override attacks.
H-4 — User email addresses logged to console throughout auth flow
File:server/src/routes/auth.ts (7 locations)What was wrong: Login attempts, user lookups, password failures, and verification results all logged the full email address:
console.log('[Signin] Login attempt for ${normalizedEmail} from IP: ${clientIp}'). Cloudflare Workers logs are accessible via dashboard and exportable to SIEM systems. Email addresses of patients and staff are HIPAA-covered PII.Fix: Removed email from all 7 console.log/error calls in the sign-in flow. Operational context (role, status, IP) is preserved; only the identifier is removed.
Impact prevented: PII/PHI leakage via application logs. HIPAA minimum-necessary-access violation.
H-5 — WhatsApp webhook verify token in plaintext in version-controlled wrangler.toml
File:server/wrangler.toml lines 39, 77What was wrong:
WHATSAPP_WEBHOOK_VERIFY_TOKEN = "odontox_whatsapp_verify_2026" was committed in plain text in both the root [vars] and [env.production.vars] sections. Anyone with repository read access could use this to impersonate WhatsApp webhook events and inject fake patient messages or appointment notifications.Fix: Removed both entries. Replaced with comment:
# WHATSAPP_WEBHOOK_VERIFY_TOKEN — moved to Cloudflare Secret. Must be added via wrangler secret put WHATSAPP_WEBHOOK_VERIFY_TOKEN before deploy.Impact prevented: Fake WhatsApp event injection; attacker could send arbitrary messages as patients.
H-6 — Password policy: 8 characters minimum, no complexity requirements
File:server/src/lib/validation.ts line 6What was wrong:
passwordSchema = z.string().min(8, ...). Passwords like password1, dental123, 12345678 were accepted. Staff credentials protect full PHI access.Fix: New requirements: minimum 12 characters, maximum 128, must include uppercase, lowercase, digit, and special character.
Impact prevented: Dictionary attacks and credential stuffing against staff accounts with PHI access.
H-7 — Impersonation tokens had 60-minute TTL with no revocation
File:server/src/routes/admin.ts lines 2046, 2061What was wrong: Superadmin impersonation tokens were signed with
'60m' expiry. If a token was leaked (logs, browser history, network capture), an attacker had 60 minutes of full user-level access with no way to revoke it before expiry.Fix: TTL reduced to
'15m'. Response expiresIn field updated to match.Impact prevented: Extended unauthorized access window if impersonation token is exposed.
MEDIUM Fixes
M-2 — MFA setup debug logs exposed JWT payload fields
File:server/src/routes/auth.ts (4 console.log lines)What was wrong: Four
console.log statements were left in the MFA setup verify endpoint: "MFA SETUP VERIFY START", "MFA SETUP VERIFY PARAMS" (logged token length and method), "MFA SETUP VERIFY PAYLOAD" (logged JWT sub, purpose, type), "MFA SETUP VERIFY SECRETS" (logged whether TOTP secret existed). The JWT payload structure aids attackers in crafting valid tokens.Fix: All four debug log statements removed.
Impact prevented: JWT structure leakage aiding token forgery analysis.
M-5 — File upload endpoint had no rate limit
File:server/src/routes/files.ts line 65What was wrong:
POST /files/upload had no rate limiting. An authenticated attacker (or compromised staff account) could rapidly upload thousands of files, exhausting R2 storage quota and causing service disruption.Fix: Added
createRateLimitMiddleware(20, 60000) as middleware on the upload route — maximum 20 uploads per user per minute.Impact prevented: Storage quota exhaustion, cost spike DoS, service availability impact.
LOW Fixes
L-1 — Medical PDF responses served with Content-Disposition: inline
Files: server/src/routes/invoices.tsx, billing.ts, quotations.tsx, receipts.tsx, admin.tsWhat was wrong: All PDF download endpoints used
inline disposition, which instructs browsers to render PDFs directly. While modern browsers are safe, older PDF viewer plugins can execute JavaScript inside PDFs. More importantly, inline is semantically incorrect for medical documents that should be saved, not previewed.Fix: Changed all 6 occurrences from
inline to attachment.Impact prevented: Potential JavaScript execution in legacy PDF viewers; better UX for medical document handling.
L-3 — Personal Gmail address hardcoded as production contact email
File:server/wrangler.toml lines 36, 74What was wrong:
CONTACT_FORM_EMAIL = "[email protected]" was set in production configuration. Support messages from patients and clinics (potentially containing PHI) were being routed to a personal Gmail account, which is not a HIPAA Business Associate Agreement-compliant email provider.Fix: Changed to
[email protected] in both the root and [env.production.vars] sections.Impact prevented: PHI in non-compliant email storage. HIPAA BAA exposure.
One Manual Action Required Before Deploy
The WhatsApp webhook token was removed from wrangler.toml. Add it as a Cloudflare secret:ENCRYPTION_KEY set — the dev fallback has been removed permanently.
Files Modified
| File | Issues Fixed |
|---|---|
server/src/middleware/clinic-context.ts | C-1 |
server/src/middleware/permissions.ts | H-1 |
server/src/lib/encryption.ts | C-3 |
server/src/middleware/rate-limit.ts | C-4 |
server/src/api.ts | C-5 |
server/src/lib/r2.ts | C-2 |
server/src/routes/files.ts | C-2, H-3, M-5 |
server/src/routes/auth.ts | H-2, H-4, M-2 |
server/src/lib/validation.ts | H-6 |
server/src/routes/admin.ts | H-7, L-1 |
server/src/routes/invoices.tsx | L-1 |
server/src/routes/billing.ts | L-1 |
server/src/routes/quotations.tsx | L-1 |
server/src/routes/receipts.tsx | L-1 |
server/wrangler.toml | H-5, L-3 |

