Skip to content

Firestore Transaction and Batched Write Vulnerabilities

Name: Transaction and Batched Write Vulnerabilities

Applicable Services: Cloud Firestore

Description

Transaction Security Risks

The Technical Flaw: Security vulnerabilities in Firestore transactions and batch writes can occur when:

  • Authorization checks are performed outside of transactions, creating race conditions
  • Transaction logic doesn't validate all operations before committing
  • Cross-document operations bypass individual document security rules
  • Batch writes don't properly enforce per-document permissions

Intended Behavior: Transactions and batch writes should maintain all security constraints while providing atomic operations. Each document operation within a transaction should still respect security rules and authorization checks.

The Risk: Improper transaction handling can lead to unauthorized data modifications, data consistency violations, and privilege escalation attacks.

Impact

Critical Security Consequences

Transaction vulnerabilities can have severe consequences:

  • Privilege Escalation: Users can modify documents they shouldn't have access to through transaction boundaries
  • Data Inconsistency: Race conditions can create invalid data states
  • Authorization Bypass: Cross-document operations may circumvent individual security rules
  • Atomic Abuse: Attackers can bundle unauthorized operations with legitimate ones
  • Business Logic Violations: Complex transactions may violate application-specific constraints

Example Attack Scenarios

Exploit Scenario: Authorization Bypass Through Transactions

This attack demonstrates how inadequate authorization in transactions can be exploited.

The Vulnerable Code:

// VULNERABLE: Authorization check outside transaction
async function transferFunds(fromAccount, toAccount, amount) {
  // Check authorization outside transaction - RACE CONDITION
  const fromDoc = await db.collection('accounts').doc(fromAccount).get();
  if (fromDoc.data().owner !== currentUser.uid) {
    throw new Error('Unauthorized');
  }

  // Transaction runs without re-checking authorization
  await db.runTransaction(async (transaction) => {
    const fromRef = db.collection('accounts').doc(fromAccount);
    const toRef = db.collection('accounts').doc(toAccount);

    const fromSnapshot = await transaction.get(fromRef);
    const currentBalance = fromSnapshot.data().balance;

    // No authorization re-check here
    transaction.update(fromRef, { balance: currentBalance - amount });
    transaction.update(toRef, { balance: admin.firestore.FieldValue.increment(amount) });
  });
}

Attack Steps: 1. Attacker calls the function with their own account as fromAccount 2. During the time between authorization check and transaction execution, attacker changes account ownership 3. Transaction executes with stale authorization data 4. Funds are transferred from account the attacker no longer owns

Outcome: Attacker successfully steals funds from accounts they don't control.

Exploit Scenario: Batch Write Privilege Escalation

This scenario shows how batch writes can be abused to perform unauthorized operations.

The Vulnerable Pattern:

// VULNERABLE: Batch write with inadequate validation
async function updateUserProfile(updates) {
  const batch = db.batch();

  // Only checks if user owns their profile document
  const userRef = db.collection('users').doc(currentUser.uid);
  batch.update(userRef, updates);

  // Doesn't validate admin operations hidden in updates
  if (updates.adminActions) {
    updates.adminActions.forEach(action => {
      const targetRef = db.collection('users').doc(action.targetUser);
      batch.update(targetRef, action.changes);
    });
  }

  await batch.commit();
}

Attack Payload:

const maliciousUpdates = {
  name: "Updated Name",
  adminActions: [
    {
      targetUser: "admin-user-id",
      changes: { role: "superuser", permissions: ["all"] }
    },
    {
      targetUser: "victim-user-id", 
      changes: { accountBalance: 0, disabled: true }
    }
  ]
};

await updateUserProfile(maliciousUpdates);

Outcome: Attacker escalates privileges to admin and disables victim accounts in a single atomic operation.

Mitigation

Secure Transaction Patterns

