victim = Customer::factory()->create(['status' => 1, 'total' => 500_000]); $this->attacker = Customer::factory()->create(['status' => 1, 'total' => 10_000_000]); $this->product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]); ProductPricings::create(['product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 150_000, 'is_active' => true]); $this->order = Order::create(['order_num' => 'ORD-VICTIM', 'customer_id' => $this->victim->id, 'total' => 150_000, 'status' => 0]); $orderItem = OrderItem::create([ 'order_id' => $this->order->id, 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => 150_000, 'addons_meta' => [], ]); $this->invoice = Invoice::create([ 'invoice_number' => 'INV-VICTIM', 'customer_id' => $this->victim->id, 'order_id' => $this->order->id, 'invoice_type' => 1, 'sub_total' => 150_000, 'tax' => 0, 'total' => 150_000, 'status' => InvoiceStatus::UNPAID->value, ]); $this->hosting = Service::create([ 'customer_id' => $this->victim->id, 'product_id' => $this->product->id, 'order_item_id' => $orderItem->id, 'type' => 'hosting', 'status' => 'active', 'started_at' => now()->subMonth(), 'expires_at' => now()->addDays(5), ]); $this->vm = ModuleVmware::create(['customer_id' => $this->victim->id, 'vmware_id' => 987654]); $this->vps = Service::create([ 'customer_id' => $this->victim->id, 'product_id' => $this->product->id, 'type' => 'vps', 'status' => 'active', 'vm_id' => $this->vm->id, 'started_at' => now(), 'expires_at' => now()->addMonth(), ]); } private function as(Customer $customer): static { return $this->actingAs($customer, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); } private function assertDenied($response): void { $this->assertContains($response->status(), [403, 404, 422], "Expected denial, got {$response->status()}: " . $response->getContent()); } public function test_profile_endpoints_ignore_balance_status_and_email_fields(): void { $payload = [ 'fullname' => 'Attacker', 'phone' => '0900000000', 'credit' => 999_999_999, 'total' => 999_999_999, 'total_pending' => 0, 'point' => 999_999, 'status' => 1, 'email' => 'victim-takeover@example.com', 'two_factor_enabled' => false, 'id' => $this->victim->id, ]; $this->as($this->attacker)->putJson('/v1/client/update', $payload)->assertOk(); $this->as($this->attacker)->putJson('/v1/client/preferences', ['email_marketing' => true, 'receive_deposit' => true] + $payload)->assertOk(); $attacker = $this->attacker->fresh(); $this->assertSame(10_000_000, (int) $attacker->total); $this->assertNotSame('victim-takeover@example.com', $attacker->email); $this->assertSame('Attacker', $attacker->fullname); $this->assertSame(500_000, (int) $this->victim->fresh()->total); $this->assertNotSame('Attacker', $this->victim->fresh()->fullname); } public function test_cannot_read_another_customers_orders_invoices_or_transactions(): void { foreach ([ "/v1/client/orders/{$this->order->id}", "/v1/client/orders/num/{$this->order->order_num}", "/v1/client/invoices/{$this->invoice->id}", "/v1/client/invoices/number/{$this->invoice->invoice_number}", "/v1/client/invoices/order/{$this->order->order_num}", "/v1/client/invoices/order/{$this->order->order_num}/pdf", '/v1/client/payment-history/' . Str::uuid(), ] as $url) { $this->assertDenied($this->as($this->attacker)->getJson($url)); } } public function test_cannot_pay_retry_or_renew_with_another_customers_resources(): void { $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/invoices/number/{$this->invoice->invoice_number}/pay", ['payment_method' => 'balance'])); $this->assertDenied($this->as($this->attacker)->postJson("/v1/orders/{$this->order->order_num}/pay", ['payment_method' => 'balance'])); $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/orders/{$this->order->id}/retry")); $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/dich-vu/{$this->hosting->id}/renew", ['months' => 1])); $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/dich-vu/{$this->hosting->id}/auto-renew", ['auto_renew' => true])); $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/client-service/{$this->hosting->uuid}/auto-renew", ['auto_renew' => true])); $this->assertSame(InvoiceStatus::UNPAID->value, (int) $this->invoice->fresh()->status); $this->assertSame(10_000_000, (int) $this->attacker->fresh()->total); $this->assertSame(500_000, (int) $this->victim->fresh()->total); $this->assertTrue($this->hosting->fresh()->expires_at->lt(now()->addDays(6))); $this->assertFalse((bool) $this->hosting->fresh()->auto_renew); } public function test_cannot_view_or_operate_another_customers_vm_hosting_or_service(): void { foreach (["/v1/client/client-service/{$this->hosting->uuid}", "/v1/client/service/{$this->hosting->uuid}", "/v1/client/client-service/{$this->vps->uuid}/available-os", "/v1/client/client-service/{$this->vps->uuid}/console-ticket"] as $url) { $this->assertDenied($this->as($this->attacker)->getJson($url)); } $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/client-service/{$this->vps->uuid}/rebuild", ['os_id' => 1, 'password' => 'Secret12345!'])); foreach (['vm-power-off', 'vm-power-on', 'vm-restart', 'vm-change-password', 'vm-change-port', 'vm-change-ip', 'vm-create-snapshot', 'vm-rebuild', 'vm-upgrade-addons'] as $action) { foreach ([$this->vm->id, $this->vm->vmware_id, $this->vps->uuid] as $id) { $this->assertDenied($this->as($this->attacker)->postJson("/v1/system/{$action}", [ 'product_id' => $id, 'id' => $id, 'vm_id' => $id, 'password' => 'Secret12345!', 'port' => 2222, 'name' => 'x', ])); } } } public function test_cannot_touch_another_customers_dns_zone(): void { $serverId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.1', 'password' => 'x']); $zone = DnsDomain::create(['domain_name' => 'victim.vn', 'dns_manager_server_id' => $serverId, 'is_created' => 1]); $zone->customer()->attach($this->victim->id); $record = DnsManagerNS::create(['dns_manager_domain_id' => $zone->id, 'type' => 'A', 'type_valid' => 'A', 'name' => 'victim.vn', 'value' => '1.1.1.1', 'ttl' => 3600]); $base = "/v1/client/client-service/domains/{$zone->id}"; $this->assertDenied($this->as($this->attacker)->getJson($base)); $this->assertDenied($this->as($this->attacker)->getJson("{$base}/records")); $this->assertDenied($this->as($this->attacker)->postJson("{$base}/records", ['type' => 'A', 'name' => 'evil', 'value' => '6.6.6.6', 'ttl' => 300])); $this->assertDenied($this->as($this->attacker)->putJson("{$base}/records/{$record->id}", ['type' => 'A', 'name' => 'victim.vn', 'value' => '6.6.6.6', 'ttl' => 300])); $this->assertDenied($this->as($this->attacker)->deleteJson("{$base}/records/{$record->id}")); $this->assertSame('1.1.1.1', $record->fresh()?->value); $this->assertSame(1, DnsManagerNS::where('dns_manager_domain_id', $zone->id)->count()); } public function test_cannot_read_reply_or_close_another_customers_ticket_or_notification(): void { $ticket = Ticket::create(['uuid' => (string) Str::uuid(), 'ticket_num' => 'TK-VICTIM', 'subject' => 'S', 'customer_id' => $this->victim->id, 'description' => 'D', 'status' => 'open']); $notification = Notification::create(['recipient_type' => 'client', 'recipient_id' => $this->victim->id, 'type' => 'info', 'title' => 'T', 'body' => 'B', 'is_read' => false]); $this->assertDenied($this->as($this->attacker)->getJson("/v1/client/tickets/{$ticket->ticket_num}")); $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/tickets/{$ticket->ticket_num}/reply", ['message' => 'pwned', 'content' => 'pwned'])); $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/tickets/{$ticket->ticket_num}/close")); $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/notifications/{$notification->uuid}/read")); $this->assertDenied($this->as($this->attacker)->getJson("/v1/client/notifications/{$notification->uuid}")); $own = $this->as($this->victim)->getJson("/v1/client/notifications/{$notification->uuid}")->assertOk()->json('data'); $this->assertSame($notification->uuid, $own['uuid']); $this->assertArrayNotHasKey('id', $own); $this->assertSame('open', $ticket->fresh()->status); $this->assertFalse((bool) $notification->fresh()->is_read); } public function test_cannot_confirm_cancel_or_read_another_customers_deposit_code(): void { $uuid = $this->as($this->victim)->postJson('/v1/transaction/manual-deposit/prepare', ['amount' => 100_000, 'bank_id' => null])->json('data.uuid'); if (!$uuid) { $this->markTestSkipped('prepare requires bank setup'); } $this->assertDenied($this->as($this->attacker)->getJson("/v1/transaction/manual-deposit/{$uuid}")); $this->assertDenied($this->as($this->attacker)->deleteJson("/v1/transaction/manual-deposit/{$uuid}/cancel")); $this->assertDenied($this->as($this->attacker)->postJson('/v1/transaction/manual-deposit/confirm', ['uuid' => $uuid, 'proof_image' => 'deposit-proofs/x.png', 'amount' => 999_999_999])); $this->assertSame(500_000, (int) $this->victim->fresh()->total); } public function test_order_price_is_computed_server_side_and_ignores_client_amounts(): void { $response = $this->as($this->attacker)->postJson('/v1/orders', [ 'product_id' => $this->product->uuid, 'billing_cycle' => 'monthly', 'server_name' => 'attacker-site.vn', 'price' => 1, 'total' => 1, 'amount' => 1, 'sub_total' => 1, 'discount' => 999_999, ]); $order = Order::where('customer_id', $this->attacker->id)->latest('id')->first(); $this->assertNotNull($order, 'order not created: ' . $response->getContent()); $this->assertSame(150_000, (int) $order->total); } }