Improper Enforcement of a Single, Unique Action

Description

Improper Enforcement of a Single, Unique Action is a business logic vulnerability where software fails to properly restrict an actor to performing a particular action only once or maintaining a single unique instance of that action. Many applications have operations that should be inherently unique or one-time: voting in elections, redeeming discount codes, claiming free trials, submitting applications, or making certain financial transactions. When these restrictions are not properly enforced server-side, attackers can repeat actions that should be singular, potentially manipulating outcomes, gaining unfair advantages, or causing financial loss.

Risk

This vulnerability can have severe business consequences. In voting systems, attackers could "stuff the ballot box" and manipulate election results. In e-commerce, coupon codes or promotional offers could be redeemed multiple times, causing financial losses. In ticketing systems, the same seat could be booked multiple times, leading to overbooking and customer service issues. In gaming or competition contexts, entries could be submitted multiple times to increase winning chances. In financial systems, transactions that should occur once might be duplicated. The impact extends beyond individual exploitation—systematic abuse can undermine the integrity of entire systems.

Solution

Implement server-side enforcement of action uniqueness. Use database constraints (unique indexes) to prevent duplicate entries. Track completed actions with persistent storage, not just client-side state or cookies. Generate and validate single-use tokens for one-time operations. Implement idempotency keys for critical operations to prevent duplicate processing. Use transactions with proper isolation levels to prevent race conditions. For voting or similar systems, verify identity before allowing the action and record completion in a tamper-proof manner. Implement rate limiting as a defense-in-depth measure.

Common Consequences

ImpactDetails
OtherScope: Other

Gain Advantage - Attackers may gain advantages over legitimate users by performing the restricted action multiple times.
IntegrityScope: Integrity

Modify Application Data - Repeated actions can corrupt data integrity, such as vote counts or inventory levels.
OtherScope: Other

Business Impact - Financial losses from coupon abuse, unfair competition outcomes, overbooking, or other business logic violations.

Example Code

Vulnerable Code

# Vulnerable: Voting system without proper uniqueness enforcement
from flask import Flask, request, session

app = Flask(__name__)

votes = {'candidate_a': 0, 'candidate_b': 0}

@app.route('/vote', methods=['POST'])
def vulnerable_vote():
    candidate = request.form['candidate']

    # Vulnerable: Only checks client-side cookie
    if session.get('has_voted'):
        return 'Already voted', 403

    # Vulnerable: Cookie can be deleted/modified by client
    session['has_voted'] = True
    votes[candidate] += 1

    return 'Vote recorded'

# Attacker clears cookies and votes again!
// Vulnerable: Coupon redemption without server-side tracking
public class VulnerableCouponService {

    public boolean redeemCoupon(String couponCode, int userId) {
        Coupon coupon = couponRepository.findByCode(couponCode);

        if (coupon == null || !coupon.isValid()) {
            return false;
        }

        // Vulnerable: No check if user already redeemed this coupon
        // No tracking of redemption
        applyDiscount(userId, coupon.getDiscount());

        return true;
    }
}

// User can redeem same coupon multiple times
// Vulnerable: Free trial registration
<?php
function vulnerable_start_trial($email) {
    // Vulnerable: Only checks if email exists as active user
    $user = get_user_by_email($email);

    if ($user && $user['status'] === 'active') {
        return false;  // Already has account
    }

    // Vulnerable: No check for previous trials
    // User creates multiple accounts with email+1@, email+2@, etc.
    create_trial_account($email);
    return true;
}
?>
// Vulnerable: Contest entry system
app.post('/contest/enter', async (req, res) => {
    const { userId, contestId } = req.body;

    // Vulnerable: Race condition allows multiple entries
    const existingEntry = await Entry.findOne({ userId, contestId });

    if (existingEntry) {
        return res.status(400).json({ error: 'Already entered' });
    }

    // Between check and insert, another request can succeed
    await Entry.create({ userId, contestId, timestamp: Date.now() });

    res.json({ success: true });
});
# Vulnerable: One-time password reset link
def vulnerable_reset_password(token, new_password):
    reset_request = get_reset_request(token)

    if not reset_request:
        return False

    # Vulnerable: Token not invalidated immediately
    # Can be used multiple times before it expires
    user = get_user(reset_request['user_id'])
    user.set_password(new_password)
    user.save()

    # Token should be invalidated here!
    return True