// VULNERABLE: Authorization outside transaction
async function secureTransfer(fromAccount, toAccount, amount) {
  // Race condition: authorization check outside transaction
  const fromDoc = await db.collection('accounts').doc(fromAccount).get();
  if (fromDoc.data().owner !== currentUser.uid) {
    throw new Error('Unauthorized');
  }

  await db.runTransaction(async (transaction) => {
    const fromRef = db.collection('accounts').doc(fromAccount);
    const toRef = db.collection('accounts').doc(toAccount);

    const fromSnapshot = await transaction.get(fromRef);
    const currentBalance = fromSnapshot.data().balance;

    transaction.update(fromRef, { balance: currentBalance - amount });
    transaction.update(toRef, { balance: admin.firestore.FieldValue.increment(amount) });
  });
}
// SECURE: Authorization within transaction
async function secureTransfer(fromAccount, toAccount, amount) {
  await db.runTransaction(async (transaction) => {
    const fromRef = db.collection('accounts').doc(fromAccount);
    const toRef = db.collection('accounts').doc(toAccount);

    // Authorization check INSIDE transaction
    const fromSnapshot = await transaction.get(fromRef);
    const fromData = fromSnapshot.data();

    // Validate ownership within transaction
    if (fromData.owner !== currentUser.uid) {
      throw new Error('Unauthorized: Not account owner');
    }

    // Validate business rules
    if (fromData.balance < amount) {
      throw new Error('Insufficient funds');
    }

    if (amount <= 0) {
      throw new Error('Invalid transfer amount');
    }

    // Get target account and validate
    const toSnapshot = await transaction.get(toRef);
    if (!toSnapshot.exists) {
      throw new Error('Target account does not exist');
    }

    // Perform atomic updates
    transaction.update(fromRef, { 
      balance: fromData.balance - amount,
      lastModified: admin.firestore.FieldValue.serverTimestamp()
    });

    transaction.update(toRef, { 
      balance: admin.firestore.FieldValue.increment(amount),
      lastModified: admin.firestore.FieldValue.serverTimestamp()
    });

    // Log the transaction
    const logRef = db.collection('transaction_logs').doc();
    transaction.set(logRef, {
      from: fromAccount,
      to: toAccount,
      amount: amount,
      timestamp: admin.firestore.FieldValue.serverTimestamp(),
      initiatedBy: currentUser.uid
    });
  });
}

Secure Batch Write Patterns

// VULNERABLE: No per-document authorization
async function bulkUpdateUsers(updates) {
  const batch = db.batch();

  updates.forEach(update => {
    const userRef = db.collection('users').doc(update.userId);
    batch.update(userRef, update.changes);
  });

  await batch.commit();
}
// SECURE: Validate each operation before batching
async function bulkUpdateUsers(updates) {
  // Pre-validate all operations
  const validatedUpdates = [];

  for (const update of updates) {
    // Check authorization for each document
    const userDoc = await db.collection('users').doc(update.userId).get();

    if (!userDoc.exists) {
      throw new Error(`User ${update.userId} does not exist`);
    }

    const userData = userDoc.data();

    // Verify user can modify this document
    if (userData.owner !== currentUser.uid && !isAdmin(currentUser)) {
      throw new Error(`Unauthorized to modify user ${update.userId}`);
    }

    // Validate the specific changes
    validateUserUpdateFields(update.changes, userData, currentUser);

    validatedUpdates.push(update);
  }

  // Only proceed if all validations pass
  const batch = db.batch();

  validatedUpdates.forEach(update => {
    const userRef = db.collection('users').doc(update.userId);
    const sanitizedChanges = sanitizeUserFields(update.changes);

    batch.update(userRef, {
      ...sanitizedChanges,
      lastModified: admin.firestore.FieldValue.serverTimestamp(),
      modifiedBy: currentUser.uid
    });
  });

  await batch.commit();
}

function validateUserUpdateFields(changes, currentData, user) {
  // Prevent privilege escalation
  if (changes.role && !isAdmin(user)) {
    throw new Error('Unauthorized to modify user role');
  }

  // Prevent balance manipulation
  if (changes.accountBalance && !isFinanceAdmin(user)) {
    throw new Error('Unauthorized to modify account balance');
  }

  // Validate data types and ranges
  if (changes.age && (typeof changes.age !== 'number' || changes.age < 0 || changes.age > 150)) {
    throw new Error('Invalid age value');
  }
}

