Reject normalized datetime-local inputs

createFromFormat() only returns false on hard parse failures.
Impossible dates such as 2026-02-31 were silently normalized
into March, so a confirmed event could land on a different day
than the booking link supplied. Non-padded values like
2026-2-3T10:00 were also accepted and reformatted.

Parse with a leading "!" so no current-date fields leak in,
require zero warnings and errors from getLastErrors(), and
round-trip the value through the format. Impossible dates,
out-of-range components and non-padded values now throw in
both the confirmation and the creation stage.
This commit is contained in:
stefan.tollkuehn committed 2026-10-26 13:39:07 +01:00
1 parent a9b7fd11af
commit 2b3e96e50a
2 files changed
+53 -15

No files matched your search

+24 -15
View File
@@ -844,27 +844,36 @@ class CalendarController extends Controller
{ {
$timeZone = $this->dateTimeZone->getTimeZone(); $timeZone = $this->dateTimeZone->getTimeZone();
$date = \DateTimeImmutable::createFromFormat( /*
self::DATETIME_LOCAL, * "!" resets all fields, so no parts of the current date
$value, * leak in. PHP normalizes impossible dates instead of
$timeZone * failing (2026-02-31 rolls into March), so require zero
); * warnings and an exact format round-trip on top of the
* parse result.
if ($date === false) { */
foreach (['!' . self::DATETIME_LOCAL, '!Y-m-d\TH:i:s'] as $format) {
$date = \DateTimeImmutable::createFromFormat( $date = \DateTimeImmutable::createFromFormat(
'Y-m-d\TH:i:s', $format,
$value, $value,
$timeZone $timeZone
); );
$errors = \DateTimeImmutable::getLastErrors();
if (
$date !== false
&& ($errors === false
|| ($errors['warning_count'] === 0
&& $errors['error_count'] === 0))
&& $date->format(substr($format, 1)) === $value
) {
return $date;
}
} }
if ($date === false) { throw new \InvalidArgumentException(
throw new \InvalidArgumentException( $this->l10n->t('Invalid date/time.')
$this->l10n->t('Invalid date/time.') );
);
}
return $date;
} }
/** /**
+29
View File
@@ -218,6 +218,35 @@ class CalendarControllerTest extends TestCase
$this->invoke($c, 'parseLocalDateTime', ['tomorrow']); $this->invoke($c, 'parseLocalDateTime', ['tomorrow']);
} }
public function testParseLocalDateTimeAcceptsLeapDay(): void
{
$c = $this->newController();
$date = $this->invoke($c, 'parseLocalDateTime', ['2028-02-29T10:00']);
$this->assertSame('2028-02-29 10:00:00 UTC', $date->format('Y-m-d H:i:s T'));
}
public static function provideInvalidLocalDateTimes(): iterable
{
return [
'impossible day rolls into next month' => ['2026-02-31T10:00'],
'leap day in a non-leap year' => ['2027-02-29T10:00'],
'month out of range' => ['2026-13-01T10:00'],
'day out of range' => ['2026-00-15T10:00'],
'hour out of range' => ['2026-02-03T25:00'],
'non-padded components' => ['2026-2-3T10:00'],
];
}
#[DataProvider('provideInvalidLocalDateTimes')]
public function testParseLocalDateTimeRejectsInvalidValues(string $value): void
{
$c = $this->newController();
$this->expectException(\InvalidArgumentException::class);
$this->invoke($c, 'parseLocalDateTime', [$value]);
}
/* /*
* validateEventData * validateEventData
*/ */