Liberapay · Report #3878586
Unauthenticated team "income/payments" export ignores donor privacy settings (hide_giving, hide_from_lists) and uses frozen visibility, exposing donat
How it was found
Liberapay lets teams publish the list of donors who chose to donate publicly (donation
visibility = 3). That public list is served by www/%username/income/payments.spt. The problem
is that this endpoint decides whether to reveal a donor using only the payment's frozen
visibility flag, and ignores the two donor privacy controls that every other public surface honors
(hide_giving, hide_from_lists) as well as the recipient's opt‑in gate. As a result an
unauthenticated attacker can retrieve donor identity + exact amount + date for donations that the
donor has explicitly opted out of public listings for, or has since changed to private/secret.
The privacy model it should follow
The canonical public patron list on the team's profile page (www/%username/index.html.spt) gates
each donor on all of the following:
index.html.spt:32— recipient opted in:if patron_visibilities & 4:index.html.spt:42—AND tip.visibility = 3index.html.spt:45—AND NOT tipper_p.hide_givingindex.html.spt:46—AND tipper_p.hide_from_lists = 0- it also reads the current visibility from
current_tips.
Root cause (code)
www/%username/income/payments.spt:
:33participant = get_participant(state, restrict=False)→ unauthenticated.:34only constraint isif participant.kind != 'group'(teams only).:51‑53selectspayer.id,payer.username,payer.public_name.:62the only donor‑identity gate:LEFT JOIN participants payer ON payer.id = pi.payer AND pt.visibility = 3
It omits hide_giving, omits hide_from_lists, omits the recipient patron_visibilities & 4
gate, and reads pt.visibility = payin_transfers.visibility, which is set once at payment
time (liberapay/payin/common.py:752, abs(visibility)) and is never updated afterwards
(none of the four UPDATE payin_transfers statements touch the visibility column; changing a
donation's visibility via set_tip_to / the giving page only INSERTs a new tips row). So the
per‑payment visibility is frozen forever.
Two concrete consequences, each a privacy violation:
- Opt‑out ignored. A donor who set
hide_from_lists = 1(explicit "exclude me from public lists") orhide_givingis still fully listed here — name + exact per‑payment amount + date — even though the team's own profile page excludes them. - Downgrade ignored (strongest case). A donor who made a public donation and later changed
that donation to private or secret disappears from the profile page and from
/<team>/patrons/public.csv(both readcurrent_tips.visibility), but their historical public payments remain exposed here because this endpoint reads the frozenpayin_transfers.visibility. The donor took a deliberate privacy action and reasonably believes it is now hidden everywhere.
Proof — unauthenticated disclosure surface (confirmed live 2026‑07‑21)
GET https://liberapay.com/GIMP/income/payments.json HTTP/2
Host: liberapay.com
(no Cookie, no session)
→ HTTP 200, JSON array (45 KB). Public‑donation entries return
"payer_id": <int>, "payer_username": "<username>", "payer_public_name": "<real name>" plus
amount, unit_amount, period, n_units, date, payment_method. Non‑public entries return
payer_id/username/public_name = null, confirming the sole gate is pt.visibility = 3.
(Real donor identities were returned; they are redacted in evidence/income-payments-disclosure.md.)
/<team>/income/payments.csv returns the entire history in one request (per_page defaults to
None for CSV, no pagination cap).
Full end‑to‑end PoC for the privacy bypass (donor account + one small donation)
- Register free account A.
- As A, make a public (
visibility=3) donation to any team and complete the payment (any small amount). - As A, either (a) set the donation's visibility to secret on
/<A>/giving/, or (b) enable "Hide me from public lists" (hide_from_lists) in privacy settings. - Verify A no longer appears on
https://liberapay.com/<team>/(profile) nor inhttps://liberapay.com/<team>/patrons/public.csv. - Fetch
https://liberapay.com/<team>/income/payments.jsonunauthenticated → account A is still listed with username, exact amount, and date.
Impact
Unauthenticated third‑party disclosure of donor identity, exact donation amount, and dates for a
team's public donors, bypassing the donor's explicit hide_giving / hide_from_lists opt‑outs and
continuing to expose donations the donor has since made private or secret. Donation relationships
and amounts are the platform's core privacy asset.
Remediation
Make the query match the profile page's model:
LEFT JOIN participants payer
ON payer.id = pi.payer
AND pt.visibility = 3
AND payer.hide_giving IS NOT TRUE
AND payer.hide_from_lists = 0
and gate the endpoint on the recipient's patron_visibilities & 4, and filter on the current
tip visibility (join current_tips) rather than the frozen payin_transfers.visibility, so that a
donor's later privacy change is honored here as it is everywhere else.