Skip to content

[CRITICAL] — PATCH /campaigns/:id is an IDOR: any authenticated wallet holder can overwrite *any* campaign's title, description, story, and image #19

@Alqku

Description

@Alqku

Severity: Critical
Type: Security vulnerability
Scope: Campaigns
Labels: bug, security, Official Campaign

Description

CampaignsService.updateCampaign (src/campaigns/campaigns.service.ts, lines ~53–78) accepts a userId parameter and never uses it. After the findUnique check the method performs prisma.campaign.update on the requested campaignId with no comparison against campaign.creatorId and no admin override:

async updateCampaign(userId: string, campaignId: string, dto: UpdateCampaignDto) {
  const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId } });
  if (!campaign) throw new NotFoundException('Campaign not found');
  // userId is silently ignored
  return this.prisma.campaign.update({ where: { id: campaignId }, data: { /* fields */ } });
}

The controller (src/campaigns/campaigns.controller.ts, lines ~73–93) decorates the route with only @UseGuards(JwtAuthGuard). There is no AdminGuard, no RolesGuard, no per-resource ownership check, and the FORBIDDEN_FIELDS blocklist only filters certain fields — never the caller.

Consequences:

  • Any authenticated wallet holder can overwrite title, description, story, and imageUrl of every campaign on the platform, including featured campaigns.
  • Attacker can inject phishing URLs into story or imageUrl (no sanitisation either).
  • Defacing other creators' campaigns requires no admin tooling and leaves no AuditLog trail.

This is a textbook OWASP A01:2021 Broken Access Control / IDOR vulnerability. Categorised CRITICAL because exploitation is trivial (one authenticated PATCH request) and blast radius is platform-wide.

Recommendation

  • In CampaignsService.updateCampaign, immediately after fetching the campaign, enforce if (campaign.creatorId !== userId && !isAdmin) throw new ForbiddenException(). Mirror the existing deleteUpdate admin-flag pattern.
  • Add @UseGuards(RolesGuard) and @Roles('admin', 'creator') to the controller method, with role normalised from req.user.role like deleteUpdate already does.
  • Move FORBIDDEN_FIELDS from the controller into the DTO using @Exclude() from class-transformer so field protection is declarative.
  • Write a regression test that issues PATCH /campaigns/<id> as wallet A against a campaign owned by wallet B and asserts a 403; do the same for imageUrl because the current absence of validation is what enables phishing injection.
  • Add an AuditLog row for every successful (and rejected) update with actorUserId, targetCampaignId, and the diff applied.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignAudit finding under the Official CampaignbugSomething isn't workingsecuritySecurity vulnerability or hardening

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions