Round 1 + fix verification
Production Readiness Report
Founder view
As delivered, the app should not launch: 6 critical issues expose all customer data and allow fake payments. With the fixes in this report applied (already written and tested) and the manual steps under Next 24 hours done, every control we ran passes. What remains before launch are the go-live checks this review did not cover: a live payment test, Supabase Auth settings, backups, hosting and dependencies (see Scope).
Founder summary
What blocks launch?
Six critical issues, all fixed in this report: the master database key is inside the website (F-01); anyone can join any business as owner (F-02) or make himself admin (F-03); payments can be faked with one request (F-04); any business's agenda and every client list can be read by strangers (F-05, F-06).
What could expose customer data?
Before the fixes: client names, emails, phones and private notes including health data (F-06), client phones through the agenda without login (F-05), ID cards and medical documents in storage (F-08), and every user's email and phone (F-10). After the fixes, each of these attacks was re-run and returned nothing.
Is payment safe enough to launch?
Not before: one forged request marked a booking as paid (control C39), the customer chose the price (C42), and real Mercado Pago notifications were not processed at all (C40). After the fixes: unsigned requests are refused, amounts are confirmed with Mercado Pago and must match the booking price, and replays are recorded once. Condition: set the two Mercado Pago secrets and complete one sandbox payment end to end before going live — that live test is outside this review.
Next 24 hours
- Rotate the service_role key in Supabase and purge .env from git history (F-01).
- Apply migration 0005_security_fixes.sql and deploy the four updated edge functions.
- Set function secrets: MP_WEBHOOK_SECRET, MP_ACCESS_TOKEN, ALLOWED_ORIGINS.
- Check production data: unexpected rows in staff, accounts with is_admin = true, overlapping active bookings.
Next 7 days
- Run one end-to-end payment in Mercado Pago's sandbox and confirm the booking flips to confirmed.
- Add tests/run-controls.sh to CI so every Lovable change is re-tested before deploy.
- Deactivate coupon AMIGO50 if it should not stay public.
- Decide the public booking flow: today an anonymous visitor cannot load /book/:slug (businesses are readable only by members), so customers cannot book without being staff. A functional gap, not a security one.
Findings at a glance
| ID | Severity | What it means for you | Status |
|---|---|---|---|
| F-01 | Critical | The master database key is published inside the websiteAnyone who opens the site can copy the key that skips every security rule and read, change or delete all data of every business. | FixedC48, C46 |
| F-02 | Critical | Any signed-up user can add himself as owner of any businessA free account is enough to join any clinic or salon as staff and then see and change its agenda, clients, payments, prices and documents. | FixedC07 |
| F-03 | Critical | Any user can make himself administratorOne request turns a normal account into an admin. Today that unlocks the admin menu; every admin feature built later would be open to everyone. | FixedC01, C02 |
| F-04 | Critical | Payments can be faked: the payment webhook believes any requestAnyone can send one request that marks a booking as paid and confirmed without paying, or fills a business's revenue with invented payments. | FixedC39, C40, C30, C31 |
| F-05 | Critical | Any business's agenda can be downloaded without logging inWithout an account, anyone can pull every appointment of any business, with each client's name, phone, service and price. | FixedC12, C13 |
| F-06 | Critical | Every client of every business is visible to any logged-in userAnyone who signs up can download the full client list of every clinic and salon: name, email, phone and private notes, which can include health data. | FixedC09, C10 |
| F-07 | High | Anyone, even without logging in, can cancel or confirm any appointmentA stranger can cancel a business's whole day or mark unpaid bookings as confirmed. Appointment ids are exposed by F-05. | FixedC15, C16, C17 |
| F-08 | High | Client ID documents can be read and uploaded across businessesAny logged-in user can list and download the ID cards, medical studies and consent forms of every business's clients, and drop files into other businesses' folders. | FixedC19, C21, C28 |
| F-09 | High | The customer decides the price, and discount codes are visible in the websiteA booking can be sent with any price (for example 1 cent). The 50% code AMIGO50 and the other coupons can be read by anyone in the page source. | FixedC42, C32, C51 |
| F-10 | Medium | Every user's email and phone can be listed without logging inAnyone on the internet can download the list of registered users with email, phone and who is admin: material for spam, phishing or targeted attacks. | FixedC04 |
| F-11 | Medium | Anyone can host files in the public marketing bucketAny account can upload a phishing page or malware that is then served from your project's public storage address, with no size or type limit. | FixedC24, C29 |
| F-12 | Medium | Double bookings and bookings in the past are possibleTwo customers can get the same slot, and the booking page offers times that already passed. Annoying for customers, costly for the business. | FixedC35, C36 |
| F-13 | Medium | Error responses reveal internal detailsA malformed request returns stack traces and raw database messages that help an attacker map the system. | FixedC41, C43 |
| F-14 | Medium | Any website can call the functions from a visitor's browserThe functions accept cross-origin calls from any website, and one of them also declares it accepts credentials, an invalid and unsafe combination. | FixedC44 |
Technical findings
F-01 Critical The master database key is published inside the website
Fixed
Impact. Anyone who opens the site can copy the key that skips every security rule and read, change or delete all data of every business.
- Location
- src/integrations/supabase/admin.ts · .env (VITE_SUPABASE_SERVICE_ROLE_KEY) · src/pages/AdminPage.tsx
- Standard
- V13 Configuration (secret management) · V8 Authorization
What is wrong
The admin panel creates a Supabase client with the service_role key read from a VITE_ variable. Vite inlines every VITE_ variable into the public JavaScript bundle, so the key ships to every visitor. The .env file holding it is committed. The /admin route has no server-side check; the menu link is only hidden.
Evidence (real test output)
Production build of the original app (npx vite build), then searching the output:
$ grep -o 'eyJ[A-Za-z0-9._-]*service_role_key_full_access' dist/assets/*.js
dist/assets/index-D4vCh4Ci.js: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIi...
# JWT payload decodes to {"role":"service_role"}. Same search on the fixed build: no match. C48 No service_role key in frontend code or VITE_ variables; .env is git-ignored
service_role referenced in: src/integrations/supabase/admin.ts, .env; .env not in .gitignoreno references; .env ignoredC46 Admin data refused to a non-admin user (server-side check)
no server-side admin endpoint: the admin panel queries the database from the browser with the service_role keyHTTP 403 {"error":"forbidden"}Reproduction
# 1. Open the live site, DevTools > Sources, search the main .js file for "service_role" # 2. Use the key against the REST API — every row of every table comes back: curl "https://<project>.supabase.co/rest/v1/clients?select=*" \ -H "apikey: <service_role key from the bundle>" \ -H "Authorization: Bearer <service_role key from the bundle>"
Expected behavior
The service_role key exists only on the server. Admin data is served by a server function that verifies the caller is an admin (a flag users cannot edit).
Fix (diff excerpt)
- src/integrations/supabase/admin.ts (deleted) - VITE_SUPABASE_SERVICE_ROLE_KEY=eyJ... (.env, removed; .env added to .gitignore) --- a/src/pages/AdminPage.tsx - const { data } = await adminClient.from("businesses").select("id, name, slug, owner_id"); + const { data, error } = await supabase.functions.invoke("admin-overview"); +++ b/supabase/functions/admin-overview/index.ts (new) + const { data: { user } } = await admin.auth.getUser(token); + if (!user) return json({ error: "unauthorized" }, 401); + const { data: profile } = await admin.from("profiles").select("is_admin").eq("id", user.id).single(); + if (!profile?.is_admin) return json({ error: "forbidden" }, 403);
Rotate the service_role key in Supabase (Settings > API) and purge .env from git history. Code cannot do this for you: the old key must be treated as leaked.
Controls for this finding
| Control | Before | After |
|---|---|---|
| C48 No service_role key in frontend code or VITE_ variables; .env is git-ignored | FAIL | PASS |
| C46 Admin data refused to a non-admin user (server-side check) | FAIL | PASS |
| C47 Admin data served to a real admin through the server normal use | FAIL | PASS |
F-02 Critical Any signed-up user can add himself as owner of any business
Fixed
Impact. A free account is enough to join any clinic or salon as staff and then see and change its agenda, clients, payments, prices and documents.
- Location
- supabase/migrations/0002_rls.sql · policy "staff can be added" on public.staff
- Standard
- V8 Authorization
What is wrong
The INSERT policy on staff is WITH CHECK (true). Every other permission in the app is "is this user in staff for this business?", so whoever can write to staff holds the keys to every tenant. Business IDs are readable without login from the public services table.
Evidence (real test output)
C07 An outsider cannot add himself as owner of another business
affected=1; state after = 1ERROR: new row violates row-level security policy for table "staff"Reproduction
-- as any logged-in user (role authenticated, own JWT)
insert into public.staff (business_id, user_id, role, display_name)
values ('<business A id>', auth.uid(), 'owner', 'x'); Expected behavior
Only the owner of a business can add staff to it.
Fix (diff excerpt)
-create policy "staff can be added" on public.staff - for insert to authenticated - with check (true); +create policy "owners add staff" on public.staff + for insert to authenticated + with check (public.is_business_owner(business_id));
Review the staff table in production and remove memberships nobody approved.
Controls for this finding
| Control | Before | After |
|---|---|---|
| C07 An outsider cannot add himself as owner of another business | FAIL | PASS |
| C08 The owner can add staff to his own business normal use | PASS | PASS |
F-03 Critical Any user can make himself administrator
Fixed
Impact. One request turns a normal account into an admin. Today that unlocks the admin menu; every admin feature built later would be open to everyone.
- Location
- supabase/migrations/0002_rls.sql · policy "users update own profile" · src/components/Layout.tsx
- Standard
- V8 Authorization (field-level access)
What is wrong
The profile UPDATE policy correctly limits users to their own row, but not to specific columns. is_admin and role live in the same row, so users can rewrite them.
Evidence (real test output)
C01 A user cannot give himself is_admin = true
affected=1; state after = trueERROR: permission denied for table profilesC02 A user cannot change his own role to admin
affected=1; state after = adminERROR: permission denied for table profilesReproduction
-- as any logged-in user
update public.profiles set is_admin = true, role = 'admin' where id = auth.uid(); Expected behavior
Users can edit their name and phone; role and is_admin change only from the server.
Fix (diff excerpt)
+revoke insert, update on public.profiles from anon, authenticated; +grant update (full_name, phone) on public.profiles to authenticated;
Check which accounts currently have is_admin = true or role = 'admin' in production.
Controls for this finding
| Control | Before | After |
|---|---|---|
| C01 A user cannot give himself is_admin = true | FAIL | PASS |
| C02 A user cannot change his own role to admin | FAIL | PASS |
| C03 A user can still edit his own name and phone normal use | PASS | PASS |
F-04 Critical Payments can be faked: the payment webhook believes any request
Fixed
Impact. Anyone can send one request that marks a booking as paid and confirmed without paying, or fills a business's revenue with invented payments.
- Location
- supabase/functions/payments-webhook/index.ts · 0002_rls.sql policy "members insert payments"
- Standard
- V2 Validation and Business Logic · V4 API and Web Service
What is wrong
The webhook runs with the service_role key and takes status, amount, appointment and business from the request body without checking Mercado Pago's signature or asking Mercado Pago about the payment. If no status is sent it assumes "approved". Separately, staff could insert "paid" rows straight from the browser, and nothing stopped the same payment being recorded twice. The run also showed the opposite problem: a real Mercado Pago notification (which only carries the payment id) was rejected with "missing appointment", so genuine payments never confirmed bookings.
Evidence (real test output)
C39 A forged, unsigned 'payment approved' call changes nothing
HTTP 200; database writes: POST /rest/v1/payments {"appointment_id":"a1000000-0000-0000-0000-000000000001","business_id":"aaaaaaaa-0000-0000-0000-000000000001","amount_cents":100,"status":"paid","provider":"mp","provider_ref":""} || PATCH /rest/v1/appointments {"status":"confirmed"}HTTP 401; database writes: noneC40 A genuine signed notification (confirmed with Mercado Pago) marks the booking paidnormal use
HTTP 400 {"error":"missing appointment"}; database writes: noneHTTP 200 {"ok":true}; database writes: POST /rest/v1/payments {"appointment_id":"a1000000-0000-0000-0000-000000000001","business_id":"aaaaaaaa-0000-0000-0000-000000000001","amount_cents":500000,"currency":"ARS","status":"paid","provider":"mp","provider_ref":" || PATCH /rest/v1/appointments {"status":"confirmed"}C30 Nobody can write a "paid" payment from the browser (only the verified webhook)
affected=1; state after = 1ERROR: new row violates row-level security policy for table "payments"C31 A replayed webhook cannot record the same payment twice
affected=2; state after = 2ERROR: duplicate key value violates unique constraint "payments_provider_ref_unique"Reproduction
curl -X POST "https://<project>.supabase.co/functions/v1/payments-webhook" \
-H "Content-Type: application/json" \
-d '{"metadata":{"appointment_id":"<id>","business_id":"<id>"},"amount":1}'
# -> 200 {"ok":true}; payment stored as "paid", appointment set to "confirmed" Expected behavior
Unsigned requests get 401 and change nothing. Status and amount come from Mercado Pago's API, the amount must equal the booking price, and a payment id is recorded once.
Fix (diff excerpt)
- const status = event.data?.status ?? event.status ?? "approved"; - const amount = event.data?.transaction_amount ?? event.amount ?? 0; + const valid = await verifyMpSignature({ + xSignature: req.headers.get("x-signature"), + xRequestId: req.headers.get("x-request-id"), + dataId, secret: Deno.env.get("MP_WEBHOOK_SECRET") ?? "" }); + if (!valid) return json({ error: "invalid_signature" }, 401); + const mpRes = await fetch(`https://api.mercadopago.com/v1/payments/${encodeURIComponent(dataId!)}`, ...); + const paid = payment.status === "approved" && amountCents === appt.price_cents; + await supabase.from("payments").upsert({ ... }, { onConflict: "provider,provider_ref" }); -- migration 0005 +drop policy if exists "members insert payments" on public.payments; +alter table public.payments add constraint payments_provider_ref_unique unique (provider, provider_ref);
Set MP_WEBHOOK_SECRET and MP_ACCESS_TOKEN as function secrets and run one end-to-end payment in Mercado Pago's sandbox before launch.
Controls for this finding
| Control | Before | After |
|---|---|---|
| C39 A forged, unsigned 'payment approved' call changes nothing | FAIL | PASS |
| C40 A genuine signed notification (confirmed with Mercado Pago) marks the booking paid normal use | FAIL | PASS |
| C49 Signature verifier accepts genuine webhooks and rejects forged/replayed ones (executed) | FAIL | PASS |
| C50 Payment status/amount are read from Mercado Pago and checked against the booking price | FAIL | PASS |
| C30 Nobody can write a "paid" payment from the browser (only the verified webhook) | FAIL | PASS |
| C31 A replayed webhook cannot record the same payment twice | FAIL | PASS |
F-05 Critical Any business's agenda can be downloaded without logging in
Fixed
Impact. Without an account, anyone can pull every appointment of any business, with each client's name, phone, service and price.
- Location
- supabase/migrations/0003_functions.sql · function public.get_business_appointments
- Standard
- V8 Authorization
What is wrong
The function is SECURITY DEFINER (it skips row-level security) and filters only by the business id it receives, without checking that the caller belongs to that business. It is executable by anon. Free scanners do not flag it because search_path is set.
Evidence (real test output)
C12 A visitor without login cannot pull a business's agenda
rows=1: ("Patient A","+54 11 4444-0001",500000)ERROR: permission denied for function get_business_appointmentsC13 Business B cannot pull Business A's agenda
rows=1: ("Patient A","+54 11 4444-0001",500000)ERROR: forbiddenReproduction
curl "https://<project>.supabase.co/rest/v1/rpc/get_business_appointments" \
-H "apikey: <public anon key>" -H "Content-Type: application/json" \
-d '{"p_business_id":"<business A id>"}' Expected behavior
Only members of the business can read its agenda; anonymous calls are refused.
Fix (diff excerpt)
+ if not public.is_business_member(p_business_id) then + raise exception 'forbidden' using errcode = '42501'; + end if; +revoke execute on function public.get_business_appointments(uuid, timestamptz, timestamptz) from public, anon;
Controls for this finding
| Control | Before | After |
|---|---|---|
| C12 A visitor without login cannot pull a business's agenda | FAIL | PASS |
| C13 Business B cannot pull Business A's agenda | FAIL | PASS |
| C14 Business A can load its own agenda normal use | PASS | PASS |
F-06 Critical Every client of every business is visible to any logged-in user
Fixed
Impact. Anyone who signs up can download the full client list of every clinic and salon: name, email, phone and private notes, which can include health data.
- Location
- supabase/migrations/0002_rls.sql · policy "read clients" on public.clients
- Standard
- V8 Authorization · V14 Data Protection
What is wrong
The SELECT policy is USING (auth.role() = 'authenticated'), which is true for every logged-in user. The other client policies correctly check business membership; this one does not.
Evidence (real test output)
C09 An outsider cannot read any business's clients
rows=2: ("Patient A",patient@a.test,"+54 11 4444-0001","allergic to penicillin") | ("Customer B",customer@b.test,"+54 11 4444-0002","prefers mornings")rows=0C10 Business B cannot read Business A's clients
rows=1: ("Patient A","+54 11 4444-0001","allergic to penicillin")rows=0Reproduction
-- as any logged-in user
select full_name, email, phone, notes from public.clients; Expected behavior
Users see only the clients of businesses they belong to.
Fix (diff excerpt)
-create policy "read clients" on public.clients - for select to authenticated - using (auth.role() = 'authenticated'); +create policy "members read clients" on public.clients + for select to authenticated + using (public.is_business_member(business_id));
Controls for this finding
| Control | Before | After |
|---|---|---|
| C09 An outsider cannot read any business's clients | FAIL | PASS |
| C10 Business B cannot read Business A's clients | FAIL | PASS |
| C11 Business A can read its own clients normal use | PASS | PASS |
F-07 High Anyone, even without logging in, can cancel or confirm any appointment
Fixed
Impact. A stranger can cancel a business's whole day or mark unpaid bookings as confirmed. Appointment ids are exposed by F-05.
- Location
- supabase/migrations/0003_functions.sql · function public.update_appointment_status
- Standard
- V8 Authorization · V2 Validation and Business Logic
What is wrong
SECURITY DEFINER function that updates any appointment by id, with no membership check and no validation of the new status value. Executable by anon.
Evidence (real test output)
C15 A visitor without login cannot cancel an appointment
affected=1; state after = cancelledERROR: permission denied for function update_appointment_statusC16 Business B cannot confirm Business A's appointment
affected=1; state after = confirmedERROR: forbiddenC17 Status only accepts known values
affected=1; state after = hackedERROR: invalid statusReproduction
-- as anon (no login)
select public.update_appointment_status('<appointment id>', 'cancelled'); Expected behavior
Only members of the appointment's business can change it, and only to a known status.
Fix (diff excerpt)
+ if p_status is null or p_status not in ('pending','confirmed','cancelled','done','no_show') then + raise exception 'invalid status' using errcode = '22023'; + end if; + select a.business_id into v_business from public.appointments a where a.id = p_appointment_id; + if v_business is null or not public.is_business_member(v_business) then + raise exception 'forbidden' using errcode = '42501'; + end if; +revoke execute on function public.update_appointment_status(uuid, text) from public, anon;
Controls for this finding
| Control | Before | After |
|---|---|---|
| C15 A visitor without login cannot cancel an appointment | FAIL | PASS |
| C16 Business B cannot confirm Business A's appointment | FAIL | PASS |
| C17 Status only accepts known values | FAIL | PASS |
| C18 Business A can cancel its own appointment normal use | PASS | PASS |
F-08 High Client ID documents can be read and uploaded across businesses
Fixed
Impact. Any logged-in user can list and download the ID cards, medical studies and consent forms of every business's clients, and drop files into other businesses' folders.
- Location
- supabase/migrations/0004_storage.sql · policies on storage.objects (bucket client-docs)
- Standard
- V5 File Handling · V8 Authorization
What is wrong
The storage policies only check bucket_id = 'client-docs', not the business folder in the path (<business_id>/<client_id>/file). The bucket has no size limit and accepts any file type.
Evidence (real test output)
C19 An outsider cannot list client ID documents
rows=2: (aaaaaaaa-0000-0000-0000-000000000001/ca000000-0000-0000-0000-000000000001/id-card.pdf) | (bbbbbbbb-0000-0000-0000-000000000002/cb000000-0000-0000-0000-000000000002/consent.pdf)rows=0C21 An outsider cannot upload into Business A's folder
affected=1; state after = 1ERROR: new row violates row-level security policy for table "objects"C28 client-docs bucket has a size limit and a type allowlist (no HTML/SVG)
size_limit=none; types=anysize_limit=10485760; types={application/pdf,image/jpeg,image/png}Reproduction
-- as any logged-in user select name from storage.objects where bucket_id = 'client-docs'; -- with the listed path, supabase.storage.from('client-docs').createSignedUrl(path, 60) downloads it
Expected behavior
Only members of the business in the first folder of the path can list, upload or delete; uploads are limited to PDF/JPEG/PNG up to 10 MB.
Fix (diff excerpt)
-create policy "read client docs" on storage.objects - for select to authenticated using (bucket_id = 'client-docs'); +create policy "members read client docs" on storage.objects + for select to authenticated + using (bucket_id = 'client-docs' and public.is_member_of_folder(name)); +update storage.buckets set file_size_limit = 10485760, + allowed_mime_types = array['application/pdf','image/jpeg','image/png'] where id = 'client-docs';
Controls for this finding
| Control | Before | After |
|---|---|---|
| C19 An outsider cannot list client ID documents | FAIL | PASS |
| C20 Business B cannot list Business A's documents | FAIL | PASS |
| C21 An outsider cannot upload into Business A's folder | FAIL | PASS |
| C22 Business A can upload to its own folder normal use | PASS | PASS |
| C23 Business A sees only its own documents, not Business B's | FAIL | PASS |
| C28 client-docs bucket has a size limit and a type allowlist (no HTML/SVG) | FAIL | PASS |
F-09 High The customer decides the price, and discount codes are visible in the website
Fixed
Impact. A booking can be sent with any price (for example 1 cent). The 50% code AMIGO50 and the other coupons can be read by anyone in the page source.
- Location
- supabase/functions/create-booking/index.ts · src/lib/pricing.ts · src/pages/BookingPage.tsx
- Standard
- V2 Validation and Business Logic
What is wrong
The browser computes the total (coupons included) and create-booking stores price_cents and business_id exactly as sent. Row-level security still blocks inserts from non-members in the original app, so the price is forged by staff accounts today, and by anyone combined with F-02.
Evidence (real test output)
C42 A customer who sends price_cents = 1 is still charged the real price (500000)
HTTP 200; stored row: price_cents=1, business_id=attacker-chosen businessHTTP 200; stored row: price_cents=500000, business_id=service's businessC32 Server quotes the real price; an invented coupon gives no discount
ERROR: function public.quote_price(unknown, unknown) does not existrows=1: (500000)C51 No coupon codes in frontend source
src/lib/pricing.ts: "BIENVENIDA10", "VERANO25", "AMIGO50"none foundReproduction
curl -X POST "https://<project>.supabase.co/functions/v1/create-booking" \
-H "Authorization: Bearer <user JWT>" -H "Content-Type: application/json" \
-d '{"service_id":"<id>","business_id":"<other id>","starts_at":"...","ends_at":"...","price_cents":1}' Expected behavior
Price, duration and business come from the database; coupons are validated on the server against a table users cannot read.
Fix (diff excerpt)
- business_id: body.business_id, - price_cents: body.price_cents, + const { data: service } = await supabase.from("services") + .select("id, business_id, duration_min").eq("id", body.service_id).eq("active", true).single(); + const { data: price } = await supabase.rpc("quote_price", { p_service_id: service.id, p_coupon: body.coupon }); + business_id: service.business_id, + price_cents: price, -- migration 0005: table public.coupons (RLS on, no client access) + function public.quote_price()
AMIGO50 was public in the old bundle: deactivate it in public.coupons if it should not be used by everyone.
Controls for this finding
| Control | Before | After |
|---|---|---|
| C42 A customer who sends price_cents = 1 is still charged the real price (500000) | FAIL | PASS |
| C32 Server quotes the real price; an invented coupon gives no discount | FAIL | PASS |
| C33 A valid coupon is applied on the server normal use | FAIL | PASS |
| C34 Coupon codes cannot be listed by users | FAIL | PASS |
| C51 No coupon codes in frontend source | FAIL | PASS |
F-10 Medium Every user's email and phone can be listed without logging in
Fixed
Impact. Anyone on the internet can download the list of registered users with email, phone and who is admin: material for spam, phishing or targeted attacks.
- Location
- supabase/migrations/0002_rls.sql · policy "profiles are viewable"
- Standard
- V8 Authorization · V14 Data Protection
What is wrong
SELECT policy USING (true) with no role restriction, so it also applies to anon.
Evidence (real test output)
C04 A visitor without login cannot list user emails and phones
rows=4: (owner@clinic-a.test,"+54 11 5555-0a",f) | (owner@salon-b.test,"+54 11 5555-0b",f) | (someone@outside.test,"+54 11 5555-0c",f) | (new@clinic-a.test,"+54 11 5555-0d",f)rows=0Reproduction
curl "https://<project>.supabase.co/rest/v1/profiles?select=email,phone,is_admin" -H "apikey: <public anon key>"
Expected behavior
Each user reads only his own profile.
Fix (diff excerpt)
-create policy "profiles are viewable" on public.profiles - for select using (true); +create policy "users read own profile" on public.profiles + for select to authenticated using (id = auth.uid());
Controls for this finding
| Control | Before | After |
|---|---|---|
| C04 A visitor without login cannot list user emails and phones | FAIL | PASS |
| C05 A logged-in user cannot read other users' profiles | FAIL | PASS |
| C06 A user can read his own profile normal use | PASS | PASS |
F-11 Medium Anyone can host files in the public marketing bucket
Fixed
Impact. Any account can upload a phishing page or malware that is then served from your project's public storage address, with no size or type limit.
- Location
- supabase/migrations/0004_storage.sql · bucket marketing · policy "members upload marketing"
- Standard
- V5 File Handling
What is wrong
Despite its name, the upload policy only checks bucket_id = 'marketing'. The bucket is public (by design, for logos and banners) with no size limit or type allowlist.
Evidence (real test output)
C24 An outsider cannot host files (e.g. a phishing page) in the public bucket
affected=1; state after = 1ERROR: new row violates row-level security policy for table "objects"C29 marketing bucket has a size limit and an image-only allowlist
size_limit=none; types=anysize_limit=2097152; types={image/png,image/jpeg,image/webp}Reproduction
-- as any logged-in user
insert into storage.objects (bucket_id, name, owner) values ('marketing', 'login-verify.html', auth.uid()); Expected behavior
Only business members upload, inside their own folder, images only, up to 2 MB. Public reading stays as designed.
Fix (diff excerpt)
+create policy "members upload marketing" on storage.objects + for insert to authenticated + with check (bucket_id = 'marketing' and public.is_member_of_folder(name)); +update storage.buckets set file_size_limit = 2097152, + allowed_mime_types = array['image/png','image/jpeg','image/webp'] where id = 'marketing';
Controls for this finding
| Control | Before | After |
|---|---|---|
| C24 An outsider cannot host files (e.g. a phishing page) in the public bucket | FAIL | PASS |
| C25 Business B cannot replace Business A's public images | FAIL | PASS |
| C26 Business A can upload its own banner normal use | PASS | PASS |
| C27 Marketing images stay public by design normal use | PASS | PASS |
| C29 marketing bucket has a size limit and an image-only allowlist | FAIL | PASS |
F-12 Medium Double bookings and bookings in the past are possible
Fixed
Impact. Two customers can get the same slot, and the booking page offers times that already passed. Annoying for customers, costly for the business.
- Location
- supabase/migrations/0003_functions.sql · get_available_slots · table public.appointments
- Standard
- V2 Validation and Business Logic
What is wrong
Availability only hides slots whose start time is exactly equal to an existing booking, and nothing in the database prevents overlaps.
Evidence (real test output)
C35 Two bookings cannot overlap for the same service
affected=1; state after = 2ERROR: conflicting key value violates exclusion constraint "appointments_no_overlap"C36 Availability hides slots that overlap an existing booking (10:15-10:45)
rows=2: (10:00) | (10:30)rows=0Reproduction
select to_char(slot,'HH24:MI') from public.get_available_slots('<service>', current_date + 7);
-- an existing booking 10:15-10:45 still leaves 10:00 and 10:30 on offer Expected behavior
Overlapping bookings for the same service are rejected by the database; availability excludes overlaps and past times.
Fix (diff excerpt)
+alter table public.appointments add constraint appointments_no_overlap + exclude using gist (service_id with =, tstzrange(starts_at, ends_at, '[)') with &&) + where (status <> 'cancelled'); + where g.slot > now() + and not exists (... tstzrange(a.starts_at, a.ends_at, '[)') && tstzrange(g.slot, g.slot + duration, '[)'))
The constraint will not apply if production already has overlapping active bookings: resolve those first.
Controls for this finding
| Control | Before | After |
|---|---|---|
| C35 Two bookings cannot overlap for the same service | FAIL | PASS |
| C36 Availability hides slots that overlap an existing booking (10:15-10:45) | FAIL | PASS |
| C37 Availability offers no slots in the past | FAIL | PASS |
| C38 Free future slots are still offered normal use | PASS | PASS |
F-13 Medium Error responses reveal internal details
Fixed
Impact. A malformed request returns stack traces and raw database messages that help an attacker map the system.
- Location
- payments-webhook/index.ts (catch) · create-booking/index.ts · get-availability/index.ts
- Standard
- V16 Security Logging and Error Handling
What is wrong
The webhook returns {error, stack}; the other functions return error.message from the database as is.
Evidence (real test output)
C41 Webhook errors do not reveal stack traces
HTTP 500; body: {"error":"SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)","stack":"SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)\n at parse HTTP 500; body: {"error":"internal_error"}C43 Database error messages are not echoed to the browser
HTTP 400; body: {"error":"duplicate key value violates unique constraint \"appointments_internal_idx_7\""}HTTP 400; body: {"error":"booking_failed"}Reproduction
curl -X POST "https://<project>.supabase.co/functions/v1/payments-webhook" -d '{not json' Expected behavior
Generic error bodies to the client; details only in server logs.
Fix (diff excerpt)
- return new Response(JSON.stringify({ error: String(err), stack: (err as Error).stack }), { + console.error("payments-webhook failed", err); // details stay in server logs + return json({ error: "internal_error" }, 500);
Controls for this finding
| Control | Before | After |
|---|---|---|
| C41 Webhook errors do not reveal stack traces | FAIL | PASS |
| C43 Database error messages are not echoed to the browser | FAIL | PASS |
| C52 Edge functions return generic errors (no stack traces / raw DB messages) | FAIL | PASS |
F-14 Medium Any website can call the functions from a visitor's browser
Fixed
Impact. The functions accept cross-origin calls from any website, and one of them also declares it accepts credentials, an invalid and unsafe combination.
- Location
- get-availability/index.ts · create-booking/index.ts · payments-webhook/index.ts
- Standard
- V3 Web Frontend Security
What is wrong
Access-Control-Allow-Origin: * on every function; get-availability adds Access-Control-Allow-Credentials: true.
Evidence (real test output)
C44 A foreign website is not granted cross-origin access
Origin evil.example -> Allow-Origin: *; Allow-Credentials: trueOrigin evil.example -> Allow-Origin: (none); Allow-Credentials: (none)Reproduction
curl -i -X OPTIONS "https://<project>.supabase.co/functions/v1/get-availability" -H "Origin: https://evil.example"
Expected behavior
Only the app's own origins (ALLOWED_ORIGINS) are allowed; never credentials with a wildcard. The webhook sends no CORS headers (server-to-server).
Fix (diff excerpt)
- "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Credentials": "true", +// supabase/functions/_shared/cors.ts + if (ALLOWED_ORIGINS.includes(origin)) headers["Access-Control-Allow-Origin"] = origin;
Set ALLOWED_ORIGINS to the production domain(s) in the function secrets.
Controls for this finding
| Control | Before | After |
|---|---|---|
| C44 A foreign website is not granted cross-origin access | FAIL | PASS |
| C45 The app's own origin can still call the function normal use | PASS | PASS |
| C53 Edge functions use an origin allowlist (no '*', never with credentials) | FAIL | PASS |
Scope and what was not tested
Covered
- All SQL migrations (tables, row-level security, functions, storage policies and buckets), tested on a real PostgreSQL 16 database with a Supabase-compatible layer (anon / authenticated / service_role roles, JWT claims, auth.uid()).
- The four edge functions, executed in Deno with the real code and mocked Supabase and Mercado Pago endpoints, plus a type check (deno check).
- Frontend source and production bundle (vite build, tsc) for secrets, client-side pricing and access control.
Not covered
- No penetration test against a live deployment: the app was never deployed; the Supabase layer is an emulation, not the hosted service.
- Mercado Pago signature format implemented from Mercado Pago's documented scheme and tested with locally generated HMACs, not with a real notification.
- File size and type limits are enforced by Supabase Storage's API; here they were verified as bucket configuration only.
- No load or performance testing, no rate limiting review, no denial-of-service testing.
- No review of Supabase Auth settings (email confirmation, password policy, MFA), DNS, hosting or backups.
- No dependency/CVE audit of npm packages.
- Not a compliance assessment (HIPAA, GDPR, SOC 2, ISO). The demo stores health-related notes; we tested who can technically read them, not regulatory compliance.
Controls left in your repo
The tests used for this report stay in the repository, so every future change (including AI-generated ones) can be re-checked in one command. Results of both runs are committed as JSON.
- supabase/migrations/0005_security_fixes.sql
- All database fixes in one reviewed migration.
- tests/rls_controls.sql
- 38 database controls: seeds two businesses and an outsider, attacks as each role, rolls back.
- tests/edge/edge-controls.ts
- 9 edge function controls: runs the real functions in Deno against mocked Supabase and Mercado Pago.
- tests/code-controls.mjs
- 6 code controls, including an executed test of the webhook signature verifier.
- tests/run-controls.sh
- Builds a fresh database, runs everything, writes JSON, exits non-zero on any failure (CI-ready).
- results/before.json · results/after.json
- The two runs quoted in this report.
Run
$ tests/run-controls.sh . turnos_pro_ci results/latest.json ... 53/53 controls passed -> results/latest.json
All 53 controls
| ID | Control | Type | Before | After |
|---|---|---|---|---|
| C01 | A user cannot give himself is_admin = true · F-03 | Database | FAIL | PASS |
| C02 | A user cannot change his own role to admin · F-03 | Database | FAIL | PASS |
| C03 | A user can still edit his own name and phone normal use · F-03 | Database | PASS | PASS |
| C04 | A visitor without login cannot list user emails and phones · F-10 | Database | FAIL | PASS |
| C05 | A logged-in user cannot read other users' profiles · F-10 | Database | FAIL | PASS |
| C06 | A user can read his own profile normal use · F-10 | Database | PASS | PASS |
| C07 | An outsider cannot add himself as owner of another business · F-02 | Database | FAIL | PASS |
| C08 | The owner can add staff to his own business normal use · F-02 | Database | PASS | PASS |
| C09 | An outsider cannot read any business's clients · F-06 | Database | FAIL | PASS |
| C10 | Business B cannot read Business A's clients · F-06 | Database | FAIL | PASS |
| C11 | Business A can read its own clients normal use · F-06 | Database | PASS | PASS |
| C12 | A visitor without login cannot pull a business's agenda · F-05 | Database | FAIL | PASS |
| C13 | Business B cannot pull Business A's agenda · F-05 | Database | FAIL | PASS |
| C14 | Business A can load its own agenda normal use · F-05 | Database | PASS | PASS |
| C15 | A visitor without login cannot cancel an appointment · F-07 | Database | FAIL | PASS |
| C16 | Business B cannot confirm Business A's appointment · F-07 | Database | FAIL | PASS |
| C17 | Status only accepts known values · F-07 | Database | FAIL | PASS |
| C18 | Business A can cancel its own appointment normal use · F-07 | Database | PASS | PASS |
| C19 | An outsider cannot list client ID documents · F-08 | Database | FAIL | PASS |
| C20 | Business B cannot list Business A's documents · F-08 | Database | FAIL | PASS |
| C21 | An outsider cannot upload into Business A's folder · F-08 | Database | FAIL | PASS |
| C22 | Business A can upload to its own folder normal use · F-08 | Database | PASS | PASS |
| C23 | Business A sees only its own documents, not Business B's · F-08 | Database | FAIL | PASS |
| C24 | An outsider cannot host files (e.g. a phishing page) in the public bucket · F-11 | Database | FAIL | PASS |
| C25 | Business B cannot replace Business A's public images · F-11 | Database | FAIL | PASS |
| C26 | Business A can upload its own banner normal use · F-11 | Database | PASS | PASS |
| C27 | Marketing images stay public by design normal use · F-11 | Database | PASS | PASS |
| C28 | client-docs bucket has a size limit and a type allowlist (no HTML/SVG) · F-08 | Database | FAIL | PASS |
| C29 | marketing bucket has a size limit and an image-only allowlist · F-11 | Database | FAIL | PASS |
| C30 | Nobody can write a "paid" payment from the browser (only the verified webhook) · F-04 | Database | FAIL | PASS |
| C31 | A replayed webhook cannot record the same payment twice · F-04 | Database | FAIL | PASS |
| C32 | Server quotes the real price; an invented coupon gives no discount · F-09 | Database | FAIL | PASS |
| C33 | A valid coupon is applied on the server normal use · F-09 | Database | FAIL | PASS |
| C34 | Coupon codes cannot be listed by users · F-09 | Database | FAIL | PASS |
| C35 | Two bookings cannot overlap for the same service · F-12 | Database | FAIL | PASS |
| C36 | Availability hides slots that overlap an existing booking (10:15-10:45) · F-12 | Database | FAIL | PASS |
| C37 | Availability offers no slots in the past · F-12 | Database | FAIL | PASS |
| C38 | Free future slots are still offered normal use · F-12 | Database | PASS | PASS |
| C39 | A forged, unsigned 'payment approved' call changes nothing · F-04 | Edge function | FAIL | PASS |
| C40 | A genuine signed notification (confirmed with Mercado Pago) marks the booking paid normal use · F-04 | Edge function | FAIL | PASS |
| C41 | Webhook errors do not reveal stack traces · F-13 | Edge function | FAIL | PASS |
| C42 | A customer who sends price_cents = 1 is still charged the real price (500000) · F-09 | Edge function | FAIL | PASS |
| C43 | Database error messages are not echoed to the browser · F-13 | Edge function | FAIL | PASS |
| C44 | A foreign website is not granted cross-origin access · F-14 | Edge function | FAIL | PASS |
| C45 | The app's own origin can still call the function normal use · F-14 | Edge function | PASS | PASS |
| C46 | Admin data refused to a non-admin user (server-side check) · F-01 | Edge function | FAIL | PASS |
| C47 | Admin data served to a real admin through the server normal use · F-01 | Edge function | FAIL | PASS |
| C48 | No service_role key in frontend code or VITE_ variables; .env is git-ignored · F-01 | Code | FAIL | PASS |
| C49 | Signature verifier accepts genuine webhooks and rejects forged/replayed ones (executed) · F-04 | Code | FAIL | PASS |
| C50 | Payment status/amount are read from Mercado Pago and checked against the booking price · F-04 | Code | FAIL | PASS |
| C51 | No coupon codes in frontend source · F-09 | Code | FAIL | PASS |
| C52 | Edge functions return generic errors (no stack traces / raw DB messages) · F-13 | Code | FAIL | PASS |
| C53 | Edge functions use an origin allowlist (no '*', never with credentials) · F-14 | Code | FAIL | PASS |
Want this for your app?
The Readiness Review is $249 and credited in full toward Graduation. Check in two minutes whether your app qualifies.