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¶
// 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¶
- Authorization Within Transactions
- Always perform authorization checks inside the transaction
- Re-validate permissions for each document operation
-
Don't rely on pre-transaction authorization checks
-
Validation and Sanitization
- Validate all input data within the transaction
- Sanitize values before applying updates
-
Check business logic constraints atomically
-
Error Handling
- Implement comprehensive error handling within transactions
- Ensure transactions fail fast on authorization violations
-
Log security-relevant transaction failures
-
Atomic Consistency
- Ensure all operations in a transaction are logically related
- Don't mix unrelated operations in a single transaction
- Validate cross-document relationships
Batch Write Security Guidelines¶
- Per-Document Validation
- Validate authorization for each document in the batch
- Check permissions before creating the batch
-
Sanitize all field updates
-
Size and Rate Limiting
- Limit batch sizes to prevent abuse
- Implement rate limiting for batch operations
-
Monitor for suspicious batch patterns
-
Audit and Logging
- Log all batch operations with user context
- Track the scope and impact of batch changes
- 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);
}
});