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.
Critical because self-email verification allows any user to fraudulently take ownership of any listing, including competitors' businesses.
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:
// 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.
ID: directory-submissions-moderation.moderation.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:
// 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' })
}