Skip to content

IssueActions renders an unconditional Refund sponsor action to any authenticated visitor; Bounty has no funder identity to gate it #82

Description

@chonilius

Overview

IssueActions.tsx renders a "Refund sponsor" button for any funded/claimed bounty to any authenticated visitor of the page — there is no check that the current user is the sponsor who actually funded that specific bounty, and no such check is even possible today, because Bounty has no field identifying who funded it:

{(bounty.status === "funded" || bounty.status === "claimed") && (
  <Button size="lg" variant="outline" onClick={handleRefund} disabled={pending}>
    Refund sponsor
  </Button>
)}
async function handleRefund() {
  setError(null);
  setNotice(null);
  setPending(true);
  try {
    await apiPost(`/bounties/${bounty.id}/refund`);
    setNotice("Escrowed funds were refunded to the sponsor.");
    router.refresh();
  } catch (err) {
    setError(err instanceof ApiRequestError ? err.message : "Something went wrong.");
  } finally {
    setPending(false);
  }
}

No funderAddress/funderId/sponsorId is sent, checked, or even available — apiPost is called with no body at all, and the JSX condition rendering the button checks only bounty.status, nothing about user. Any GitHub-authenticated visitor who happens to browse to a funded bounty's detail page sees a live "Refund sponsor" button and can click it.

Whatever authorization actually gates this on the backend (mergefi-backend) is invisible from here — but even in the best case (the backend correctly 403s a non-sponsor's refund attempt), the frontend is still offering an affordance to every visitor that it cannot possibly have a legitimate reason to grant to most of them, which is a real UX/trust problem on its own (a contributor casually browsing bounties sees a button that looks like it could undo a sponsor's funding, for bounties they have no relationship to) — and if the backend's authorization ever has a gap, this is the exact button that would exploit it, with zero friction, since the frontend does nothing to suggest the action is restricted.

This is structurally impossible to fix correctly without a data model change: src/types/index.ts's Bounty interface has claimedBy?: string (the contributor) but no equivalent field for who funded/sponsors the bounty — escrowId?: string exists but is an opaque contract identifier, not a sponsor identity, and (per a related finding in this batch) isn't even rendered anywhere in the UI today. There is no funderAddress, funderId, or sponsorHandle anywhere on the type the frontend has to work with.

Requirements

  • Add a funder/sponsor identity field to the Bounty type (e.g. funderId?: string or funderAddress?: string, matching whatever mergefi-backend's RawBounty shape already carries or would need to start carrying — check with the backend team/repo for the actual field name rather than guessing) and map it through adaptBounty in src/lib/adapters.ts.
  • Once available, gate the "Refund sponsor" button's rendering on the current user actually being the bounty's funder (comparing against user.id/user.stellarAddress as appropriate) — don't rely on the backend's authorization alone to make this correct from a UX standpoint; a button a user can't legitimately use shouldn't be shown to them at all.
  • Audit handleFund similarly: funding an open bounty is legitimately available to any sponsor (crowdfunding an issue is presumably intentional per the README's "Sponsors fund repositories or specific issues/milestones" framing), so this one is likely fine as unconditional — but confirm and document that reasoning explicitly in the PR rather than leaving the distinction implicit, since a future reader shouldn't have to re-derive why handleFund is intentionally open while handleRefund is not.
  • Confirm what the backend actually does today when a non-funder calls POST /bounties/:id/refund — document the finding (proper 403, silent success, or something else) as part of this issue's investigation, since it materially changes how urgent the frontend-side gating is.

Acceptance Criteria

  • Bounty (and the adaptBounty mapping that populates it) carries a funder/sponsor identity field sourced from the backend.
  • The "Refund sponsor" button is not rendered (or is rendered in a clearly-disabled, explained state) for any user who is not the bounty's funder.
  • The actual current backend authorization behavior for a non-funder's refund attempt is documented in the PR, with a link to or description of the relevant mergefi-backend code/behavior if accessible.
  • The "Fund this bounty" button's intentionally-unconditional availability is explicitly documented (a code comment at minimum) so the asymmetry with "Refund sponsor" reads as a deliberate design decision, not an oversight.
  • No regression to the actual funder's ability to refund their own bounty.

Additional Notes

Precise references:

  • src/app/issues/[id]/IssueActions.tsx:92-96 — the unconditional "Refund sponsor" button render, gated only on bounty.status.
  • src/app/issues/[id]/IssueActions.tsx:64-77handleRefund, calling apiPost(\/bounties/${bounty.id}/refund`)` with no body and no client-side authorization check of any kind.
  • src/types/index.ts:23-40 — the full Bounty interface; confirmed no funder/sponsor identity field exists (claimedBy?: string is the only participant-identity field present, and it names the contributor, not the sponsor).
  • src/lib/adapters.ts:42-53 (RawBounty) and :55-82 (adaptBounty) — confirmed the raw shape this app currently reads from the backend also has no funder field mapped through (whether or not the backend's actual entity has one — that's the open question this issue's investigation requirement is about).

Edge cases: if the bounty is funded via a crowdfunding-style model where multiple sponsors can contribute to a single bounty's escrow (plausible given the milestone-funding pages elsewhere in this app support exactly that pattern for milestones), "the funder" may not be a single identity at all, and the correct authorization model might be "any of the contributing funders, refunded proportionally" rather than a single owner check — this materially affects both the data model and the UI, and should be confirmed with the backend/product before assuming a single funderId is even the right shape.

Test/reproduction plan: with a mocked useAuth() returning a user who is not the bounty's funder (once the field exists), render IssueActions for a "funded" bounty and assert the "Refund sponsor" button is absent or disabled. Repeat with a user who is the funder and assert the button renders and functions as before.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingsecuritySecurity-related issuevery hardVery difficult task, expert-level effort required

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions