whisper-money/tests/Feature/IdorVulnerabilityTest.php

623 lines
24 KiB
PHP

<?php
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use function Pest\Laravel\actingAs;
describe('Account Balance IDOR Protection', function () {
it('cannot update current balance for another user account via direct manipulation', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
actingAs($attacker);
// Attacker tries to update victim's account balance
$response = $this->putJson("/api/accounts/{$victimAccount->id}/balance/current", [
'balance' => 9999999,
]);
$response->assertForbidden();
$this->assertDatabaseMissing('account_balances', [
'account_id' => $victimAccount->id,
'balance' => 9999999,
]);
});
it('cannot update current balance for another user account', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
actingAs($attacker);
$response = $this->putJson("/api/accounts/{$victimAccount->id}/balance/current", [
'balance' => 5000000,
]);
$response->assertForbidden();
$this->assertDatabaseMissing('account_balances', [
'account_id' => $victimAccount->id,
'balance' => 5000000,
]);
});
it('cannot update existing balance for another user account', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimBalance = AccountBalance::factory()->for($victimAccount)->create([
'balance' => 1000000,
]);
actingAs($attacker);
$response = $this->putJson("/api/accounts/{$victimAccount->id}/balance/current", [
'balance' => 9999999,
]);
$response->assertForbidden();
$this->assertDatabaseHas('account_balances', [
'id' => $victimBalance->id,
'balance' => 1000000, // Balance should remain unchanged
]);
});
it('cannot delete balance from another user account', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimBalance = AccountBalance::factory()->for($victimAccount)->create();
actingAs($attacker);
$response = $this->deleteJson("/api/accounts/{$victimAccount->id}/balances/{$victimBalance->id}");
$response->assertForbidden();
$this->assertDatabaseHas('account_balances', [
'id' => $victimBalance->id,
]);
});
it('cannot list balances from another user account', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
AccountBalance::factory()->for($victimAccount)->count(3)->create();
actingAs($attacker);
$response = $this->getJson("/api/accounts/{$victimAccount->id}/balances");
$response->assertForbidden();
});
it('cannot delete balance using mismatched account and balance IDs', function () {
$attacker = User::factory()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimBalance = AccountBalance::factory()->for($victimAccount)->create();
actingAs($attacker);
// Attempt to delete victim's balance using attacker's account ID
$response = $this->deleteJson("/api/accounts/{$attackerAccount->id}/balances/{$victimBalance->id}");
$response->assertNotFound();
$this->assertDatabaseHas('account_balances', [
'id' => $victimBalance->id,
]);
});
});
describe('Transaction IDOR Protection', function () {
it('cannot create transaction for another user account', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
actingAs($attacker);
$response = $this->post('/transactions', [
'account_id' => $victimAccount->id,
'description' => 'Malicious transaction',
'description_iv' => str_repeat('a', 16),
'transaction_date' => now()->format('Y-m-d'),
'amount' => -50000,
'currency_code' => 'USD',
'source' => 'manual',
]);
// The transaction might be created but should belong to attacker, not victim
// OR it should fail if account validation includes ownership check
if ($response->status() === 201) {
$transaction = Transaction::latest()->first();
expect($transaction->user_id)->toBe($attacker->id)
->and($transaction->account_id)->not->toBe($victimAccount->id);
} else {
// If there's validation, it should fail with 422 or redirect with errors
expect($response->status())->toBeIn([302, 422]);
}
});
it('cannot update transaction from another user', function () {
$attacker = User::factory()->onboarded()->create();
$attackerCategory = Category::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimCategory = Category::factory()->for($victim)->create();
$victimTransaction = Transaction::factory()
->for($victim)
->for($victimAccount)
->for($victimCategory)
->create();
actingAs($attacker);
$response = $this->patch("/transactions/{$victimTransaction->id}", [
'category_id' => $attackerCategory->id,
'notes' => 'Hacked notes',
'notes_iv' => str_repeat('b', 16),
]);
$response->assertForbidden();
$this->assertDatabaseHas('transactions', [
'id' => $victimTransaction->id,
'category_id' => $victimCategory->id,
'user_id' => $victim->id,
]);
$this->assertDatabaseMissing('transactions', [
'id' => $victimTransaction->id,
'notes' => 'Hacked notes',
]);
});
it('cannot delete transaction from another user', function () {
$attacker = User::factory()->onboarded()->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimTransaction = Transaction::factory()
->for($victim)
->for($victimAccount)
->create();
actingAs($attacker);
$response = $this->delete("/transactions/{$victimTransaction->id}");
$response->assertForbidden();
$this->assertDatabaseHas('transactions', [
'id' => $victimTransaction->id,
'deleted_at' => null,
]);
});
it('cannot bulk update transactions from another user by ID', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerCategory = Category::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimCategory = Category::factory()->for($victim)->create();
$victimTransactions = Transaction::factory()
->for($victim)
->for($victimAccount)
->for($victimCategory)
->count(3)
->create();
actingAs($attacker);
$response = $this->patch('/transactions/bulk', [
'transaction_ids' => $victimTransactions->pluck('id')->toArray(),
'category_id' => $attackerCategory->id,
]);
// Should return 403 because transaction IDs don't belong to attacker
$response->assertForbidden();
// Verify victim's transactions remain unchanged
foreach ($victimTransactions as $transaction) {
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'category_id' => $victimCategory->id,
'user_id' => $victim->id,
]);
}
});
it('cannot bulk update transactions from another user using filters', function () {
$attacker = User::factory()->onboarded()->create();
$attackerCategory = Category::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimCategory = Category::factory()->for($victim)->create();
$victimTransactions = Transaction::factory()
->for($victim)
->for($victimAccount)
->for($victimCategory)
->count(5)
->create([
'transaction_date' => now()->subDays(5),
'amount' => 10000,
]);
actingAs($attacker);
// Attempt to bulk update victim's transactions using date/amount filters
$response = $this->patch('/transactions/bulk', [
'filters' => [
'date_from' => now()->subDays(10)->format('Y-m-d'),
'date_to' => now()->format('Y-m-d'),
'amount_min' => 50,
'amount_max' => 150,
],
'category_id' => $attackerCategory->id,
]);
$response->assertOk();
// Bulk update should only affect attacker's own transactions (0 in this case)
expect($response->json('count'))->toBe(0);
// Verify victim's transactions remain unchanged
foreach ($victimTransactions as $transaction) {
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'category_id' => $victimCategory->id,
'user_id' => $victim->id,
]);
}
});
it('bulk update only affects authenticated user transactions', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerCategory = Category::factory()->for($attacker)->create();
$attackerTransactions = Transaction::factory()
->for($attacker)
->for($attackerAccount)
->count(2)
->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimCategory = Category::factory()->for($victim)->create();
$victimTransactions = Transaction::factory()
->for($victim)
->for($victimAccount)
->for($victimCategory)
->count(3)
->create();
actingAs($attacker);
// Update all transactions without filters
$response = $this->patch('/transactions/bulk', [
'category_id' => $attackerCategory->id,
]);
$response->assertOk();
expect($response->json('count'))->toBe(2); // Only attacker's transactions
// Verify only attacker's transactions were updated
foreach ($attackerTransactions as $transaction) {
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'category_id' => $attackerCategory->id,
]);
}
// Verify victim's transactions remain unchanged
foreach ($victimTransactions as $transaction) {
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'category_id' => $victimCategory->id,
'user_id' => $victim->id,
]);
}
});
});
describe('Cross-Resource IDOR Protection', function () {
it('cannot use another user category when creating transaction', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimCategory = Category::factory()->for($victim)->create();
actingAs($attacker);
$response = $this->post('/transactions', [
'account_id' => $attackerAccount->id,
'category_id' => $victimCategory->id, // Attempt to use victim's category
'description' => 'Test transaction',
'description_iv' => str_repeat('a', 16),
'transaction_date' => now()->format('Y-m-d'),
'amount' => -10000,
'currency_code' => 'USD',
'source' => 'manual',
]);
// Should either fail validation or ignore the category
if ($response->status() === 201) {
$transaction = Transaction::latest()->first();
expect($transaction->category_id)->not->toBe($victimCategory->id);
} else {
expect($response->status())->toBeIn([302, 422]);
}
});
it('cannot use another user category when updating transaction', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerCategory = Category::factory()->for($attacker)->create();
$attackerTransaction = Transaction::factory()
->for($attacker)
->for($attackerAccount)
->for($attackerCategory)
->create();
$victim = User::factory()->onboarded()->create();
$victimCategory = Category::factory()->for($victim)->create();
actingAs($attacker);
$response = $this->patch("/transactions/{$attackerTransaction->id}", [
'category_id' => $victimCategory->id, // Attempt to use victim's category
]);
// Should fail validation with 422 or redirect with errors
expect($response->status())->toBeIn([302, 422]);
// Verify transaction category remains unchanged
$attackerTransaction->refresh();
expect($attackerTransaction->category_id)->toBe($attackerCategory->id);
});
it('cannot use another user account when creating transaction', function () {
$attacker = User::factory()->onboarded()->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
actingAs($attacker);
$response = $this->post('/transactions', [
'account_id' => $victimAccount->id, // Attempt to use victim's account
'description' => 'Malicious transaction',
'description_iv' => str_repeat('a', 16),
'transaction_date' => now()->format('Y-m-d'),
'amount' => -50000,
'currency_code' => 'USD',
'source' => 'manual',
]);
// Should either fail validation or the transaction should not be linked to victim's account
if ($response->status() === 201) {
$transaction = Transaction::latest()->first();
expect($transaction->account_id)->not->toBe($victimAccount->id);
} else {
expect($response->status())->toBeIn([302, 422]);
}
});
});
describe('Sync Endpoints IDOR Protection', function () {
describe('TransactionSyncController', function () {
it('cannot list transactions from another user', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerTransaction = Transaction::factory()->for($attacker)->for($attackerAccount)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimTransactions = Transaction::factory()->for($victim)->for($victimAccount)->count(3)->create();
actingAs($attacker);
$response = $this->getJson('/api/sync/transactions');
$response->assertSuccessful();
$transactions = $response->json('data');
expect($transactions)->toBeArray();
expect(count($transactions))->toBe(1);
expect($transactions[0]['id'])->toBe($attackerTransaction->id);
expect($transactions[0]['id'])->not->toBeIn($victimTransactions->pluck('id')->toArray());
});
it('cannot list transactions from another user even with since parameter', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerTransaction = Transaction::factory()->for($attacker)->for($attackerAccount)->create([
'updated_at' => now()->subDay(),
]);
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimTransaction = Transaction::factory()->for($victim)->for($victimAccount)->create([
'updated_at' => now(),
]);
actingAs($attacker);
$response = $this->getJson('/api/sync/transactions?since='.now()->subDays(2)->toISOString());
$response->assertSuccessful();
$transactions = $response->json('data');
expect($transactions)->toBeArray();
$transactionIds = collect($transactions)->pluck('id')->toArray();
expect($transactionIds)->toContain($attackerTransaction->id);
expect($transactionIds)->not->toContain($victimTransaction->id);
});
it('cannot create transaction for another user account via sync endpoint', function () {
$attacker = User::factory()->onboarded()->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
actingAs($attacker);
$response = $this->postJson('/api/sync/transactions', [
'account_id' => $victimAccount->id,
'description' => 'Malicious transaction',
'description_iv' => str_repeat('a', 16),
'transaction_date' => now()->format('Y-m-d'),
'amount' => -50000,
'currency_code' => 'USD',
'source' => 'manual',
]);
$response->assertUnprocessable();
expect($response->json('errors.account_id'))->toBeArray();
});
it('cannot create transaction using another user transaction ID via sync endpoint', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerCategory = Category::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimCategory = Category::factory()->for($victim)->create();
$victimTransaction = Transaction::factory()
->for($victim)
->for($victimAccount)
->for($victimCategory)
->create();
actingAs($attacker);
$response = $this->postJson('/api/sync/transactions', [
'id' => $victimTransaction->id,
'account_id' => $attackerAccount->id,
'description' => 'Attempted ID reuse',
'description_iv' => str_repeat('a', 16),
'transaction_date' => now()->format('Y-m-d'),
'amount' => -50000,
'currency_code' => 'USD',
'source' => 'manually_created',
]);
$response->assertStatus(500);
$this->assertDatabaseHas('transactions', [
'id' => $victimTransaction->id,
'user_id' => $victim->id,
]);
$this->assertDatabaseMissing('transactions', [
'id' => $victimTransaction->id,
'user_id' => $attacker->id,
]);
});
it('idempotent create returns existing transaction when same user provides same ID', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->for($user)->create();
$category = Category::factory()->for($user)->create();
$existingTransaction = Transaction::factory()
->for($user)
->for($account)
->for($category)
->create([
'description' => 'Original transaction',
'description_iv' => str_repeat('a', 16),
]);
actingAs($user);
$response = $this->postJson('/api/sync/transactions', [
'id' => $existingTransaction->id,
'account_id' => $account->id,
'description' => 'Attempted duplicate',
'description_iv' => str_repeat('b', 16),
'transaction_date' => now()->format('Y-m-d'),
'amount' => -50000,
'currency_code' => 'USD',
'source' => 'manually_created',
]);
$response->assertSuccessful();
expect($response->json('data.id'))->toBe($existingTransaction->id);
expect($response->json('data.description'))->toBe('Original transaction');
$this->assertDatabaseHas('transactions', [
'id' => $existingTransaction->id,
'description' => 'Original transaction',
]);
});
it('cannot update transaction from another user via sync endpoint', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimCategory = Category::factory()->for($victim)->create();
$victimTransaction = Transaction::factory()
->for($victim)
->for($victimAccount)
->for($victimCategory)
->create();
actingAs($attacker);
$response = $this->patchJson("/api/sync/transactions/{$victimTransaction->id}", [
'account_id' => $attackerAccount->id,
'description' => 'Hacked transaction',
'description_iv' => str_repeat('b', 16),
'transaction_date' => $victimTransaction->transaction_date->format('Y-m-d'),
'amount' => $victimTransaction->amount,
'currency_code' => $victimTransaction->currency_code,
'source' => $victimTransaction->source->value,
]);
$response->assertForbidden();
$this->assertDatabaseHas('transactions', [
'id' => $victimTransaction->id,
'user_id' => $victim->id,
'account_id' => $victimAccount->id,
]);
});
it('cannot delete transaction from another user via sync endpoint', function () {
$attacker = User::factory()->onboarded()->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimTransaction = Transaction::factory()
->for($victim)
->for($victimAccount)
->create();
actingAs($attacker);
$response = $this->deleteJson("/api/sync/transactions/{$victimTransaction->id}");
$response->assertForbidden();
$this->assertDatabaseHas('transactions', [
'id' => $victimTransaction->id,
'deleted_at' => null,
]);
});
});
});