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) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 16:20:14 +00:00
parent b51084b8db
commit aedc9ee378
No known key found for this signature in database
GPG key ID: AF404715446AEB41

View file

@ -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<int, string>
*/
public const BILLING_ADDRESS_REQUIRED_FIELDS = ['line1', 'city', 'postcode', 'country'];
/**
* All recognised billing address keys.
*
* @var array<int, string>
*/
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',