HexBrain

Liberapay · Report #3878586

Unauthenticated team "income/payments" export ignores donor privacy settings (hide_giving, hide_from_lists) and uses frozen visibility, exposing donat

Validstatus
mediumseverity
$100bounty
68upvotes
1 Aug 2026disclosed

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 = 3
  • index.html.spt:45 — AND NOT tipper_p.hide_giving
  • index.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:

  • :33 participant = get_participant(state, restrict=False) → unauthenticated.
  • :34 only constraint is if participant.kind != 'group' (teams only).
  • :51‑53 selects payer.id, payer.username, payer.public_name.
  • :62 the 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:

  1. Opt‑out ignored. A donor who set hide_from_lists = 1 (explicit "exclude me from public lists") or hide_giving is still fully listed here — name + exact per‑payment amount + date — even though the team's own profile page excludes them.
  2. 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 read current_tips.visibility), but their historical public payments remain exposed here because this endpoint reads the frozen payin_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)

  1. Register free account A.
  2. As A, make a public (visibility=3) donation to any team and complete the payment (any small amount).
  3. 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.
  4. Verify A no longer appears on https://liberapay.com/<team>/ (profile) nor in https://liberapay.com/<team>/patrons/public.csv.
  5. Fetch https://liberapay.com/<team>/income/payments.json unauthenticated → 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original