From 767016c65918496b2bd3a276a343849b7c303957 Mon Sep 17 00:00:00 2001 From: Marek Miklewicz Date: Tue, 2 Jun 2026 21:54:56 +0200 Subject: [PATCH] fix: scope csrf fields and block past dates --- exec/handlers/_form_helpers.php | 26 +++++++++++++++++++++----- exec/lib/AppContext.php | 14 ++++++++++---- exec/lib/RuleValidator.php | 3 +++ plugin.conf | 2 +- tests/csrf_test.php | 24 ++++++++++++++++++++++++ tests/handler_test.php | 18 ++++++++++++++++++ tests/rule_validator_test.php | 23 +++++++++++++++++++++++ user/enhanced.css | 2 +- 8 files changed, 101 insertions(+), 11 deletions(-) diff --git a/exec/handlers/_form_helpers.php b/exec/handlers/_form_helpers.php index 6675532..3475e89 100644 --- a/exec/handlers/_form_helpers.php +++ b/exec/handlers/_form_helpers.php @@ -9,8 +9,8 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string $replyFrequency = selected_reply_frequency($rule); $body = $rule['body'] ?? ''; $enabled = $rule === null || !empty($rule['enabled']); - $startValue = (string)($rule['start_value'] ?? '2026-06-10T09:00'); - $endValue = (string)($rule['end_value'] ?? '2026-06-24T17:00'); + $startValue = (string)($rule['start_value'] ?? default_schedule_value($ctx, true)); + $endValue = (string)($rule['end_value'] ?? default_schedule_value($ctx, false)); $action = $ctx->url($isUpdate ? 'update.html' : 'create.html'); $intent = $isUpdate ? 'update:' . $id : 'create'; $csrf = $ctx->csrfField($intent); @@ -27,6 +27,16 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string ''; } +function default_schedule_value(AppContext $ctx, bool $start): string +{ + $today = new DateTimeImmutable('today', new DateTimeZone($ctx->settings->timezone())); + $date = $start ? $today : $today->modify('+14 days'); + if ($ctx->settings->scheduleMode() === 'directadmin_period') { + return $date->format('Y-m-d') . '|' . ($start ? 'morning' : 'evening'); + } + return $date->format('Y-m-d') . 'T' . ($start ? '09:00' : '17:00'); +} + /** @param array|null $rule */ function selected_reply_frequency(?array $rule): string { @@ -119,7 +129,8 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st } $selectedDt = DateTimeImmutable::createFromFormat('!Y-m-d', $selectedDate) ?: new DateTimeImmutable('today'); $monthStart = $selectedDt->modify('first day of this month'); - $days = render_calendar_days($name, $selectedDate, $monthStart); + $minDate = (new DateTimeImmutable('today', new DateTimeZone($ctx->settings->timezone())))->format('Y-m-d'); + $days = render_calendar_days($name, $selectedDate, $monthStart, $minDate); $visibleMonth = $monthStart->format('Y-m'); $monthLabel = calendar_month_label($ctx, $monthStart); if ($mode === 'exact_hour') { @@ -131,7 +142,7 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st '' . ''; } - return '

' . AppContext::e($title) . '

' . AppContext::e($title) . '

' . AppContext::e($monthLabel) . '
' . + return '

' . AppContext::e($title) . '

' . AppContext::e($title) . '

' . AppContext::e($monthLabel) . '
' . '' . render_weekday_headers($ctx) . '' . $days . '
' . '
' . AppContext::e($ctx->t('Selected')) . ': ' . AppContext::e($selectedDate) . '
' . '

' . AppContext::e($ctx->t('Time')) . '

