-
Notifications
You must be signed in to change notification settings - Fork 0
Security
Felines was designed around a unique risk: the product maps locations of vulnerable animals. Security here isn't just data protection — it's physical protection of the cats. This page is especially relevant to Philippe Dourassov (Aikido Security, #1 hacker worldwide 2024), the security judge for this hackathon, and is written to be read end to end, not skimmed.
Full audit report: docs/AUDIT_REPORT.md
Vulnerability disclosure policy: SECURITY.md
Two distinct threats, addressed by two distinct — but overlapping — sets of controls:
- A malicious actor targeting the animals. Someone who wants the exact location of a colony or cat to harm it. This is the threat that shapes progressive location blur and the reason exact coordinates are never returned by any public API response, for any role, under any circumstance.
- A malicious actor targeting the application itself. Someone probing for the standard web-app vulnerability classes — RLS bypass, RPC privilege escalation, injection, SSRF, path traversal, open redirect, spam/abuse of the anonymous write paths. This is the threat that shapes everything from §2 onward.
Every security decision documented here maps back to one of these two threats — the animal-safety framing is not a slogan, it's the actual design constraint that shaped the schema.
The most important security feature in the product — designed to protect cats, not just user data. Real cases of colonies being targeted after their locations were shared carelessly online informed this design.
| Level | User | Precision | Offset | Enforced by |
|---|---|---|---|---|
| 1 | Anonymous | Neighborhood | ~500m + visible uncertainty circle |
latitude_blurred/longitude_blurred — the only columns anon has a grant on |
| 2 | Authenticated, not caretaker | Street level | ~100m |
latitude_blurred_near/longitude_blurred_near — the only additional columns authenticated has a grant on |
| 3 | Linked caretaker or creator | Exact | none |
get_colony_exact_location(p_colony_id) — re-checks the caretaker/creator link against the database on every call
|
Why a column split, not a row filter: a row-level policy can say "you may or may not see this row," but it cannot say "you may see this row, just not these two specific columns of it." That distinction — column-level access within an otherwise-visible row — is exactly what's needed here: everyone should see a colony exists, its name, its narrative; almost nobody should see exactly where it is.
Implementation:
revoke select (latitude, longitude) on colonies from anon, authenticated;
grant select (latitude_blurred, longitude_blurred) on colonies to anon;
grant select (latitude_blurred_near, longitude_blurred_near) on colonies to authenticated;Even a signed-in caretaker's own browser cannot fetch the latitude/longitude columns directly — the only path to the real value is the get_colony_exact_location() RPC, and it never trusts a cached session flag or a client-side boolean; it re-queries the caretakers table (or checks colonies.created_by) on every single invocation.
Tested live: SELECT latitude FROM colonies with the anon key → 42501 permission denied ✅. SELECT latitude_blurred FROM colonies with the anon key → succeeds, returns the offset value ✅. The identical column-vs-function pattern is reused for reports.latitude/longitude, since a report's exact location could otherwise leak a colony's near-exact position through its own open reports — an easy blind spot to miss if you only think about the colonies table.
Why the anonymous offset is visible, not hidden: the ~500m uncertainty circle is rendered on the map, not just applied silently. A visitor should know they're looking at an approximation, and understand why — silently shifting a pin without disclosure would be a small dishonesty in an otherwise trust-dependent product.
RLS enabled on all 26 tables. No table anywhere in the schema has
an unrestricted "allow all" policy — every policy scopes access
to a specific role, a specific relationship (auth.uid() = created_by, a caretaker link, a colony-ownership check), or an
explicit column grant.
| Table | Anonymous | Authenticated | Caretaker / Owner |
|---|---|---|---|
colonies |
Blurred columns only | Blurred + near-blurred columns | Exact coordinates via RPC |
cats |
Full read | Full read | Insert/update (caretaker of parent colony) |
cat_notes |
Full read | Full read + insert | No update policy — immutable by design |
caretakers |
Full read | Full read | Insert own link; delete own link ("stop being a caretaker") |
reports |
INSERT only (no SELECT) | INSERT + SELECT | SELECT + UPDATE (own or caretaker's colony) |
report_confirmations |
No access | Insert via RPC only | Same |
timeline_events |
Full read | Full read + insert (own) | Same |
feedings |
Full read | Full read + insert (caretaker) | Same |
knowledge_progress |
No access | Own rows only | Own rows only |
flags |
INSERT only | INSERT only | INSERT only — no SELECT for anyone, no UPDATE/DELETE ever |
profiles |
Public fields only | Own full row + others' public fields | Same — banned/banned_until/ban_count/current_streak/longest_streak never publicly granted |
notifications |
No access | Own rows only (select/update/delete) | Same |
colony_stories |
No access (login required) | Full read + insert (own) | Same |
story_reactions |
Full read | Full read + insert | Same |
resource_posts |
Full read | Full read + insert (own) | Same |
resource_post_interests |
No access | Own + post-owner visibility | Same |
community_contacts |
Full read | Full read + insert; update/delete own | Same |
neutering_requests / help_requests
|
Full read | Full read | Insert/update (caretaker of parent colony) |
colony_verifications |
Full read | Insert (blocked for creator/caretaker of that colony) | — |
caretaker_certifications |
Full read | No insert policy — write-only via RPC | — |
care_reminders |
No access | No access | Full access (own caretaker relationship only) |
suggested_colonies |
Full read | Full read | System-inserted via trigger, no direct client insert |
All of these were tested with direct HTTP requests against the live Supabase REST endpoint using the real public anon key — the same vantage point an attacker with no account has — not just reviewed as SQL. See Testing for the specific tests and results.
A recurring pattern worth internalizing: select=* on a
table with column-level grants (like colonies or profiles)
correctly returns 42501 permission denied for any role that
doesn't have every requested column granted — this is standard
PostgREST/Postgres behavior, not a bug, and it was specifically
re-verified during the final audit pass so it wouldn't be
mistaken for one.
Every RPC that writes data on behalf of another user, or that
needs to make an authorization decision a row policy can't
express, is SECURITY DEFINER. Every one of these functions falls
into exactly one of two categories, reviewed individually:
-
Cross-user notification writes —
notify_caretakers,notify_nearby_caretakers,notify_followers,thank_action,respond_to_help_request,respond_to_resource_post— the caller needs to insert into another user'snotificationsrow, which their own grant on that table doesn't permit directly. Each either takes no caller-supplied free text at all, or builds its message from a fixed template. -
Server-side authorization a row policy can't express —
get_colony_exact_location(must return different data per caller, not just gate row visibility),get_own_streak(same shape),confirm_report(atomic counter increment + self- confirm guard + auto-resolve, all in one transaction),mark_cat_seen_today,earn_caretaker_certification,check_colony_verification,recalculate_colony_health,record_daily_visit,record_care_streak,is_user_banned,handle_false_pin_threshold.
The pattern that produced every real finding in this section:
a SECURITY DEFINER function accepting unvalidated input that
ends up somewhere another user will see it (a message, a
notification, a stored value), or a function callable by a role
it shouldn't be callable by. A function that's SECURITY DEFINER
purely to bypass a row check on the caller's own data is a much
smaller risk than one that writes data attributed to or visible
by someone else.
A real vulnerability found during development: several RPCs
were callable by the anon key by default Supabase privilege —
record_daily_visit was found still callable anonymously during
a live test despite its migration's own comment claiming
otherwise (the deployed database had drifted from the migration
history). Fixed by 0075_reassert_record_daily_visit_grant.sql, then re-verified live via curl.
| RPC | Protection | Migration |
|---|---|---|
get_colony_exact_location |
Caretaker/creator link re-validated server-side, every call | 0016 |
confirm_report |
Atomic increment, blocks self-confirmation and duplicate confirmation | 0038 |
record_daily_visit |
Idempotent, anon access re-revoked after drift was found | 0064, 0075 |
thank_action |
Blocks self-thanking | 0068 |
notify_caretakers / notify_nearby_caretakers
|
Fixed server-side message template, no caller-supplied free text | 0065 |
notify_caretakers (rate limit) |
Database-level circuit breaker — one notification batch per colony per 5 minutes, even via direct REST calls | 0078 |
is_user_banned |
SECURITY DEFINER, used inside INSERT policies so ban status can be checked without a public grant |
0082 |
handle_false_pin_threshold |
Trigger on flags insert, escalates ban count, never trusts client-reported flag counts |
0082 |
A note on notify_caretakers: the fixed message template wasn't
a stylistic choice, it was a security decision. Every granted RPC
is exposed as a public REST endpoint (/rest/v1/rpc/notify_caretakers)
reachable with the same anon key that ships in every page load —
if the message content were caller-supplied, this endpoint would
be a ready-made spam/phishing vector, letting anyone inject
arbitrary text into real notifications sent to real caretakers.
SQL injection: every migration was grepped for EXECUTE/
format( — no dynamic SQL construction from caller input exists
anywhere in the schema. All functions use parameterized, static
SQL bodies.
Problem: File uploads across 6 upload sites (colony photos,
cat photos, avatars, timeline photos, story photos, the
new-colony form) built storage paths from user-influenced input
without sanitization — a filename or prefix containing ../
sequences could, in principle, write outside the intended
storage folder.
Fix: centralized all path construction in lib/storage.ts:
-
buildSafeStoragePath()strips..//..\sequences and any character outside an explicit allowlist - The filename is never the user's original filename — it's a timestamp + random token + a validated extension
-
validatePhotoFile()checks an explicit MIME allowlist (image/jpeg,image/png,image/webp,image/gif) instead of a broadimage/*match, which would also acceptimage/svg+xml— SVGs can embed<script>tags, a known upload-based XSS vector unrelated to the path-traversal finding but caught during the same hardening pass -
assertSafeStoragePath()re-asserts the path can't contain..or start with/, called again immediately before every.upload()call as defense in depth — not just buried once inside the builder
Commit: security: fix path traversal vulnerability in file uploads — Aikido report
Problem: The reverse-geocoding call to Nominatim interpolated latitude/longitude directly into a URL string with no validation. Severity was rated LOW because coordinates in practice come from Leaflet map clicks, not typed input — but it was fixed for defense in depth regardless of how likely exploitation was in the current UI.
Fix: lib/validateCoordinates.ts (rejects out-of-range,
NaN, Infinity, or non-numeric coordinates before any request
is built), URLSearchParams instead of string interpolation in
lib/geocode.ts, and redirect: "error" so a malicious
redirect response can't be followed silently. The same
unvalidated pattern was also found in the weather-fetch helper
(outside Aikido's original report) and fixed identically, plus a
pre-existing bug where the geocoding response's language was
always Portuguese regardless of visitor preference.
Commit: security: fix SSRF vulnerability in geocoding requests — Aikido report
| Header | Value | Protects against |
|---|---|---|
X-DNS-Prefetch-Control |
on |
Controls DNS prefetching behavior |
X-Frame-Options |
DENY |
Clickjacking |
X-Content-Type-Options |
nosniff |
MIME-type sniffing |
Referrer-Policy |
strict-origin-when-cross-origin |
Referrer/URL leakage to third parties |
Permissions-Policy |
camera=(), microphone=(), geolocation=(self) |
Unwanted browser permission access |
X-XSS-Protection |
1; mode=block |
Legacy XSS fallback for older browsers |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Downgrade to plain HTTP |
Content-Security-Policy |
Scoped to the app's actual external origins (Supabase, OpenStreetMap tiles, OpenWeatherMap, Nominatim) | XSS, unauthorized resource loading |
The CSP is deliberately narrow — it lists exactly the origins the app talks to (Supabase for data/storage, OSM tile subdomains for map imagery, OpenWeatherMap, Nominatim) rather than a broad allowlist, so a compromised third-party script has nowhere to exfiltrate to even in a worst-case XSS scenario.
Client-side maxLength and range checks are a UX nicety — every
real boundary is re-enforced by a database CHECK constraint,
since a direct REST call with the public anon key bypasses the
client entirely:
-
char_lengthconstraints on every free-text column: colony name (100), narrative (2000), report description (1000), cat name (50), caretaker letter, story content (3000), resource description (1000), profile display name (60), public contact (200) - Geographic range
CHECKconstraints (-90..90latitude,-180..180longitude) on every coordinate column incolonies(6 columns: exact + 2 blur levels) andreports(2 columns) — added specifically becausereportsis anon-insertable and out-of-range values were reaching the table without ever passing throughlib/validateCoordinates.ts, which only guards outbound API calls, not database writes - MIME-type allowlist on every upload path (see §4)
Why this had to move from "only in application code" to "also
a database constraint": a CHECK constraint is the one boundary
that can't be bypassed regardless of which code path reaches the
table — a Next.js API route, a direct client insert, or a raw
curl request. Anything enforced only in lib/ is only as strong
as every call site remembering to call it.
-
API layer:
lib/rateLimit.ts, an in-memory sliding-window limiter — 10 requests/hour for anonymous callers, 30/hour for authenticated, keyed by IP for anon and user id for authenticated. This exists specifically because/api/reportsis the one endpoint an anonymous visitor with no account at all can hit repeatedly. -
Database layer: a circuit breaker inside
notify_caretakers()itself — if an identical(colony_id, type)notification was already created in the last 5 minutes, the insert is skipped. This works even if a caller skips the Next.js API route entirely and calls the public Supabase RPC endpoint directly with the anon key — confirmed live: repeated direct calls to/rest/v1/rpc/notify_caretakersreturned204with no throttling before this fix, and were correctly rate-limited after it.
What doesn't have a rate limit, and why that's an acceptable
risk: posting a community_contacts entry, a colony_stories
post, a resource_posts listing, or a flags submission all
already require authentication — raising the cost of abuse from
"free and anonymous" to "tied to a real account." This is
documented as a known, lower-priority gap rather than silently
left unaddressed.
Once a single colony pin accumulates 3 false-pin flags
(never_seen_cats, location_doesnt_exist, duplicate_colony,
suspicious_harmful), a database trigger (0082) automatically:
- Removes the colony pin from the map (
colonies.removed_at) - Bars the creator from posting new colonies/reports for 1 month (
profiles.banned_until) - Notifies the creator, explaining exactly why and for how long
- On the 3rd such ban for the same account, makes the ban permanent (
profiles.banned)
This is deliberately distributed moderation — nobody at
Felines has to manually review every disputed pin, but a single
disgruntled flag also can't take a real colony down; it takes
three independent people agreeing something is wrong. Ban status
is checked via is_user_banned(), a SECURITY DEFINER function
used inside the INSERT policies for colonies and reports, so
banned/ban-count columns never need a public grant — the same
column-protection pattern used for streak data and exact
coordinates.
Why anonymous users are unaffected: the ban check is
auth.uid() is null or not is_user_banned(auth.uid()) — an
anonymous caller has no auth.uid() to ban in the first place, so
the check is a no-op for them, matching the product's existing
stance that urgent anonymous reporting should never be blocked.
-
Open-redirect protection: the post-login/signup
returnToredirect parameter is validated bylib/safeReturnTo.tsto accept only an internal path starting with exactly one/— rejecting//evil.com,/\evil.com, and any absolute URL. This closes a realistic phishing vector: a crafted link like/login?returnTo=//evil.comcould otherwise redirect a user off-site immediately after a real, successful login — the exact moment a follow-on page is most likely to be trusted. - No service-role key exists anywhere in client-side code, server
code,
.env.local, or git history — confirmed by a full-repository grep forservice_role/SUPABASE_SERVICE, not assumed..env.localcontains exactly 3 intentionally-public variables. - Streak data (
profiles.current_streak,longest_streak) and ban status (banned,banned_until,ban_count) are not publicly readable — protected the same way exact coordinates are, via column-level grant revocation plus a narrowSECURITY DEFINERaccessor function, not a row policy alone. - Session state lives in
localStoragevia Supabase Auth's own client, never in a URL parameter or a non-httpOnly cookie readable by injected scripts.
-
npm audit: 0 vulnerabilities of any severity (info, low, moderate, high, critical) across 448 total dependencies, as of the final pre-submission pass. -
npx depcheck: flagged@tailwindcss/postcss,@types/node, andtailwindcssas unused devDependencies — all three are false positives, used implicitly by the Tailwind/PostCSS build pipeline and ambient TypeScript types, never imported directly in source.leaflet.markerclusterwas flagged as a missing dependency forcomponents/ColonyMap.tsx— pre-existing, unrelated to security, not chased down in this pass. - No secrets in git history — confirmed by a full-repository grep, not a spot check.
Several product decisions were made specifically to protect the physical safety of the cats — not just user data:
- No reporting people, only situations — prevents digital vigilantism between neighbors; the correct channel for an actual crime is DEPREMA/181, not the app.
- 3-confirmation threshold — prevents one user from hiding or fabricating a serious report; the same pattern is reused for community colony verification and the false-pin ban system.
-
Sensitive reports never deleted — permanent evidence trail for poisoning/abuse/disease incidents, enforced by a database trigger (
set_report_sensitivity()) that classifiessuspected_poisoning,suspected_abuse, anddisease_outbreakautomatically, not by a manual flag a caretaker could forget to set. - No public shareable colony URL — even with blur, a direct link lowers the barrier for malicious access; map navigation already provides enough context for someone who's actually looking to help.
- UUID filenames — original user input never reaches storage paths.
- Exact coordinates only via server-side RPC — never in any public API response, for any role.
cats, caretakers (including the letter field), and
timeline_events are fully readable via the public anon key
regardless of a visiting browser's login state, even though the
colony page's UI visually gates them behind "log in to view."
Root cause: this app has no SSR session forwarding.
lib/supabaseClient.ts exports a single shared anon-key client
used identically in server and client components; there is no
cookie-based session on the server. The colony detail page is a
server component, so it always fetches with the anon key
regardless of the visiting browser's actual session — the fetched
data is already in the initial HTML/RSC payload by the time any
client-side gate could hide it.
Why this is a known limitation, not a silently-accepted gap:
- Exact GPS coordinates remain fully protected regardless — confirmed unaffected; this gap is orthogonal to the coordinate-protection system.
- What's exposed is user-generated but not classified as sensitive PII in this app's threat model: cat names/photos, caretakers' names, their letter text, and public timeline events — content caretakers write knowing it's shown on a page that, by design, already surfaces publicly on the map in blurred form.
- No auth tokens, emails, or passwords are exposed.
- The fix (real SSR session forwarding, or moving these fetches to client-only calls gated on a confirmed session) is a genuine architectural change with a blast radius across every server component that fetches colony-scoped data — scoped as a dedicated follow-up rather than a last-minute patch that risks introducing new bugs closer to submission.
| Migration | Fix |
|---|---|
| 0044–0045 | Critical/secondary RPCs reject anon key |
| 0065 | Notification RPCs hardened against spam injection |
| 0066 | Streaks restricted to own user |
| 0067 | Field size limits added to all columns |
| 0068 | Self-thanking blocked |
| 0069 | Unique constraint on knowledge_progress
|
| 0070 | Flag deduplication |
| 0075 |
record_daily_visit anon grant re-revoked after drift |
| 0077 | Colony stories restricted to logged-in visibility |
| 0078 |
notify_caretakers rate-limited at the database layer |
| 0079 | Coordinate range CHECK constraints |
| 0080 |
profiles.display_name length constraint |
| 0082 | 3-false-pin-flag ban system |
See SECURITY.md for the full responsible disclosure policy, response-time commitments, and scope.