function sanitizeUserFields(changes) {
  // Remove any system fields that shouldn't be directly modified
  const { createdAt, systemId, internalFlags, ...userFields } = changes;
  return userFields;
}

Security Rules for Transactions

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Account documents with transaction support
    match /accounts/{accountId} {
      allow read: if request.auth != null && 
                     request.auth.uid == resource.data.owner;

      // Allow updates only with proper validation
      allow update: if request.auth != null && 
                       request.auth.uid == resource.data.owner &&
                       // Ensure balance changes are valid
                       validateBalanceChange() &&
                       // Prevent direct manipulation of system fields
                       !hasRestrictedFieldChanges();
    }

    // Transaction logs - write-only for audit
    match /transaction_logs/{logId} {
      allow create: if request.auth != null &&
                       request.resource.data.initiatedBy == request.auth.uid;
      allow read: if request.auth != null && 
                     (request.auth.uid == resource.data.initiatedBy ||
                      isAdmin());
    }

    function validateBalanceChange() {
      return request.resource.data.balance >= 0 &&
             request.resource.data.balance is number;
    }

    function hasRestrictedFieldChanges() {
      return request.resource.data.diff(resource.data).affectedKeys().hasAny([
        'owner', 'createdAt', 'accountType'
      ]);
    }

    function isAdmin() {
      return request.auth.token.admin == true;
    }
  }
}

Prevention Best Practices

Transaction Security Guidelines

  1. Authorization Within Transactions
  2. Always perform authorization checks inside the transaction
  3. Re-validate permissions for each document operation
  4. Don't rely on pre-transaction authorization checks

  5. Validation and Sanitization

  6. Validate all input data within the transaction
  7. Sanitize values before applying updates
  8. Check business logic constraints atomically

  9. Error Handling

  10. Implement comprehensive error handling within transactions
  11. Ensure transactions fail fast on authorization violations
  12. Log security-relevant transaction failures

  13. Atomic Consistency

  14. Ensure all operations in a transaction are logically related
  15. Don't mix unrelated operations in a single transaction
  16. Validate cross-document relationships

Batch Write Security Guidelines

  1. Per-Document Validation
  2. Validate authorization for each document in the batch
  3. Check permissions before creating the batch
  4. Sanitize all field updates

  5. Size and Rate Limiting

  6. Limit batch sizes to prevent abuse
  7. Implement rate limiting for batch operations
  8. Monitor for suspicious batch patterns

  9. Audit and Logging

  10. Log all batch operations with user context
  11. Track the scope and impact of batch changes
  12. Monitor for unauthorized batch operations

Detection and Monitoring

// Monitor for suspicious transaction patterns
exports.monitorTransactions = functions.firestore
  .document('transaction_logs/{logId}')
  .onCreate(async (snap, context) => {
    const transaction = snap.data();

    // Detect high-value transfers
    if (transaction.amount > 10000) {
      await alertHighValueTransaction(transaction);
    }

    // Detect rapid successive transactions
    const recentTransactions = await db.collection('transaction_logs')
      .where('initiatedBy', '==', transaction.initiatedBy)
      .where('timestamp', '>', admin.firestore.Timestamp.fromMillis(Date.now() - 300000)) // 5 minutes
      .get();

    if (recentTransactions.size > 10) {
      await alertSuspiciousActivity(transaction);
    }
  });

// Monitor for batch write abuse
exports.monitorBatchWrites = functions.firestore
  .document('users/{userId}')
  .onUpdate(async (change, context) => {
    const before = change.before.data();
    const after = change.after.data();

    // Detect privilege escalation
    if (before.role !== after.role && after.role === 'admin') {
      await alertPrivilegeEscalation(context.params.userId, after);
    }

    // Detect suspicious field changes
    const changedFields = Object.keys(after).filter(key => 
      JSON.stringify(before[key]) !== JSON.stringify(after[key])
    );

    if (changedFields.includes('accountBalance') || changedFields.includes('permissions')) {
      await auditSensitiveFieldChange(context.params.userId, changedFields, before, after);
    }
  });

References