' . $timeControl . '
' . @@ -153,7 +164,7 @@ function render_weekday_headers(AppContext $ctx): string return $html; } -function render_calendar_days(string $name, string $selectedDate, DateTimeImmutable $monthStart): string +function render_calendar_days(string $name, string $selectedDate, DateTimeImmutable $monthStart, string $minDate): string { $offset = ((int)$monthStart->format('N')) - 1; $daysInMonth = (int)$monthStart->format('t'); @@ -167,6 +178,11 @@ function render_calendar_days(string $name, string $selectedDate, DateTimeImmuta continue; } $date = $monthStart->setDate((int)$monthStart->format('Y'), (int)$monthStart->format('m'), $day)->format('Y-m-d'); + if ($date < $minDate) { + $html .= '' . $day . ''; + $day++; + continue; + } $class = $date === $selectedDate ? ' is-selected' : ''; $html .= ''; $day++; diff --git a/exec/lib/AppContext.php b/exec/lib/AppContext.php index 911527d..425ff86 100644 --- a/exec/lib/AppContext.php +++ b/exec/lib/AppContext.php @@ -58,14 +58,14 @@ final class AppContext public function csrfField(string $intent): string { $issued = $this->csrf->issue($intent); - return '' . - '' . - ''; + return '' . + '' . + ''; } public function requireCsrf(string $intent): void { - if (!$this->csrf->validate($intent, $this->post('csrf_ts'), $this->post('csrf_token'), 3600, $this->post('csrf_sid'))) { + if (!$this->csrf->validate($intent, $this->post('gar_csrf_ts'), $this->post('gar_csrf_token'), 3600, $this->post('gar_csrf_sid'))) { throw new RuntimeException('Nieprawidłowy lub wygasły token CSRF.'); } } @@ -158,6 +158,7 @@ final class AppContext } function renderMonth(calendar, year, month) { var selected = calendar.getAttribute('data-selected-date') || ''; + var minDate = calendar.getAttribute('data-min-date') || ''; var tbody = calendar.querySelector('[data-calendar-days]'); var label = calendar.querySelector('[data-calendar-month-label]'); if (!tbody || !label) { return; } @@ -174,6 +175,11 @@ final class AppContext continue; } var value = year + '-' + pad(month) + '-' + pad(day); + if (minDate && value < minDate) { + html += '' + day + ''; + day++; + continue; + } var cls = value === selected ? ' is-selected' : ''; html += ''; day++; diff --git a/exec/lib/RuleValidator.php b/exec/lib/RuleValidator.php index df9cdf9..9dee7d9 100644 --- a/exec/lib/RuleValidator.php +++ b/exec/lib/RuleValidator.php @@ -39,6 +39,9 @@ final class RuleValidator $tz = new DateTimeZone($settings->timezone()); [$startTs, $startValue] = self::parseScheduleValue((string)($input['start_value'] ?? ''), $mode, $tz, true); [$endTs, $endValue] = self::parseScheduleValue((string)($input['end_value'] ?? ''), $mode, $tz, false); + if (substr($startValue, 0, 10) < (new DateTimeImmutable('today', $tz))->format('Y-m-d')) { + throw new InvalidArgumentException('Data rozpoczęcia nie może być wcześniejsza niż aktualna data.'); + } if ($endTs <= $startTs) { throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.'); } diff --git a/plugin.conf b/plugin.conf index c5d6910..83c15bd 100644 --- a/plugin.conf +++ b/plugin.conf @@ -2,7 +2,7 @@ name=global-autoresponder id=global-autoresponder type=user author=HITME.PL -version=1.0.8 +version=1.0.9 active=no installed=no user_run_as=root diff --git a/tests/csrf_test.php b/tests/csrf_test.php index f3e870c..ea9ae4a 100644 --- a/tests/csrf_test.php +++ b/tests/csrf_test.php @@ -2,6 +2,12 @@ declare(strict_types=1); require_once PLUGIN_ROOT . '/exec/lib/CsrfGuard.php'; +require_once PLUGIN_ROOT . '/exec/lib/Settings.php'; +require_once PLUGIN_ROOT . '/exec/lib/Lang.php'; +require_once PLUGIN_ROOT . '/exec/lib/DirectAdminUser.php'; +require_once PLUGIN_ROOT . '/exec/lib/RuleRepository.php'; +require_once PLUGIN_ROOT . '/exec/lib/AuditLog.php'; +require_once PLUGIN_ROOT . '/exec/lib/AppContext.php'; test('csrf token is bound to user session action and time', function (): void { $guard = new CsrfGuard('secret', 'alice', 'session-1'); @@ -13,3 +19,21 @@ test('csrf token is bound to user session action and time', function (): void { assert_false($guard->validate('create', (string)($issued['ts'] - 7200), $issued['token'], 3600, 'session-1')); assert_false($guard->validate('create', 'bad', $issued['token'], 3600, 'session-1')); }); + +test('csrf form fields use plugin scoped names to avoid directadmin token collisions', function (): void { + $settings = Settings::load(TEST_TMP . '/missing-csrf-form.conf'); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + new RuleRepository(TEST_TMP . '/data'), + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $html = $ctx->csrfField('create'); + assert_contains('name="gar_csrf_ts"', $html); + assert_contains('name="gar_csrf_sid"', $html); + assert_contains('name="gar_csrf_token"', $html); + assert_false(str_contains($html, 'name="csrf_token"')); +}); diff --git a/tests/handler_test.php b/tests/handler_test.php index ff1cc76..7115bb8 100644 --- a/tests/handler_test.php +++ b/tests/handler_test.php @@ -119,6 +119,24 @@ test('calendar picker is based on selected rule month and supports month navigat assert_false(strpos($html, 'Czerwiec 2026') !== false); }); +test('calendar picker disables dates before today', function (): void { + $settingsPath = TEST_TMP . '/form-calendar-min.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); + $settings = Settings::load($settingsPath); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + new RuleRepository(TEST_TMP . '/data'), + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $html = render_calendar_picker($ctx, 'start_value', $ctx->t('Start date'), '2000-01-15T09:00'); + assert_contains('br-cal-day-disabled', $html); + assert_false(str_contains($html, 'data-date-value="2000-01-15"'), 'Past dates must not be clickable'); +}); + test('rule form localizes calendar and preview labels without translating user content globally', function (): void { $settingsPath = TEST_TMP . '/form-i18n.conf'; test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=en\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); diff --git a/tests/rule_validator_test.php b/tests/rule_validator_test.php index ce3c86a..590ef6f 100644 --- a/tests/rule_validator_test.php +++ b/tests/rule_validator_test.php @@ -75,3 +75,26 @@ test('rule validator rejects unsupported response frequency', function (): void } throw new RuntimeException('Expected invalid frequency to fail'); }); + +test('rule validator rejects start date before current date', function (): void { + $settingsPath = TEST_TMP . '/validator-past.conf'; + test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); + $settings = Settings::load($settingsPath); + $tz = new DateTimeZone($settings->timezone()); + $yesterday = (new DateTimeImmutable('yesterday', $tz))->format('Y-m-d'); + $tomorrow = (new DateTimeImmutable('tomorrow', $tz))->format('Y-m-d'); + + try { + RuleValidator::validate([ + 'subject_prefix' => 'Urlop', + 'reply_frequency' => '1440', + 'body' => 'Body', + 'start_value' => $yesterday . 'T09:00', + 'end_value' => $tomorrow . 'T17:00', + ], $settings); + } catch (InvalidArgumentException $e) { + assert_contains('wcześniejsza niż aktualna', $e->getMessage()); + return; + } + throw new RuntimeException('Expected past start date to fail'); +}); diff --git a/user/enhanced.css b/user/enhanced.css index 2877ec0..ef15900 100644 --- a/user/enhanced.css +++ b/user/enhanced.css @@ -64,7 +64,7 @@ th { background: #f6f8fb; color: #4a5566; font-weight: 600; } .br-calendar-grid td { padding: 4px; text-align: center; border-bottom: 0; } .br-cal-day, .br-cal-day-disabled { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border); background: #fff; padding: 0; display: inline-flex; align-items: center; justify-content: center; } .br-cal-day.is-selected { background: var(--amber); color: #fff; border-color: var(--amber); } -.br-cal-day-disabled { color: #a0a7b4; border-color: transparent; background: transparent; } +.br-cal-day-disabled { color: #a0a7b4; border-color: transparent; background: transparent; cursor: not-allowed; } .br-date-label { font-weight: 600; color: #1f2a37; margin: 6px 0 10px; } .br-time-picker { margin: 8px 0 14px; } .br-time-title { font-weight: 600; margin-bottom: 6px; color: var(--muted); }