Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save Loschcode/2f1ecebb1565378da2a0e99de492888e to your computer and use it in GitHub Desktop.

Select an option

Save Loschcode/2f1ecebb1565378da2a0e99de492888e to your computer and use it in GitHub Desktop.
Security Audit Report — Dashboard Clínicas (+Pacientes) — 2026-07-23

Security Audit Report — Dashboard Clínicas (+Pacientes)

Date: 2026-07-23 Scope: Full codebase at /Users/loschcode/Desktop/work/dashboard-clinicas Methodology: OWASP Top 10:2025, OWASP API Security Top 10:2023, OWASP Top 10 for LLM Applications:2025 Stack: React 19 + Vite 8 + Supabase (Postgres + Auth + Storage) + Vercel serverless + Stripe + Anthropic/OpenAI APIs


Executive Summary

The application is a medical clinic dashboard with Supabase as the backend. Auth flows are reasonably well-implemented via Supabase Auth. However, the audit identified critical gaps primarily in:

  1. No server-side role/authorization checks on API endpoints — all API functions check "is logged in?" but never "does this user have the right role?"
  2. Missing security headers — no CSP, HSTS, X-Frame-Options, or other headers configured anywhere
  3. Wildcard CORS on the chat endpointAccess-Control-Allow-Origin: * with credential-bearing requests
  4. No rate limiting on any endpoint (login, signup, AI chat, Stripe, import)
  5. Session tokens in localStorage (Supabase default) — vulnerable to XSS-based token theft
  6. 12 npm vulnerabilities including 1 critical (vitest) and 6 high

The majority of issues follow the classic "vibe-coded" pattern: authentication is present but authorization is absent. Every API endpoint validates the JWT but never checks if the user has the appropriate role (admin, super_admin, etc.) to perform the action.


A01 — Broken Access Control (incl. SSRF) · Critical