// Vulnerable: Ticket purchase without seat locking
public class VulnerableTicketService
{
    public bool PurchaseTicket(int seatId, int userId)
    {
        var seat = _db.Seats.Find(seatId);

        // Vulnerable: No locking - race condition
        if (!seat.IsAvailable)
        {
            return false;
        }

        // Multiple users can pass the check simultaneously
        seat.IsAvailable = false;
        seat.OwnerId = userId;
        _db.SaveChanges();

        return true;
    }
}

Fixed Code

# Fixed: Voting system with server-side uniqueness enforcement
from flask import Flask, request, g
import sqlite3

app = Flask(__name__)

@app.route('/vote', methods=['POST'])
def fixed_vote():
    candidate = request.form['candidate']
    user_id = get_authenticated_user_id()

    try:
        # Fixed: Database constraint prevents duplicate votes
        db = get_db()
        db.execute(
            'INSERT INTO votes (user_id, candidate, timestamp) VALUES (?, ?, ?)',
            (user_id, candidate, datetime.now())
        )
        db.commit()

        return 'Vote recorded'
    except sqlite3.IntegrityError:
        # Unique constraint on user_id violated
        return 'Already voted', 403

# Database schema:
# CREATE TABLE votes (
#     id INTEGER PRIMARY KEY,
#     user_id INTEGER UNIQUE,  -- Ensures one vote per user
#     candidate TEXT,
#     timestamp DATETIME
# );
// Fixed: Coupon redemption with tracking
import javax.persistence.*;

public class FixedCouponService {

    @Transactional
    public boolean redeemCoupon(String couponCode, int userId) {
        Coupon coupon = couponRepository.findByCode(couponCode);

        if (coupon == null || !coupon.isValid()) {
            return false;
        }

        // Fixed: Check for existing redemption
        if (redemptionRepository.existsByUserIdAndCouponId(userId, coupon.getId())) {
            return false;  // Already redeemed by this user
        }

        // Fixed: Record redemption atomically
        Redemption redemption = new Redemption();
        redemption.setUserId(userId);
        redemption.setCouponId(coupon.getId());
        redemption.setTimestamp(Instant.now());

        try {
            redemptionRepository.save(redemption);
            applyDiscount(userId, coupon.getDiscount());
            return true;
        } catch (DataIntegrityViolationException e) {
            // Unique constraint violation - concurrent redemption
            return false;
        }
    }
}

// Database: unique constraint on (user_id, coupon_id)
// Fixed: Free trial with comprehensive tracking
<?php
function fixed_start_trial($email, $device_fingerprint, $ip_address) {
    $normalized_email = normalize_email($email);  // Remove +tags, etc.

    // Fixed: Check multiple indicators of previous trials
    $previous_trial = db_query(
        'SELECT * FROM trial_history WHERE
         normalized_email = ? OR
         device_fingerprint = ? OR
         ip_address = ?',
        [$normalized_email, $device_fingerprint, $ip_address]
    );

    if ($previous_trial) {
        return ['success' => false, 'reason' => 'Trial already used'];
    }

    // Fixed: Record trial with multiple identifiers
    db_query(
        'INSERT INTO trial_history
         (email, normalized_email, device_fingerprint, ip_address, created_at)
         VALUES (?, ?, ?, ?, NOW())',
        [$email, $normalized_email, $device_fingerprint, $ip_address]
    );

    create_trial_account($email);
    return ['success' => true];
}

