From aedc9ee378f53258da78cca6281054d1ac3e0428 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 16:20:14 +0000 Subject: [PATCH] security: validate billing address structure in Order model Add billing address validation on Order creating/updating events. Required fields (line1, city, postcode, country) are enforced when commerce.checkout.require_billing_address is enabled (default). Unrecognised keys are stripped to prevent data pollution. Fixes #12 Co-Authored-By: Claude Opus 4.6 (1M context) --- Models/Order.php | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/Models/Order.php b/Models/Order.php index 55d2456..91de420 100644 --- a/Models/Order.php +++ b/Models/Order.php @@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -51,11 +53,94 @@ class Order extends Model use HasFactory; use LogsActivity; + /** + * Required keys for a valid billing address. + * + * @var array + */ + public const BILLING_ADDRESS_REQUIRED_FIELDS = ['line1', 'city', 'postcode', 'country']; + + /** + * All recognised billing address keys. + * + * @var array + */ + public const BILLING_ADDRESS_ALLOWED_FIELDS = ['line1', 'line2', 'city', 'state', 'postcode', 'country']; + protected static function newFactory(): OrderFactory { return OrderFactory::new(); } + protected static function booted(): void + { + $validate = function (self $order): void { + $order->validateBillingAddress(); + }; + + static::creating($validate); + static::updating($validate); + } + + /** + * Validate the billing_address structure. + * + * When `commerce.checkout.require_billing_address` is enabled (default), + * the billing address must be present and contain the required fields. + * When disabled, null is permitted but any non-null value must still + * conform to the expected structure. + * + * @throws ValidationException + */ + public function validateBillingAddress(): void + { + $requireAddress = config('commerce.checkout.require_billing_address', true); + $address = $this->billing_address; + + // Null is acceptable only when billing address is not required + if ($address === null) { + if ($requireAddress) { + throw ValidationException::withMessages([ + 'billing_address' => ['Billing address is required.'], + ]); + } + + return; + } + + // Non-null value must be an array + if (! is_array($address)) { + throw ValidationException::withMessages([ + 'billing_address' => ['Billing address must be an array.'], + ]); + } + + $validator = Validator::make($address, [ + 'line1' => ['required', 'string', 'max:255'], + 'line2' => ['nullable', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:255'], + 'state' => ['nullable', 'string', 'max:255'], + 'postcode' => ['required', 'string', 'max:20'], + 'country' => ['required', 'string', 'size:2'], + ]); + + if ($validator->fails()) { + throw ValidationException::withMessages( + collect($validator->errors()->toArray()) + ->mapWithKeys(fn (array $messages, string $key) => [ + "billing_address.{$key}" => $messages, + ]) + ->all() + ); + } + + // Strip any unrecognised keys to prevent data pollution + $this->billing_address = array_intersect_key( + $address, + array_flip(self::BILLING_ADDRESS_ALLOWED_FIELDS) + ); + } + protected $fillable = [ 'orderable_type', 'orderable_id', -- 2.45.3