ID Item Verdict Evidence Severity Fix
A01-1 Object-level ownership (IDOR/BOLA) FAIL All API endpoints (api/chat.ts:1237, api/verify-account.ts:59, api/stripe/refund.ts:44, api/web-audit.ts:361, api/fuentes-scrape.ts:220, api/import-ghl-appointments.ts:182, api/import-ghl-operaciones.ts) verify the JWT is valid but never check if the user owns/has access to the specific resource (locationId, prepayment, etc.). Any authenticated user can trigger imports for any location, refund any prepayment, audit any web, etc. The api/stripe/refund.ts is especially critical: any logged-in user can refund any prepayment by passing any id. Critical Every API endpoint must check the user's role from approved_users after verifying the JWT. For example in api/stripe/refund.ts after line 45, add: const { data: au } = await verifyClient.from('approved_users').select('role, approved').eq('user_id', userResult.user.id).maybeSingle(); if (!au?.approved || !['admin','super_admin'].includes(au.role)) return json({ error: 'Forbidden' }, 403); Apply the same pattern to all API handlers.
A01-2 Function-level authorization (BFLA) FAIL Admin actions like user approval (UsersView.tsx:60-66), role changes (UsersView.tsx:132-138), "enter as user" (UsersView.tsx:99-130) are gated by isSuperAdmin in the frontend only (App.tsx:1040). The UI hides the buttons, but the Supabase RLS policies on approved_users appear to allow any authenticated user to UPDATE (based on the RLS file pattern supabase-rls-*-authenticated.sql). The entrarComo function calls a Supabase Edge Function admin-entrar-como which is not in this repo — its authorization must be verified separately. Critical Add Supabase RLS policies on approved_users that restrict UPDATE/DELETE to users with role = 'super_admin'. Example: CREATE POLICY "Only super_admin can update approved_users" ON approved_users FOR UPDATE USING (EXISTS (SELECT 1 FROM approved_users WHERE user_id = auth.uid() AND role = 'super_admin'));
A01-3 Deny-by-default PARTIAL PASS Supabase RLS is enabled on tables (many supabase-rls-*-authenticated.sql files). The frontend has AuthGate.tsx that blocks unauthenticated users. However, there is no middleware layer on the API functions — each handler must manually verify auth, and the pattern is copy-pasted (fragile). High Create a shared verifyAuth(req) utility in api/lib/auth.ts that all handlers import. This centralizes the auth check and makes it harder to forget. Include role-checking in this utility.
A01-4 No frontend-only gating FAIL Role checks exist only in the frontend. App.tsx:1040-1041: const isSuperAdmin = role === 'super_admin' and const isAdmin = role === 'admin' || role === 'super_admin' || role === null — the role === null fallback means any user without a role is treated as admin. The role comes from approved_users table via AuthContext.tsx:183-196, then is cached in localStorage (ROLE_CACHE_KEY). None of the API endpoints check roles. Critical 1) Remove the role === null fallback granting admin access (App.tsx:1041). 2) Add server-side role checks to every API endpoint. 3) The role should default to no-access, not admin.
A01-5 Multi-tenancy isolation FAIL Tenant/location ID is passed as a request parameter in multiple endpoints: api/web-audit.ts:366 (body.locationId), api/stripe/connect-init.ts:71 (body.location_id), api/import-ghl-appointments.ts:196-199 (body dates). The server never verifies that the authenticated user has access to that specific location. High Implement a location-access check: after JWT validation, query a mapping table (e.g., user_locations) to verify the user has access to the requested location_id.
A01-6 Direct object references FAIL api/stripe/refund.ts:49: Prepayment ID is accepted directly from the request body. api/fuentes-scrape.ts:225: Source ID accepted from body. No ownership validation beyond "is authenticated". UUIDs provide some obscurity but this is not a security control. High After verifying the JWT, check that the resource belongs to a location the user has access to.
A01-7 SSRF FAIL api/web-audit.ts:237-238: Takes a user-supplied url and navigates to it with Puppeteer (page.goto(url)). api/fuentes-scrape.ts:113-114: Same pattern. No URL validation, no blocklist for internal ranges. An attacker could point these at http://169.254.169.254/latest/meta-data/ or internal services. High In api/web-audit.ts and api/fuentes-scrape.ts, validate the URL before navigating: 1) Parse the URL and resolve the hostname. 2) Block private/link-local IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.169.254, 127.0.0.0/8, ::1, fd00::/8). 3) Only allow http: and https: schemes. Add a shared validateExternalUrl(url) function.
A01-8 CORS wildcard with credentials FAIL api/chat.ts:1217: 'Access-Control-Allow-Origin': '*' is set on the OPTIONS response. While Access-Control-Allow-Credentials is not explicitly set (so browsers won't send cookies), the endpoint accepts Authorization: Bearer tokens. Combined with XSS on any origin, this allows cross-origin token theft and API abuse. Medium Replace '*' with an explicit origin allowlist: const ALLOWED_ORIGINS = ['https://lab.maspacientes.io', 'http://localhost:5173']; const origin = req.headers.get('origin'); if (ALLOWED_ORIGINS.includes(origin)) headers['Access-Control-Allow-Origin'] = origin;
A01-9 Path traversal / forced browsing N/A No custom file-serving endpoints. Static files served by Vite/Vercel.

A02 — Security Misconfiguration · Critical

ID Item Verdict Evidence Severity Fix
A02-1 Security headers present FAIL No Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, or Permissions-Policy headers configured anywhere. vercel.json only has rewrite rules, no headers. index.html has no meta-tag CSP. Grep for all six headers returned zero matches. Critical Add a headers section to vercel.json: {"headers": [{"source": "/(.*)", "headers": [{"key": "X-Frame-Options", "value": "DENY"}, {"key": "X-Content-Type-Options", "value": "nosniff"}, {"key": "Referrer-Policy", "value": "strict-origin-when-cross-origin"}, {"key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()"}, {"key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload"}, {"key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://maps.googleapis.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://services.leadconnectorhq.com https://api.stripe.com; frame-src https://connect.stripe.com https://citas.maspacientes.io; font-src 'self' data:;"}]}]}
A02-2 Debug/dev mode off in prod PASS AUTH_DEBUG in AuthContext.tsx:34 is gated by import.meta.env.DEV. VITE_DEBUG_COTIZACIONES and VITE_DEBUG_DEMOS are optional debug flags, not default-on. No source maps in prod build config.
A02-3 Default credentials removed PASS .env.example contains a Supabase anon key (which is public by design) and no passwords/secrets. No default admin/admin credentials found. However, .env.example:5 contains a real Supabase anon key with the project reference yohtffzgmwtuxvnqwgyu embedded. While anon keys are meant to be public, it's cleaner to use a placeholder.
A02-4 Cloud storage not public NEEDS-MANUAL EntregaPublicView.tsx:59 calls supabase.storage.from('ediciones').getPublicUrl(...) — the ediciones bucket appears to be public. Need to verify that it doesn't contain sensitive data beyond intended public deliverables. Medium Verify in Supabase dashboard that storage buckets are configured with appropriate access policies. The ediciones bucket being public is intentional for client deliveries, but ensure no sensitive internal files are stored there.
A02-5 Database not exposed to internet NEEDS-MANUAL Supabase manages this. The app connects via the PostgREST API (supabase.co), not direct DB connections. Verify in Supabase dashboard that direct DB connections require SSL and are not publicly accessible without authentication.
A02-6 Directory listing disabled PASS .gitignore excludes .env, .claude/. Vercel doesn't serve directory listings by default. .git/ is not deployed.
A02-7 Admin panels auth-restricted PARTIAL PASS Admin panel (UsersView.tsx) is behind auth but role-gated only in the frontend (App.tsx:7931: isSuperAdmin && activeSection === 'usuarios'). The underlying Supabase table approved_users is accessible to any authenticated user via RLS. High See A01-2 fix — add RLS policies restricting approved_users mutations to super_admin.
A02-8 Unused features/endpoints stripped FAIL api/stripe/connect-start.ts is explicitly disabled (returns 403) but the file still exists and is deployed. While it correctly returns 403, dead code should be removed. Low Delete api/stripe/connect-start.ts entirely.

A03 — Software Supply Chain Failures · High

ID Item Verdict Evidence Severity Fix
A03-1 Dependencies exist and are reputable PASS All 30+ dependencies in package.json are well-known, reputable packages (React, Supabase, Radix UI, Stripe, Anthropic SDK, etc.). No suspicious or hallucinated package names. country-state-city (line 37) and tw-animate-css (line 55) are less common but real and established.
A03-2 Lockfile committed and honored PASS package-lock.json is committed (338KB).
A03-3 Known-vuln scan clean FAIL npm audit reports 12 vulnerabilities: 1 critical (vitest RCE), 6 high (brace-expansion DoS, ws memory issues, vite file read), 3 moderate (Anthropic SDK permissions, micromatch ReDoS), 2 low (babel file read). High Run npm audit fix to address the auto-fixable vulnerabilities. For the critical vitest vulnerability, update to vitest >= 3.2.6. For @anthropic-ai/sdk, update to >= 0.91.1. For ws, update to >= 8.20.2.
A03-4 No install-time script surprises NEEDS-MANUAL Not audited in this pass. Low Run `npm ls --all --json
A03-5 CI/CD secrets scoped and MFA-protected NEEDS-MANUAL No CI config files in the repo. Deployment is via Vercel. Verify in Vercel dashboard that environment variables are scoped to production/preview as appropriate and that team MFA is enabled.
A03-6 Pinned base images for containers N/A No Dockerfiles in the repo. Vercel manages the runtime.
A03-7 SBOM exists FAIL No SBOM file found. Low Generate with npx @cyclonedx/cyclonedx-npm --output-file sbom.json and keep it updated.

A04 — Cryptographic Failures · High

ID Item Verdict Evidence Severity Fix
A04-1 TLS everywhere PASS All external API calls use https:// (Supabase, Stripe, GHL, Anthropic, OpenAI). vite.config.ts:88: proxy to GHL uses secure: true. Vercel enforces HTTPS by default.
A04-2 Password hashing is strong PASS Password hashing is handled by Supabase Auth (bcrypt by default). No custom password hashing in the codebase.
A04-3 Secrets at rest PASS Secrets are in environment variables (process.env.SUPABASE_SERVICE_ROLE_KEY, process.env.STRIPE_SECRET_KEY, process.env.ANTHROPIC_API_KEY). Not committed to the repo. .env is in .gitignore. Git history shows no .env files were ever committed.
A04-4 No deprecated primitives PASS HMAC-SHA256 used correctly for Stripe Connect state signing (api/stripe/connect-callback.ts:38-49). Uses crypto.subtle (Web Crypto API). No Math.random() for tokens, no MD5/SHA1.
A04-5 Data minimization PASS PII is redacted in the chat AI responses (api/chat.ts:107-123): names, phones, emails, addresses are replaced with [redacted] before sending to Claude.
A04-6 Secrets in URLs/query strings FAIL api/verify-account.ts:95-98: The verify_secret is appended as a query parameter to the Edge Function URL: fnUrl.searchParams.set('secret', verifySecret). Query strings appear in server logs, proxy logs, and potentially in browser history for redirects. Medium Pass the secret as a header instead: headers: { 'X-Verify-Secret': verifySecret } and modify the Edge Function to read from the header.

A05 — Injection (SQL/NoSQL/OS/XSS) · High

ID Item Verdict Evidence Severity Fix
A05-1 Parameterized queries / ORM everywhere PASS All Supabase client queries use the builder pattern (.from().select().eq() etc.) which parameterizes automatically. The run_readonly_sql RPC (supabase-rpc-run-readonly-sql.sql) uses execute format(...) but is constrained to SELECT/WITH only, with DML/DDL keyword blocking and transaction_read_only = on. The api/chat.ts uses PostgREST URL building (buildPostgrestUrl) with proper parameter encoding.
A05-2 Server-side input validation FAIL No input validation library (zod, yup, joi) is used. API endpoints do minimal validation: api/stripe/refund.ts:49 just does String(body?.id ?? '').trim(). api/chat.ts trusts the messages and context objects from the client with only type assertions. api/import-ghl-appointments.ts:196-199 validates dates exist but not their format rigorously. High Add zod schemas to validate all API request bodies. Example for api/stripe/refund.ts: const RefundSchema = z.object({ id: z.string().uuid() }); const parsed = RefundSchema.safeParse(body); if (!parsed.success) return json({ error: 'Invalid input' }, 400);
A05-3 XSS PASS dangerouslySetInnerHTML is used in ChatDrawer.tsx:576,653 but the content goes through renderMarkdown() (src/lib/renderMarkdown.ts:17-20) which sanitizes via DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }). This is correct and safe. dompurify v3.4.0 is a recent, well-maintained version.
A05-4 No OS command execution PASS No child_process, exec, spawn, or shell command execution found in the codebase. Puppeteer is used but only for web scraping with URLs, not executing user commands.
A05-5 NoSQL/ORM operator injection PASS Supabase PostgREST doesn't support MongoDB-style operator injection. Filter operations are constrained to an explicit allowlist of operators in api/chat.ts:24-36 (FilterOp type).
A05-6 Template injection N/A No server-side template rendering. React handles all rendering client-side with JSX.

A06 — Insecure Design · High

ID Item Verdict Evidence Severity Fix
A06-1 Authorization model coherent FAIL Authorization is ad-hoc. Roles are defined (AuthContext.tsx:39: admin, ghl_expert, super_admin, closer, web_developer, editor, editor_manager) but only enforced in the frontend via conditional rendering. There is no central authorization policy, no RBAC middleware, no role-checking on any API endpoint. RLS provides table-level access for authenticated role but does not distinguish between app-level roles. Critical Implement a centralized authorization layer: 1) Create a checkRole(userId, requiredRoles[]) function used by all API endpoints. 2) Add RLS policies that use custom claims or a lookup to approved_users.role to restrict access per table. 3) Consider using Supabase custom claims (via auth.jwt()->>'role') set at login time.
A06-2 Sensitive workflow abuse protection PARTIAL PASS Password reset is handled by Supabase Auth (protected by default). The Stripe Connect flow has HMAC state validation with 30-min expiry (api/stripe/connect-callback.ts:69). However, the refund endpoint has no confirmation step or idempotency protection. Medium Add idempotency check to api/stripe/refund.ts: check if the prepayment already has a refund_id before processing.
A06-3 Business-logic limits server-side PASS Refund amounts are not client-controlled — Stripe refunds the full payment_intent amount (api/stripe/refund.ts:96: no amount parameter = full refund). Prices come from onboarding data in the DB, not from the client.
A06-4 Tenant/trust boundaries explicit FAIL No explicit tenant boundaries in the architecture. Location IDs come from the client request, not derived from the session. See A01-5. High See A01-5 fix.
A06-5 Rate limiting & resource quotas FAIL No rate limiting on any endpoint. The chat endpoint (api/chat.ts) has an internal MAX_TOOL_CALLS_PER_TURN = 50 and MAX_LOOP_ITERATIONS = 50 cap, but no per-user rate limit. An attacker could call /api/chat repeatedly to run up the Anthropic API bill. /api/web-audit launches Puppeteer + Claude + OpenAI per request — extremely expensive. Critical Implement rate limiting via Vercel Edge Middleware or a package like @upstash/ratelimit. Critical endpoints to rate-limit: /api/chat (5 req/min/user), /api/web-audit (2 req/min/user), /api/stripe/* (10 req/min/user), /api/import-ghl-* (1 req/min/user).

A07 — Authentication Failures · Critical

ID Item Verdict Evidence Severity Fix
A07-1 Auth enforced before sensitive logic PASS All API endpoints check the JWT before proceeding. The pattern is consistent: extract Bearer token, call supabase.auth.getUser(jwt), return 401 if invalid.
A07-2 Session management FAIL Supabase stores the session in localStorage (src/lib/supabase.ts:35: storageKey: 'maspacientes-dashboard-auth'). This is the Supabase default but means any XSS vulnerability = full account takeover (access token + refresh token). The tokens are not in httpOnly cookies. High Consider switching to Supabase PKCE flow with server-side session management, or at minimum ensure the CSP is tight enough to prevent XSS (currently no CSP exists — see A02-1). As an immediate mitigation, implement the CSP from A02-1.
A07-3 JWTs validated correctly PASS JWT validation is done by Supabase's auth.getUser(jwt) which verifies the signature server-side against Supabase's signing key. Not done client-side. Not trusting unverified claims.
A07-4 Brute-force / credential-stuffing defenses FAIL No rate limiting on login. The Login.tsx:19 has client-side detection of rate limit errors ('too many requests') suggesting Supabase Auth may have its own rate limiting, but this is Supabase's default (very generous — ~30 attempts/hour). No progressive lockout configured. High Configure Supabase Auth rate limits to be stricter. Add a rate limiter on the Vercel edge for the Supabase auth endpoints (or use Supabase's built-in rate limiting configuration).
A07-5 No user enumeration PASS Supabase Auth returns uniform error messages for login failures. Registration creates an approved_users row regardless, with approved: false.
A07-6 Password policy NEEDS-MANUAL Password policy is managed by Supabase Auth. Default minimum is 6 characters. No breach list checking visible in the codebase. Medium Configure Supabase Auth to require minimum 8 characters and enable HaveIBeenPwned integration if available.
A07-7 Password reset tokens PASS Handled by Supabase Auth which generates secure, single-use, time-limited tokens.

A08 — Software or Data Integrity Failures · High

ID Item Verdict Evidence Severity Fix
A08-1 Webhooks verify signatures FAIL No Stripe webhook handler exists in the codebase. The Stripe integration uses direct API calls for refunds and Connect OAuth, but there is no webhook endpoint to receive Stripe events (payment succeeded, refund completed, etc.). If webhooks are configured in Stripe to point at this app, they are unhandled. More importantly: if payment confirmations rely on client-side polling rather than webhook verification, a user could fake a successful payment. High If the app receives Stripe webhooks, add a handler at /api/stripe/webhook.ts that verifies the Stripe-Signature header using stripe.webhooks.constructEvent(body, sig, webhookSecret). If payment status is confirmed client-side, add server-side verification via webhook or Stripe API poll.
A08-2 No insecure deserialization PASS No pickle, unsafe YAML, or native serialization. JSON parsing is standard (JSON.parse).
A08-3 CI/CD integrity NEEDS-MANUAL No CI config files in the repo. Verify Vercel deployment settings.

A09 — Security Logging & Alerting Failures · Medium

ID Item Verdict Evidence Severity Fix
A09-1 Security events logged FAIL No security event logging. Auth failures return 401 but are not logged. API errors use console.error sporadically (e.g., api/stripe/refund.ts:109) but there is no structured logging, no audit trail, and no log aggregation. Medium Add structured logging to all API endpoints. At minimum, log: user ID, action, target resource, result (success/failure), IP address. Use Vercel's built-in logging or integrate with a service like Datadog (MCP server is available).
A09-2 Logs don't leak sensitive data FAIL api/stripe/refund.ts:109: console.error('stripe.refund.fail', JSON.stringify(data)) — this could log sensitive Stripe error details including partial card numbers. api/stripe/connect-callback.ts:115: console.error('stripe.oauth.token', JSON.stringify(tok)) — could log OAuth tokens. Medium Sanitize log output: strip sensitive fields before logging. Use a structured logger that automatically redacts known sensitive keys (token, secret, password, card, access_token).
A09-3 Alerting on abuse FAIL No alerting configured. No monitoring for repeated auth failures, unusual API patterns, or cost spikes on AI endpoints. Medium Set up alerts in Vercel or an external monitoring service for: failed auth attempts > 10/min, AI endpoint costs, unusual traffic patterns.
A09-4 Audit trail FAIL No audit trail for sensitive actions (user approval, role changes, refunds, "enter as user", imports). These actions modify data but leave no record of who did what. High Create an audit_log table in Supabase. Log at minimum: user_id, action, target, details, created_at. Critical actions to log: user approval/role changes, refunds, "entrar como", data imports, Stripe connections.

A10 — Mishandling of Exceptional Conditions · High

ID Item Verdict Evidence Severity Fix
A10-1 Fail closed, never open FAIL AuthContext.tsx:227-228: On approval check, if the cache says the user was previously approved (cachedUid === uid), the system sets approved=true optimistically before the server confirms. If the server query times out (line 176-177), the user remains approved from cache. This means: if the approval service is down, previously-approved users stay approved (acceptable), BUT the comment at line 253-256 says "No degradamos" — if a user is revoked while the server is unreachable, they keep access until the next successful check. Medium Add a maximum cache staleness check. If the cache is older than e.g. 24 hours and the server is unreachable, force a re-login rather than trusting stale cache indefinitely. Store the cache timestamp alongside the approval.
A10-2 No sensitive data in error responses PASS API endpoints return generic error messages. Supabase error messages are truncated (e.g., text.slice(0, 300) in api/chat.ts:605). No stack traces exposed.
A10-3 Transactions roll back FAIL api/import-ghl-appointments.ts: The import loop (lines 425-558) processes events one by one. If it fails mid-way (e.g., after inserting a contact but before inserting the demo), partial data remains. The error is logged per-event but the import continues. No transaction wrapping. Medium For data integrity, wrap related operations (contact insert + opportunity insert + demo insert) in a Supabase RPC that uses a database transaction. Or at minimum, make the import idempotent so re-running it fixes partial states.
A10-4 Global exception handler PASS Each API endpoint has try/catch at the top level. Unhandled errors return 500/502 with generic messages. Vercel also has a global error handler for serverless functions.
A10-5 Resource exhaustion guarded FAIL api/chat.ts:69: MAX_TOOL_CALLS_PER_TURN = 50 provides some protection, and MAX_ROW_LIMIT = 500 caps query results. But: 1) api/web-audit.ts:20: maxDuration: 300 (5 minutes!) — a single request can consume 5 minutes of compute + Puppeteer + Claude + OpenAI. 2) No upload size limits visible. 3) No query timeouts on PostgREST queries from the frontend (only run_readonly_sql has a 10s timeout). High 1) Add Vercel middleware to limit request body size. 2) Add timeouts to PostgREST queries. 3) Consider reducing maxDuration on expensive endpoints. 4) Add per-user daily quotas for AI-powered endpoints.

Cross-cutting: Secrets

ID Item Verdict Evidence Severity Fix
CC-S1 No secrets in repo/git history PASS .env is gitignored. git log -p -- '.env' returns nothing. No API keys, DB URLs with passwords, or private keys found in the source code.
CC-S2 No secret keys in frontend bundle PASS Frontend only uses VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY (both are publishable). VITE_GOOGLE_MAPS_API_KEY is also frontend-appropriate (restricted by HTTP referrer). Service-role keys, Stripe secret keys, and API keys are server-side only (process.env).
CC-S3 .env is gitignored PASS .gitignore:14: .env is listed. .env*.local also excluded.
CC-S4 Secrets injected via env/secret manager PASS Secrets come from process.env (Vercel environment variables).
CC-S5 Stripe Connect Client ID hardcoded FAIL api/stripe/connect-init.ts:23: const CLIENT_ID = process.env?.STRIPE_CONNECT_CLIENT_ID ?? 'ca_UiJ5oV3yc1ixvtZA477iD779wXGTPHX2' — a test-mode Client ID is hardcoded as a fallback. While Client IDs are not secret (they're public), hardcoding test credentials that could accidentally be used in production is a misconfiguration risk. Low Move the fallback Client ID to an environment variable only. In production, the env var should always be set; fail loudly if it's missing rather than falling back to test mode.
CC-S6 GHL IDs hardcoded FAIL api/import-ghl-appointments.ts:34-36: DEMOS_LOCATION_ID and DEMOS_CALENDAR_GROUP_ID have hardcoded defaults. api/stripe/connect-init.ts:24: Hardcoded redirect URI https://lab.maspacientes.io/api/stripe/connect-callback. These aren't secrets but are production-specific configuration that shouldn't be in source code. Low Move all hardcoded IDs and URLs to environment variables.

Cross-cutting: File Uploads

ID Item Verdict Evidence Severity Fix
CC-F1 Upload validation NEEDS-MANUAL File uploads appear to go directly to Supabase Storage from the client (no server-side upload endpoint in api/). Supabase Storage has configurable file size limits and MIME type restrictions, but these need to be verified in the Supabase dashboard. Medium Verify Supabase Storage bucket policies: set max file size (e.g., 50MB for videos, 5MB for images), restrict MIME types to allowed formats, and ensure uploaded files are served with Content-Disposition: attachment for non-image types.

Cross-cutting: CSRF

ID Item Verdict Evidence Severity Fix
CC-C1 CSRF protection PASS The app uses Bearer token authentication via Authorization header, not cookie-based auth. CSRF is not applicable for token-based APIs because browsers don't automatically attach custom headers in cross-origin requests. Supabase Auth tokens are in localStorage and sent via headers, not cookies.

Cross-cutting: Rate Limiting

ID Item Verdict Evidence Severity Fix
CC-R1 Rate limiting on sensitive endpoints FAIL No rate limiting on any endpoint: login, signup, password-reset (Supabase default only), AI chat, Stripe operations, web audit, imports. The only rate-limit awareness is a client-side error message check in Login.tsx:19. Critical See A06-5 fix. Implement rate limiting using Vercel Edge Middleware with @upstash/ratelimit or similar. Priority endpoints: /api/chat, /api/web-audit, /api/web-tasks, /api/fuentes-scrape (AI/compute-heavy), /api/stripe/* (financial), /api/import-ghl-* (data mutation).

Cross-cutting: Dashboard-specific

ID Item Verdict Evidence Severity Fix
CC-D1 Dashboard requires auth PASS AuthGate.tsx wraps the entire app. Unauthenticated users see only the login page. The App component is lazy-loaded only after authentication + approval.
CC-D2 Admin actions re-checked server-side FAIL See A01-2. Admin actions (approve user, change role, "enter as user") are only gated in the frontend. Critical See A01-2 fix.
CC-D3 No bulk data dump endpoints PASS The chat AI's run_readonly_sql has a 5000/10000 row cap. PostgREST queries from the frontend have pagination. No endpoint dumps all tenants' data without filtering.
CC-D4 Impersonation audited and role-gated PARTIAL PASS "Entrar como" (UsersView.tsx:99-130) is gated by isSuperAdmin in the UI and calls a Supabase Edge Function admin-entrar-como (not in this repo). The impersonation is logged in sessionStorage but not in any server-side audit log. The original session tokens are stored in sessionStorage (client-side only). High 1) The admin-entrar-como Edge Function must verify the caller is super_admin (verify this). 2) Add server-side audit logging for all impersonation events. 3) Consider adding a time limit to impersonation sessions.

Client ↔ Server Trust Boundary · Critical

ID Item Verdict Evidence Severity Fix
TSB-1 Tokens not stored where XSS can read them FAIL src/lib/supabase.ts:31-35: Supabase client uses persistSession: true with storageKey: 'maspacientes-dashboard-auth' — tokens are in localStorage. Any XSS vulnerability gives full account access. High See A07-2. Immediate mitigation: implement strict CSP (A02-1) to prevent XSS. Long-term: evaluate Supabase's PKCE + httpOnly cookie flow.
TSB-2 Short-lived access + rotating refresh PASS Supabase Auth uses short-lived JWTs (default 1 hour) with autoRefreshToken: true (supabase.ts:33). Refresh tokens rotate on use (Supabase default).
TSB-3 Server-side revocation exists PASS AuthContext.tsx:359-367: signOut() calls supabase.auth.signOut() which invalidates the session server-side. Supabase supports server-side session revocation.
TSB-4 Expiry enforced server-side PASS All API endpoints call supabase.auth.getUser(jwt) which validates expiry server-side.
TSB-5 Tokens never in URLs PASS Tokens are sent via Authorization: Bearer header in all API calls. Not in URLs or query strings. OAuth callback tokens are in the URL hash (Supabase default, cleaned up by the SDK).
TSB-6 OAuth redirect_uri allowlisted PASS AuthContext.tsx:353: redirectTo: window.location.origin — Supabase validates this against the configured redirect URLs in the project settings. api/stripe/connect-init.ts:24: REDIRECT_URI is hardcoded to https://lab.maspacientes.io/api/stripe/connect-callback. Verify in Supabase dashboard that the allowed redirect URLs list is tight (no wildcards).
TSB-7 OAuth state parameter verified PASS api/stripe/connect-callback.ts:58-74: State is HMAC-signed and verified with 30-minute expiry. Supabase OAuth uses its own CSRF protection.
TSB-8 No open redirects PASS No ?next= or ?returnTo= parameters found. api/stripe/connect-callback.ts:29: Redirect goes to hardcoded DASHBOARD URL.
TSB-9 No mass assignment / over-posting FAIL api/import-ghl-appointments.ts:196: The body is parsed as ImportRequestBody but TypeScript types are only compile-time — at runtime, any extra fields are silently accepted. The Supabase .insert() calls pass objects built from the body and external API responses without field allowlisting. While Supabase columns act as a natural allowlist, any new column added to a table could be writable by the client. Medium Explicitly destructure and allowlist fields from request bodies before passing them to Supabase.
TSB-10 Server-authoritative values PASS Prices, amounts, and totals come from Supabase/Stripe, not from the client. Refund amounts are not client-controlled.
TSB-11 IDs from session, not body FAIL The user's identity (user_id) comes from the JWT (good), but resource IDs (location_id, prepayment_id, etc.) come from the request body without ownership verification. See A01-1. High See A01-1 fix.
TSB-12 HSTS FAIL No Strict-Transport-Security header configured. See A02-1. Medium See A02-1 fix.
TSB-13 WebSocket/Realtime auth PASS Supabase Realtime is authenticated via the same JWT. Subscriptions respect RLS policies. The supabase.channel() API handles auth automatically.
TSB-14 Clickjacking protection FAIL No X-Frame-Options or CSP frame-ancestors directive. The dashboard can be framed by any site. High See A02-1 fix. Add X-Frame-Options: DENY and frame-ancestors 'none' in CSP.

API & Data-Exposure Attacks · High

ID Item Verdict Evidence Severity Fix
API-1 BOPLA / Excessive data exposure FAIL Frontend queries use select('*') extensively (App.tsx:2276, 2358, 2501, 2760, 2883, 2906, 3185, 3199, 3206 and many more). This returns all columns including any PII columns. While RLS restricts row access, it doesn't restrict column access. All fields of all rows the user can see are returned to the client. Medium Replace select('*') with explicit column lists in all queries. Create views that exclude PII columns for general use.
API-2 Anti-automation on sensitive flows FAIL No CAPTCHA, no device fingerprinting, no anti-automation on signup, login, or any flow. Medium Add CAPTCHA (e.g., Cloudflare Turnstile) to signup and login forms. Rate-limit (see CC-R1).
API-3 Unsafe consumption of third-party APIs PASS GHL API responses are validated for structure (api/import-ghl-appointments.ts:148-158). Errors are caught and handled.
API-4 Improper inventory management PASS api/stripe/connect-start.ts is disabled (returns 403). All other endpoints are active and documented. No forgotten old versions.
API-5 HTTP verb tampering PASS All API handlers check req.method at the top and return 405 for unsupported methods.

LLM / AI Integration Security · High

ID Item Verdict Evidence Severity Fix
LLM-1 Prompt injection PARTIAL PASS api/chat.ts:125-157: The system prompt is long and detailed. User messages are passed as-is in the messages array. The system prompt instructs Claude on allowed tables and operations. There's a ALLOWED_TABLES allowlist (chat.ts:75-101) that prevents querying arbitrary tables. However, the run_readonly_sql tool lets Claude generate arbitrary SELECT queries, which could be used to extract data from tables not in the allowlist (the SQL executes with the user's RLS context, but all authenticated users may have SELECT on many tables). The SQL keyword blocking in run_readonly_sql (line 31-36) prevents DML but doesn't prevent data exfiltration. Medium 1) Add table-level restrictions to run_readonly_sql by checking the FROM clause against ALLOWED_TABLES. 2) Consider adding a row-level cap on sensitive tables. 3) Monitor for unusual query patterns in the chat logs.
LLM-2 Insecure output handling PASS LLM output is rendered via renderMarkdown() which uses DOMPurify sanitization (renderMarkdown.ts:17-20). Not eval'd, not interpolated into SQL or shell commands.
LLM-3 Excessive agency FAIL The chat AI can: 1) Execute arbitrary SELECT SQL via run_readonly_sql (data extraction). 2) Create ClickUp tasks via create_clickup_task (chat.ts:659-728). 3) Add ClickUp dependencies. These actions are executed with the server's ClickUp API token (CLICKUP_API_TOKEN), not scoped to the user's permissions. Any authenticated user can use the chat to create ClickUp tasks or extract data. High 1) Scope ClickUp operations to the user's role — only admins should be able to create tasks. 2) Add confirmation before executing ClickUp mutations. 3) Consider removing or restricting the create_clickup_task tool for non-admin users.
LLM-4 Sensitive info & system prompt leakage PARTIAL PASS PII columns are redacted before sending to Claude (chat.ts:480-497). System prompt contains business logic but no secrets. However, the system prompt is very long and could be extracted via prompt injection ("repeat your system instructions"). Low Add a post-processing filter that checks Claude's response for patterns that look like system prompt leakage.
LLM-5 Unbounded consumption (cost/DoS) FAIL No per-user rate limit on /api/chat. Each request can trigger up to 50 tool calls and 50 loop iterations (chat.ts:69-70), each potentially including SQL queries and ClickUp API calls. A single malicious user could run up significant Anthropic API costs. Critical Add per-user rate limiting: max 10 chat requests per hour per user. Add daily cost caps per user. Monitor Anthropic API spending with alerts.
LLM-6 Provider keys server-side only PASS ANTHROPIC_API_KEY and OPENAI_API_KEY are in process.env (server-side only). Not exposed to the frontend.

Business Logic & Race Conditions · High

ID Item Verdict Evidence Severity Fix
BL-1 TOCTOU / double-spend FAIL api/stripe/refund.ts:55-63: The handler reads the prepayment status (status !== 'pagado' check), then issues the refund, then updates the status. Two concurrent requests could both pass the check and issue two refunds for the same payment. High Add a database-level lock: use UPDATE notif_prepayments SET status = 'procesando' WHERE id = $1 AND status = 'pagado' RETURNING * to atomically claim the refund. Only proceed if the UPDATE returns a row.
BL-2 Workflow/state-machine bypass PASS Opportunity stages are managed via Supabase (the stage values are well-defined). The import endpoints have clear state checks.
BL-3 Negative/overflow values NEEDS-MANUAL api/stripe/connect-callback.ts:77-83: parsePriceCents validates pesos > 0 and uses Math.round. However, there's no check for extremely large values. Low Add an upper bound check (e.g., pesos > 1_000_000 → reject).
BL-4 Idempotency on payments FAIL No idempotency keys on Stripe refund requests. See BL-1. High Add Idempotency-Key: refund-${id} header to the Stripe refund API call in api/stripe/refund.ts.

Denial of Service & Resource Abuse · Medium

ID Item Verdict Evidence Severity Fix
DoS-1 Unbounded queries/responses PARTIAL PASS run_readonly_sql has a 5000-10000 row cap and 10s timeout. PostgREST queries use pagination. However, frontend queries with select('*') over large tables could return large payloads. Medium Add VITE_SUPABASE_APPOINTMENTS_LIMIT as a hard cap (currently optional, default 500).
DoS-2 ReDoS PASS No user input fed into complex regexes. The regex patterns in the codebase are simple and don't have catastrophic backtracking.
DoS-3 Upload/decompression bombs NEEDS-MANUAL Verify Supabase Storage upload size limits. Medium Configure max upload size in Supabase dashboard.
DoS-4 Expensive endpoints throttled FAIL See A06-5 and CC-R1. /api/web-audit (5 min, Puppeteer + 2x AI), /api/fuentes-scrape (5 min, Puppeteer), /api/chat (Anthropic API) — all unthrottled. Critical See A06-5 fix.
DoS-5 Query timeouts PARTIAL PASS run_readonly_sql has statement_timeout = 10s. But direct Supabase client queries from the frontend have no explicit timeout (rely on Supabase default of 30s). Low Add signal: AbortSignal.timeout(15000) to long-running frontend queries.

Caching, CDN & Response Handling · Medium

ID Item Verdict Evidence Severity Fix
Cache-1 Authenticated responses not cached publicly FAIL API endpoints return JSON responses without Cache-Control headers (except SSE streams which have no-cache). Vercel's default caching could potentially cache authenticated API responses. Medium Add Cache-Control: private, no-store to all API responses that return user-specific data.
Cache-2 No sensitive data in URLs PASS Sensitive data is in request bodies and headers, not URLs.

Frontend Supply Chain & Third-Party Scripts · Medium

ID Item Verdict Evidence Severity Fix
FE-1 SRI on third-party scripts N/A No third-party CDN scripts. All dependencies are bundled via Vite. Google Maps API is loaded programmatically via @googlemaps/js-api-loader.
FE-2 Third-party script audit PASS No analytics, tag managers, or chat widgets detected in the codebase. No external scripts in index.html.
FE-3 CSP restricts script sources FAIL No CSP configured. See A02-1. High See A02-1 fix.

Privacy & Data Lifecycle (PII / GDPR-style) · Medium

ID Item Verdict Evidence Severity Fix
PII-1 Data deletion actually deletes NEEDS-MANUAL The supabase-rpc-ventas-delete-contacto-cascade.sql and supabase-rpc-ventas-delete-contactos-cascade-batch.sql suggest cascade deletion RPCs exist. Need to verify they actually cascade to all related tables (messages, calls, attributions, etc.). Medium Verify the cascade deletion RPCs cover all tables with PII.
PII-2 Retention limits FAIL No retention policy visible. Logs, messages, call transcriptions appear to be kept indefinitely. Medium Define and implement data retention policies. At minimum, auto-delete or anonymize PII after a defined period (e.g., 2 years for inactive contacts).
PII-3 PII not leaked to third parties PARTIAL PASS PII is redacted before sending to Claude (chat.ts:480-497). However, call transcriptions sent to AI for analysis (chat.ts:218-219 notes that transcriptions "pueden contener PII hablada") and the system prompt acknowledges this. Web audit screenshots sent to Claude/OpenAI (web-audit.ts:305-306) could contain patient names if visible on the audited website. Medium Add explicit PII scrubbing to transcription content before sending to AI. Document in the privacy policy that AI providers (Anthropic, OpenAI) may process clinic web content.

Infrastructure & Least Privilege · High

ID Item Verdict Evidence Severity Fix
Infra-1 Ops endpoints not public PASS No /metrics, /health, /debug, /.env, /.git endpoints exposed. Vercel doesn't serve these by default.
Infra-2 GraphQL hardening N/A No GraphQL used.
Infra-3 Database least privilege PARTIAL PASS The Supabase anon key is used client-side (correct). Service-role key is server-side only (correct). However, some API endpoints use the service-role key to bypass RLS when they could use the user's JWT instead. Example: api/import-ghl-appointments.ts:211 uses service-role to write to Demos/Opportunities/ventas_contactos — this is necessary because the user's JWT may not have INSERT permissions via RLS. The trade-off is intentional but means the service-role key has broad power. Medium Audit which service-role operations are truly needed. Where possible, create SECURITY DEFINER functions that accept the user's JWT and perform the operation with elevated privileges only after verifying the user's role.

Email & Notification Abuse · Low–Medium

ID Item Verdict Evidence Severity Fix
Email-1 No email header injection N/A No email sending in the codebase. Emails are sent via Supabase Auth (transactional) or via external services (n8n workflows, GHL).
Email-2 SPF/DKIM/DMARC NEEDS-MANUAL Not verifiable from code. Check DNS records for maspacientes.io.
Email-3 Notification rate limiting NEEDS-MANUAL Review campaign sending uses a throttle mechanism (reviewConfigSupabase.ts:26,33: resenas_get_throttle/resenas_set_throttle RPCs). Verify the throttle values are reasonable.

Manual / Runtime Checks

ID Item Verdict Notes
M-1 IDOR test against running instance NEEDS-MANUAL Try accessing /api/stripe/refund with a valid JWT but another user's prepayment ID. Try /api/web-audit with a valid JWT but a location the user doesn't own.
M-2 Hit protected endpoints with no/invalid token NEEDS-MANUAL curl -X POST https://lab.maspacientes.io/api/chat without Authorization header — should get 401. Test all API endpoints.
M-3 DAST scan NEEDS-MANUAL Run OWASP ZAP against https://lab.maspacientes.io.
M-4 DB not reachable from public internet NEEDS-MANUAL Verify in Supabase dashboard that direct DB connections are restricted.
M-5 Brute-force login throttling NEEDS-MANUAL Attempt 50 rapid login attempts to verify Supabase rate limiting kicks in.
M-6 Inspect shipped frontend bundle for secrets NEEDS-MANUAL Run npm run build, then grep -r 'sk_|secret|password|private_key' dist/ to verify no secrets leaked.

Prioritized Remediation Plan

🔴 CRITICAL — Ship-blockers (fix before any production exposure)

  1. [A01-1, A01-2, A01-4, CC-D2] Server-side authorization on ALL API endpoints

    • Every API endpoint checks "is logged in?" but never "does this user have the right role to do this?"
    • Any authenticated user can: refund any prepayment, import data for any location, audit any website, use the AI chat to extract data and create ClickUp tasks
    • Fix: Create shared verifyAuthAndRole(req, allowedRoles[]) utility. Apply to every handler in api/.
    • Files: api/stripe/refund.ts, api/chat.ts, api/web-audit.ts, api/web-tasks.ts, api/fuentes-scrape.ts, api/import-ghl-appointments.ts, api/import-ghl-operaciones.ts, api/verify-account.ts, api/number-health.ts, api/stripe/connect-init.ts
  2. [A02-1, TSB-14] Security headers (CSP, HSTS, X-Frame-Options, etc.)

    • Zero security headers configured. The app is frameable (clickjacking), has no XSS mitigation via CSP, no HSTS
    • Fix: Add headers block to vercel.json with all six headers
    • File: vercel.json
  3. [A06-5, CC-R1, LLM-5, DoS-4] Rate limiting on all endpoints

    • No rate limiting anywhere. AI endpoints can be abused to run up provider bills. Financial endpoints can be hammered
    • Fix: Add Vercel Edge Middleware with @upstash/ratelimit
    • File: New middleware.ts at project root

🟠 HIGH — Fix within 1-2 weeks

  1. [A01-5, A01-6, TSB-11] Multi-tenancy / resource ownership validation — Verify user has access to the location/resource they're acting on
  2. [A01-7] SSRF protection — Validate URLs in web-audit and fuentes-scrape before Puppeteer navigation
  3. [A03-3] npm vulnerabilities — Update vitest, @anthropic-ai/sdk, ws, vite to patched versions
  4. [A05-2] Input validation — Add zod schemas to all API request bodies
  5. [BL-1, BL-4] Race condition on refunds — Atomic status transition + idempotency key
  6. [A09-4, CC-D4] Audit logging — Create audit_log table, log all sensitive actions
  7. [LLM-3] Chat AI excessive agency — Scope ClickUp operations to admin role
  8. [A07-2, TSB-1] Token storage — Immediate: implement CSP. Long-term: evaluate httpOnly cookie flow

🟡 MEDIUM — Fix within 1 month

  1. [A01-8] CORS restriction — Replace wildcard CORS with origin allowlist on chat endpoint
  2. [A04-6] Secret in URL — Move verify_secret from query param to header
  3. [A09-1, A09-2] Structured logging — Add logging to all API endpoints, sanitize sensitive data
  4. [A10-1] Fail-open approval cache — Add staleness check to cached approval
  5. [API-1] Excessive data exposure — Replace select('*') with explicit column lists
  6. [Cache-1] Cache-Control headers — Add private, no-store to authenticated API responses
  7. [PII-2] Data retention — Define and implement retention policies

🟢 LOW — Fix when convenient

  1. [A02-8] Dead code — Remove api/stripe/connect-start.ts
  2. [A03-7] SBOM — Generate and maintain an SBOM
  3. [CC-S5, CC-S6] Hardcoded IDs — Move to environment variables
  4. [LLM-4] System prompt leakage — Add output filter

Report generated 2026-07-23 by automated security audit against the OWASP Top 10:2025 checklist. For questions about specific findings, reference the ID (e.g., A01-1) to locate the exact code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment