Español: Política de seguridad en español
| Version | Supported |
|---|---|
master (latest) |
Yes |
| Older commits / forks | Best effort only |
We ship from master to production (https://workspace.galladev.com). If you run a fork or an older commit, upgrade to master first.
Do not open a public issue. Report privately via GitHub Security Advisories.
Include:
- Description and impact.
- Steps to reproduce (without live secrets or personal data).
- Affected routes, commit, and environment.
We aim to acknowledge within 72 hours, share a remediation plan, and credit reporters (unless anonymity is preferred).
- Never put real keys, tokens, passwords, or customer data in issues, PRs, logs, or test fixtures.
- Local secrets belong in gitignored
.env.local/data/settings.local.json; production secrets in Vercel environment variables. - If you suspect a leak, rotate the credential immediately and notify a maintainer — deleting it in a later commit is not enough (git history retains it).
- Data retention: non-client leads are deleted after 12 months without activity and clients after 6 years (daily cron
/api/cron/retention, protected byCRON_SECRET, dry-run by default). Details indocs/04-operations-user-guide/GDW_context.md.
- Auth is Supabase Auth (email + password). Roles
Admin,Seller, andViewercome fromprofiles.role. An authenticated user with no profile row, a null role, or a role outside that enum is denied (401,code: "no_profile") and the app shows a pending-access screen. There is no self-signup: keep public sign-up disabled in the Supabase dashboard. An Admin creates users in Authentication → Users;on_auth_user_createdinserts a profile with no role. The Admin assignsAdmin,Seller, orViewerin Settings → Equipo (PATCH /api/team). Existing rows are not changed by that migration. - Lead RLS: Admin reads and writes every lead, including unassigned ones, and assigns
responsable. Seller reads and writes only leads whereresponsableis their user id (they cannot clear it or pass it to someone else). Viewer is read-only on the whole book, including unassigned leads — that is the product role for a trusted read-only teammate on this single-team workspace. There is no manager role. The same predicate is enforced in the lead API (list, get, patch, archive, merge, score, and the status change to Cliente). - Company mail HTML is sanitized with
sanitize-htmlbefore it is stored and again before it is returned. The thread view renders it in a sandboxed iframe (sandboxwithoutallow-scripts,srcdoc). Remote images,data:URLs, scripts, event handlers, forms, and inline styles are removed. Production CSP is set per request insrc/proxy.ts:script-srcuses a nonce and'strict-dynamic'and does not include'unsafe-inline'.style-srcstill allows'unsafe-inline'(no style nonce) because the UI sets style attributes.img-srcis'self' blob:only.connect-srcstill allowshttps:andwss:so the browser can reach Supabase. AUTH_DISABLED=true(or1) skips session checks only outside production. In production it is fail-closed:NODE_ENV=productionkeeps auth on.AUTH_SECRET,AUTH_PASSWORD, andSESSION_TTL_DAYSare not used.- Cerrar todas las sesiones (Settings → Seguridad) calls
supabase.auth.signOut({ scope: "global" }). The sidebar logout callssupabase.auth.signOut()with no scope argument. There is noPOST /api/auth/logout-alland the app does not readapp_session_epoch. - Visitor demo: an HMAC-SHA256 httpOnly cookie (
gdw_visitor, 4 hours) signed withDEMO_SESSION_SECRET(at least 16 characters). Off unlessDEMO_MODE_ENABLEDistrueor1. The session is read-only fictional data and does not construct a Supabase client. The proxy allowlists visitor routes (/,/leads,/kanban,/stats,/inbox,/duplicates, and read APIs for leads, session, and sync). Anything else redirects to/leadsor returns403withcode: "demo_readonly". Production must use its own random secret (openssl rand -hex 32), not a CI value. Playwright uses the GitHub Actions secretE2E_DEMO_SESSION_SECRETwhen it is set and at least 16 characters; otherwise it generates a process-local value. CI stays green without that secret. - Sign-in is
POST /api/auth/login(the browser does not callsignInWithPassword). Failures share one message. The limit is 20 attempts / 15 minutes per platform IP and 10 / 15 minutes per account (consumeRateLimit: Upstash whenUPSTASH_REDIS_REST_*orKV_REST_API_*is set, otherwise in-memory per instance). GET /api/db-statususes the session client and RLS. The JSON status isok,auth,config, ordown, with a fixed message. It does not return Postgres text and it does not use the service role.handle_new_user()is not an RPC. Migration20261002200000_revoke_handle_new_user_execute.sqlrevokesEXECUTEfromPUBLIC,anon, andauthenticated. Apply it in the Supabase SQL editor; it does not change existing rows. The signup trigger still runs as the function owner.- Company mail compose, reply, draft, and thread patch share one schema: max lengths, UUID for ids, and a mailbox on the allowlist. A reply checks the stored recipient again before send and refuses a mailbox outside that list.
- Public ingest:
POST /api/ingest/leadusesINGEST_SECRET.POST /api/ingest/n8nwas retired on 2026-10-10 and returns 410 Gone. Report auth bypasses as high severity. - The CRM makes no outbound automation calls: the n8n webhooks and
/automationswere removed on 2026-10-13. - Rate limits use
x-vercel-forwarded-for(else the lastx-forwarded-forhop).UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKEN(orKV_REST_API_URL/KV_REST_API_TOKEN) make the counter shared via@upstash/ratelimit; without them, or if Redis fails, each instance has its own window (fail-open, logged). Web ingest also caps receipt emails per day, independent of IP. - Transactional email (Resend) is fail-open by design — the lead is saved even if email fails. Do not report that as a bug.