Skip to content

Security

Luara edited this page Jul 5, 2026 · 3 revisions

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


Threat Model

Two distinct threats, addressed by two distinct — but overlapping — sets of controls:

  1. 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.
  2. 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.


1. Progressive Location Blur

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.


2. Row Level Security — 26 Tables

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.


3. Secure RPCs

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:

  1. Cross-user notification writesnotify_caretakers, notify_nearby_caretakers, notify_followers, thank_action, respond_to_help_request, respond_to_resource_post — the caller needs to insert into another user's notifications row, 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.
  2. Server-side authorization a row policy can't expressget_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.


4. Aikido Security Findings

Path Traversal (HIGH severity) ✅ Fixed

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 broad image/* match, which would also accept image/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

SSRF in Geocoding (LOW severity) ✅ Fixed

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


5. Security Headers (next.config.ts)

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.


6. Input Validation, Everywhere, at Both Layers

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_length constraints 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 CHECK constraints (-90..90 latitude, -180..180 longitude) on every coordinate column in colonies (6 columns: exact + 2 blur levels) and reports (2 columns) — added specifically because reports is anon-insertable and out-of-range values were reaching the table without ever passing through lib/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.


7. Rate Limiting, in Two Layers

  • 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/reports is 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_caretakers returned 204 with 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.


8. The 3-False-Pin-Flag Ban System

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:

  1. Removes the colony pin from the map (colonies.removed_at)
  2. Bars the creator from posting new colonies/reports for 1 month (profiles.banned_until)
  3. Notifies the creator, explaining exactly why and for how long
  4. 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.


9. Auth Security

  • Open-redirect protection: the post-login/signup returnTo redirect parameter is validated by lib/safeReturnTo.ts to 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.com could 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 for service_role/SUPABASE_SERVICE, not assumed. .env.local contains 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 narrow SECURITY DEFINER accessor function, not a row policy alone.
  • Session state lives in localStorage via Supabase Auth's own client, never in a URL parameter or a non-httpOnly cookie readable by injected scripts.

10. Dependency and Supply-Chain Hygiene

  • 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, and tailwindcss as 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.markercluster was flagged as a missing dependency for components/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.

11. Animal Safety as a Design Decision

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 classifies suspected_poisoning, suspected_abuse, and disease_outbreak automatically, 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.

12. Known Architectural Limitation (Documented Honestly)

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.

Security Migrations Reference

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

Reporting a Vulnerability

See SECURITY.md for the full responsible disclosure policy, response-time commitments, and scope.

Clone this wiki locally