623 lines
24 KiB
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,
|
|
]);
|
|
});
|
|
});
|
|
});
|