function normalize_email($email) {
    // Remove +suffix, dots (for Gmail), convert to lowercase
    $parts = explode('@', strtolower($email));
    $local = preg_replace('/\+.*$/', '', $parts[0]);
    $local = str_replace('.', '', $local);  // Gmail ignores dots
    return $local . '@' . $parts[1];
}
?>
// Fixed: Contest entry with atomic operations
app.post('/contest/enter', async (req, res) => {
    const { userId, contestId } = req.body;

    try {
        // Fixed: Use upsert with unique constraint
        // Atomic operation prevents race condition
        await Entry.findOneAndUpdate(
            { userId, contestId },
            {
                $setOnInsert: {
                    userId,
                    contestId,
                    timestamp: Date.now()
                }
            },
            { upsert: true, rawResult: true }
        ).then(result => {
            if (result.lastErrorObject.updatedExisting) {
                return res.status(400).json({ error: 'Already entered' });
            }
            res.json({ success: true });
        });
    } catch (error) {
        if (error.code === 11000) {  // Duplicate key error
            return res.status(400).json({ error: 'Already entered' });
        }
        throw error;
    }
});

// MongoDB: db.entries.createIndex({ userId: 1, contestId: 1 }, { unique: true })
# Fixed: One-time password reset link
from datetime import datetime, timedelta

def fixed_reset_password(token, new_password):
    # Fixed: Use transaction to ensure atomicity
    with db.transaction():
        reset_request = db.query(
            'SELECT * FROM reset_tokens WHERE token = ? FOR UPDATE',
            [token]
        ).fetchone()

        if not reset_request:
            return False

        if reset_request['used']:
            return False  # Already used

        if reset_request['expires_at'] < datetime.now():
            return False  # Expired

        # Fixed: Mark token as used BEFORE changing password
        db.execute(
            'UPDATE reset_tokens SET used = TRUE, used_at = ? WHERE token = ?',
            [datetime.now(), token]
        )

        # Now change the password
        user = get_user(reset_request['user_id'])
        user.set_password(new_password)
        user.save()

    return True
// Fixed: Ticket purchase with pessimistic locking
public class FixedTicketService
{
    public bool PurchaseTicket(int seatId, int userId)
    {
        using (var transaction = _db.Database.BeginTransaction(
            IsolationLevel.Serializable))
        {
            try
            {
                // Fixed: Lock the row during transaction
                var seat = _db.Seats
                    .FromSqlRaw("SELECT * FROM Seats WHERE Id = {0} FOR UPDATE", seatId)
                    .FirstOrDefault();

                if (seat == null || !seat.IsAvailable)
                {
                    transaction.Rollback();
                    return false;
                }

                seat.IsAvailable = false;
                seat.OwnerId = userId;
                seat.PurchasedAt = DateTime.UtcNow;

                _db.SaveChanges();
                transaction.Commit();

                return true;
            }
            catch
            {
                transaction.Rollback();
                return false;
            }
        }
    }
}

CVE Examples

  • CVE-2008-0294: Ticket-booking application allows users to lock the same seat multiple times, causing overbooking.
  • CVE-2005-4051: CMS allows users to vote multiple times on downloads, manipulating rankings.
  • CVE-2002-216: Polling software allows repeated voting by manipulating browser cookies.
  • CVE-2002-1018: Library system allows checking out the same e-book multiple times.

  • CWE-799: Improper Control of Interaction Frequency (parent)
  • CWE-362: Concurrent Execution Using Shared Resource with Improper Synchronization (related)
  • CWE-840: Business Logic Errors (category)

References

  1. MITRE Corporation. "CWE-837: Improper Enforcement of a Single, Unique Action." https://cwe.mitre.org/data/definitions/837.html
  2. OWASP. "Business Logic Security Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Business_Logic_Security_Cheat_Sheet.html
  3. OWASP. "Testing for Business Logic." OWASP Testing Guide.