# Listing claim verifies via business contact method, not claimant email alone

- **Pattern:** `ab-001080` (`directory-submissions-moderation.moderation.claim-verification-contact`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001080
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001080)

## Why it matters

Listing claim flows that verify only the claimant's own email let any account take over any business listing by clicking a link sent to themselves. The attack is trivial: submit a claim request for a competitor's listing, confirm the email that arrives in your own inbox, done. OWASP A01 (Broken Access Control), CWE-285 (Improper Authorization), and CWE-287 (Improper Authentication) all apply. The verification must reach the listing's on-file contact, not the claimant.

## Severity rationale

Critical because self-email verification allows any user to fraudulently take ownership of any listing, including competitors' businesses.

## Remediation

Send the verification token to the listing's contact email on file, not to the claimant. The claimant provides their email so admins can see who is requesting — but the link goes to the business:

```ts
// app/api/listings/[id]/claim/route.ts
const listing = await db.listings.findUniqueOrThrow({ where: { id: listingId } })
const token = crypto.randomBytes(32).toString('hex')

await db.claimTokens.create({
  data: { listingId, claimantEmail, token, expiresAt: new Date(Date.now() + 48 * 3600_000) }
})

// Email goes to listing.contact_email — NOT to claimantEmail
await sendEmail(listing.contact_email, {
  subject: 'Someone is requesting to claim your listing',
  body: `Approve at: /listings/${listingId}/verify-claim/${token}`
})
```

On the verification GET, confirm the token is unexpired and unverified before transferring ownership.

## Detection

- **ID:** `claim-verification-contact`
- **Severity:** `critical`
- **What to look for:** Examine the claim/ownership verification flow. Look for logic that verifies the claimant is authorized to manage the listing. Check for: (1) verification email sent to the listing's original contact email (not the claimant's), (2) verification call, (3) business document upload, or (4) DNS/domain verification. Do not look for just a verification that the claimant controls their own email.
- **Pass criteria:** Enumerate all relevant code paths. The system verifies ownership via a contact method associated with the listing (original contact email, phone, business document) rather than just confirming the claimant has email access. with at least 1 verified instance.
- **Fail criteria:** Ownership is transferred based only on claimant email verification, or no verification at all, or verification is client-side only.
- **Skip (N/A) when:** The project has no claim/ownership feature.
- **Detail on fail:** `"Users can claim any listing by submitting their email and confirming it. No verification with the business — anyone can steal another business's listing."` or `"Claim request exists but is auto-approved without any verification step."`
- **Remediation:** Implement proper claim verification:

  ```ts
  // app/api/listings/[id]/claim/route.ts
  export async function POST(req: Request) {
    const { listingId } = params
    const { claimantEmail } = await req.json()

    const listing = await db.listings.findUnique({ where: { id: listingId } })

    // Send verification link to the listing's contact email, not the claimant's
    const verificationToken = crypto.randomBytes(32).toString('hex')

    await db.claimToken.create({
      data: {
        listingId,
        claimantEmail,
        verificationToken,
        verifiedAt: null
      }
    })

    // Email sent to listing's contact, not claimant's
    await sendVerificationEmail(listing.contact_email, {
      verificationUrl: `/listings/${listingId}/verify-claim/${verificationToken}`,
      claimantEmail
    })

    return Response.json(
      { message: 'Verification email sent to the business contact on file' },
      { status: 200 }
    )
  }

  // Verification route
  export async function GET(req: Request) {
    const { listingId, token } = params

    const claimToken = await db.claimToken.findFirst({
      where: { listingId, verificationToken: token }
    })

    if (!claimToken || claimToken.verifiedAt) {
      return Response.json(
        { error: 'Invalid or already used token' },
        { status: 400 }
      )
    }

    // Update listing ownership
    await db.listings.update({
      where: { id: listingId },
      data: { owner_id: claimToken.claimantEmail, claimed_at: new Date() }
    })

    return Response.json({ message: 'Listing claimed successfully' })
  }
  ```

## External references

- cwe CWE-285
- cwe CWE-287
- owasp A01

Taxons: access-control

HTML version: https://auditbuffet.com/patterns/ab-001080
