-
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.
Full audit report: AUDIT_REPORT.md
Vulnerability disclosure policy: SECURITY.md
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
|
Implementation:
revoke select (latitude, longitude) on colonies from anon, authenticated;Even a signed-in caretaker's own browser cannot fetch those two columns directly — the only path to the real value is the RPC above, which never trusts a cached session flag or a client-side boolean.
Tested live: SELECT latitude FROM colonies with the anon key → 42501 permission denied ✅. The identical column-vs-function pattern is reused for reports.latitude/longitude.
RLS enabled on all 26 tables. Every policy was tested with direct API calls using the anon key — the same vantage point an attacker with no account has.
| Table | Anonymous | Authenticated | Caretaker / Owner |
|---|---|---|---|
colonies |
Blurred columns only | Blurred + near-blurred columns | Exact coordinates via RPC |
reports |
INSERT only (no SELECT) | INSERT + SELECT | SELECT + UPDATE (own or caretaker's colony) |
knowledge_progress |
No access | Own rows only | Own rows only |
profiles |
Public fields only | Own full row + others' public fields | Same |
flags |
INSERT only | INSERT only | INSERT only |
notifications |
No access | Own rows only (select/update/delete) | Same |
Every RPC that writes data on behalf of another user, or makes
an authorization decision a row policy can't express, is
SECURITY DEFINER.
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.
| 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 |
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.
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 -
assertSafeStoragePath()re-asserts the path can't contain..or start with/, called again immediately before every.upload()call as defense in depth
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.
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 |
Permissions-Policy |
camera=(), microphone=(), geolocation=(self) |
Unwanted browser permission access |
X-XSS-Protection |
1; mode=block |
Legacy XSS fallback |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Downgrade to plain HTTP |
Content-Security-Policy |
Scoped to the app's real external origins | XSS, unauthorized resource loading |
-
API layer:
lib/rateLimit.ts, an in-memory sliding-window limiter — 10 requests/hour anonymous, 30/hour authenticated, keyed by IP or user id. -
Database layer: a circuit breaker inside
notify_caretakers()itself, so the limit still holds even if a caller skips the Next.js API route entirely and calls the public Supabase RPC endpoint directly with the anon key.
Client-side maxLength and range checks are a UX nicety — every
real boundary is re-enforced by a database CHECK constraint:
-
char_lengthconstraints on every free-text column (colony name, narrative, report description, cat name, caretaker letter, story content, resource description, profile display name, public contact) - Geographic range
CHECKconstraints (-90..90latitude,-180..180longitude) on every coordinate column incoloniesandreports - MIME-type allowlist on every upload path
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.
-
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.comand any absolute URL. - No service-role key exists anywhere in client-side code, server
code,
.env.local, or git history — confirmed by a full-repository grep. - Streak data 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.
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
- 3-confirmation threshold — prevents one user from hiding or fabricating a serious report
- Sensitive reports never deleted — permanent evidence trail for poisoning/abuse/disease incidents
- No public shareable colony URL — even with blur, a direct link lowers the barrier for malicious access
- UUID filenames — original user input never reaches storage paths
- Exact coordinates only via server-side RPC — never in any public API response
| 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.