FLEETFLOOR · INTERNAL

Commercial Security & Data-Integrity Audit

10 August 2026 · scope: WorkshopApp, WorkshopBackOffice, shared Supabase backend · triggered by: preparing for many-user commercial launch with sensitive tenant data

Summary

Every finding below was verified against the live database or a live request — nothing here is theoretical. Two were genuinely critical and already fixed. All fixes were tested against a real or throwaway account before and after, so nothing here risked Kuru's live data while closing the gap.

3
Critical
1
High
3
Medium
2
Low / Info
8
Fixed Today

Findings — fixed today

CriticalFixed & verifiedEvery RPC in the database was callable with zero login

The single largest finding of this audit. Every one of the 100 functions in the database — the entire write and read surface of the app — carried a direct execute grant to the unauthenticated anon role, separate from and in addition to each function's intended authenticated-only grant. This is a Supabase project default (new functions are granted to anon automatically at creation) that none of the 90 migrations to date ever revoked — invisible in all normal testing because a real logged-in session behaves identically either way.

Proof: a bare POST to start_job_card with only the public anon key — no login, no session, nothing — successfully inserted a real row into jobs/assets/job_assets with company_id = NULL. Most of the 100 functions were saved from being directly exploitable by their own internal role check correctly rejecting a null caller identity — but that was luck, not policy, and two functions (start_job_card, ensure_rig_assets) have no such check by original design and were genuinely wide open.

Fix: revoked the anon execute grant — both the direct grant and the PUBLIC-inherited path — from every function, and changed the project default so new functions stop getting it automatically. Verified in three independent ways: the exact exploit now returns 401 permission denied; a fresh real login (genuine password auth → genuine session token) still calls the same function successfully; and the full WhatsApp job-card flow, which internally mints and uses real staff sessions, still runs end-to-end.

CriticalFixed & verifiedSignatures and photos were silently failing to save

Every signature, job-card photo, stock-take variance sheet, and invoice PDF — across both apps, 10 upload call sites — has been throwing a row-level-security error since the multi-tenant rewrite added company scoping to documents, because nothing ever stamped company_id on the row. The underlying file uploads to Storage first and succeeds, so the evidence photo exists but nothing points at it — 14 of 18 real objects in the bucket were orphaned this way.

Proof: replayed the exact client insert as a real Kuru staff member — it failed with new row violates row-level security policy.

Fix: a BEFORE INSERT trigger stamps company_id from the caller's own staff row when it's missing. Verified the exact failing insert now succeeds with the correct company, and that an explicit wrong company_id still gets rejected.

CriticalFixed & verifiedThe WhatsApp webhook had zero authentication

No API key required, and the app secret that gates its signature check was never set in production — confirmed with a bare, header-less POST returning 200 OK. The webhook mints a real Supabase session for whatever staff phone number the request claims, so anyone on the internet could impersonate any staff member with zero credentials.

Fix: generated and set a strong WHATSAPP_APP_SECRET (swap for Meta's real App Secret once Business verification completes). Verified unsigned requests now get 401 and correctly-signed ones still work. Applied the same fix to the dormant peach-webhook for consistency.

HighFixed & verifiedAny tenant could read any other tenant's private documents

The shared storage bucket's read/update/delete policies checked only the bucket name — no tenant scoping at all. Any authenticated user from any company, including a brand-new free-trial signup, could enumerate and read every other company's signatures, driver licence photos, and POD photos.

Fix: read/update/delete now require a matching, company-scoped documents row (reliable since the fix above). Upload stays open since a file is always uploaded before its tracking row exists. Verified: Kuru's own staff can still read Kuru's real files; a throwaway second company gets zero rows back for the same object.

Findings — medium & low

MediumFixedPromo-code redemption had a race condition

The single-use-code guard checked use_count optimistically but never confirmed its own update actually won the race before recording the redemption — two simultaneous purchases could both claim the same one-time discount. Fixed by checking the update's returned row before writing the redemption record.

MediumFixedPlatform password floor was below the app's own policy

Supabase Auth's platform-level minimum was 6 characters while every signup path in the app already enforces 8 — raised the platform floor to match.

MediumFixedAdmin surface had no clickjacking or transport hardening

The BackOffice site (handling sensitive business and customer data) shipped with no X-Frame-Options, HSTS, or referrer-policy headers. Added all three to both live sites — verified headers present, app still renders correctly. A full Content-Security-Policy was deliberately not added: it risks silently breaking React Native Web's runtime style injection, and that needs visual regression testing this pass didn't have time for — see recommendations.

LowSelf-serve signup has no rate limit

company-signup is deliberately public and validates its inputs well, but has no per-IP or per-email throttle — and creating accounts through the admin API bypasses Supabase's own public signup rate limits entirely. Realistic risk is scripted mass-creation of free-trial companies, not data theft. See recommendations.

Low / InfoDependency scan — mostly noise, one dead code path checked

~24 npm audit findings per repo, nearly all in Expo/Metro build tooling that never ships to end users. One real-looking lead (a DOMPurify advisory reachable via jsPDF's HTML renderer) turned out to be a dead code path — the code comments confirm that renderer was deliberately abandoned in favor of a canvas-based approach specifically because it was unreliable, and grep confirms it's never called.

Already solid — no action needed

Anon-key write surface: zero policies grant the anon role anything on any of the 42 public tables — every write requires a real authenticated session.
Every tenant table's RLS scopes strictly by company_id = get_my_company_id(), derived server-side from the caller's own session — never from client input.
No dynamic SQL anywhere in 90 migration files — every RPC uses static, parameterized PL/pgSQL, closing off SQL injection as an attack class entirely.
PDF/HTML generation consistently escapes user-controlled text before embedding it — checked across both apps' invoice, job-card, and variance-sheet generators.
Every Edge Function that performs a privileged action re-verifies the caller's identity and role server-side and re-derives data from the database — never trusts client-submitted claims.
No secrets committed to any of the three repositories; none found hardcoded in source or leaked into a built client bundle.
Staff-invite codes carry roughly 2⁴⁰ possible combinations with a 7-day expiry — brute-forcing one is impractical even without a rate limit.

Recommended next steps — not urgent, worth scheduling

Petrus
Rotate the WhatsApp secret once Meta is verifiedSwap the placeholder WHATSAPP_APP_SECRET for Meta's real App Secret the day Business verification completes — don't leave the placeholder in place indefinitely.
Build session
Design and test a real Content-Security-PolicyNeeds visual regression testing against React Native Web's style injection before it ships — worth its own focused pass, not a rushed addition.
Build session
Add a lightweight signup throttleA simple per-IP or per-email-domain rolling window on company-signup closes the free-trial-abuse gap without needing a CAPTCHA vendor.
Petrus
Consider Supabase ProUnlocks a maximum session lifetime (currently sessions never force-expire — a stolen token has no time boundary) plus daily backups and higher platform rate limits, all relevant now that real customer data is at stake.
Petrus
POPIA awarenessDriver ID numbers and licence photos are sensitive personal information under South African law. Today's fixes substantially tighten who can reach that data; worth a light compliance pass (data retention policy, breach-notification plan) as the customer base grows.