fix: scope csrf fields and block past dates

This commit is contained in:
Marek Miklewicz
2026-06-02 21:54:56 +02:00
parent 56709817b5
commit 767016c659
8 changed files with 101 additions and 11 deletions
+21 -5
View File
@@ -9,8 +9,8 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string
$replyFrequency = selected_reply_frequency($rule); $replyFrequency = selected_reply_frequency($rule);
$body = $rule['body'] ?? ''; $body = $rule['body'] ?? '';
$enabled = $rule === null || !empty($rule['enabled']); $enabled = $rule === null || !empty($rule['enabled']);
$startValue = (string)($rule['start_value'] ?? '2026-06-10T09:00'); $startValue = (string)($rule['start_value'] ?? default_schedule_value($ctx, true));
$endValue = (string)($rule['end_value'] ?? '2026-06-24T17:00'); $endValue = (string)($rule['end_value'] ?? default_schedule_value($ctx, false));
$action = $ctx->url($isUpdate ? 'update.html' : 'create.html'); $action = $ctx->url($isUpdate ? 'update.html' : 'create.html');
$intent = $isUpdate ? 'update:' . $id : 'create'; $intent = $isUpdate ? 'update:' . $id : 'create';
$csrf = $ctx->csrfField($intent); $csrf = $ctx->csrfField($intent);
@@ -27,6 +27,16 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string
'</form>'; '</form>';
} }
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<string,mixed>|null $rule */ /** @param array<string,mixed>|null $rule */
function selected_reply_frequency(?array $rule): string 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'); $selectedDt = DateTimeImmutable::createFromFormat('!Y-m-d', $selectedDate) ?: new DateTimeImmutable('today');
$monthStart = $selectedDt->modify('first day of this month'); $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'); $visibleMonth = $monthStart->format('Y-m');
$monthLabel = calendar_month_label($ctx, $monthStart); $monthLabel = calendar_month_label($ctx, $monthStart);
if ($mode === 'exact_hour') { if ($mode === 'exact_hour') {
@@ -131,7 +142,7 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st
'<option value="evening"' . ($selectedPeriod === 'evening' ? ' selected' : '') . '>' . AppContext::e($ctx->t('Evening')) . '</option>' . '<option value="evening"' . ($selectedPeriod === 'evening' ? ' selected' : '') . '>' . AppContext::e($ctx->t('Evening')) . '</option>' .
'</select>'; '</select>';
} }
return '<div class="panel"><h3>' . AppContext::e($title) . '</h3><div class="br-restore-layout"><div class="br-restore-calendar" data-calendar-picker data-schedule-mode="' . AppContext::e($mode) . '" data-calendar-name="' . AppContext::e($name) . '" data-visible-month="' . AppContext::e($visibleMonth) . '" data-selected-date="' . AppContext::e($selectedDate) . '"><h4>' . AppContext::e($title) . '</h4><div class="br-month-nav"><button type="button" class="br-month-btn" data-calendar-prev>&lsaquo;</button><span class="br-month-label" data-calendar-month-label>' . AppContext::e($monthLabel) . '</span><button type="button" class="br-month-btn" data-calendar-next>&rsaquo;</button></div>' . return '<div class="panel"><h3>' . AppContext::e($title) . '</h3><div class="br-restore-layout"><div class="br-restore-calendar" data-calendar-picker data-schedule-mode="' . AppContext::e($mode) . '" data-calendar-name="' . AppContext::e($name) . '" data-visible-month="' . AppContext::e($visibleMonth) . '" data-selected-date="' . AppContext::e($selectedDate) . '" data-min-date="' . AppContext::e($minDate) . '"><h4>' . AppContext::e($title) . '</h4><div class="br-month-nav"><button type="button" class="br-month-btn" data-calendar-prev>&lsaquo;</button><span class="br-month-label" data-calendar-month-label>' . AppContext::e($monthLabel) . '</span><button type="button" class="br-month-btn" data-calendar-next>&rsaquo;</button></div>' .
'<table class="br-calendar-grid"><thead><tr>' . render_weekday_headers($ctx) . '</tr></thead><tbody data-calendar-days>' . $days . '</tbody></table>' . '<table class="br-calendar-grid"><thead><tr>' . render_weekday_headers($ctx) . '</tr></thead><tbody data-calendar-days>' . $days . '</tbody></table>' .
'<div class="br-date-label" id="' . AppContext::e($name . '_label') . '">' . AppContext::e($ctx->t('Selected')) . ': ' . AppContext::e($selectedDate) . '</div><input type="hidden" id="' . AppContext::e($name . '_date') . '" name="' . AppContext::e($name . '_date') . '" value="' . AppContext::e($selectedDate) . '"></div>' . '<div class="br-date-label" id="' . AppContext::e($name . '_label') . '">' . AppContext::e($ctx->t('Selected')) . ': ' . AppContext::e($selectedDate) . '</div><input type="hidden" id="' . AppContext::e($name . '_date') . '" name="' . AppContext::e($name . '_date') . '" value="' . AppContext::e($selectedDate) . '"></div>' .
'<div class="br-restore-calendar"><h4>' . AppContext::e($ctx->t('Time')) . '</h4>' . $timeControl . '</div></div>' . '<div class="br-restore-calendar"><h4>' . AppContext::e($ctx->t('Time')) . '</h4>' . $timeControl . '</div></div>' .
@@ -153,7 +164,7 @@ function render_weekday_headers(AppContext $ctx): string
return $html; 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; $offset = ((int)$monthStart->format('N')) - 1;
$daysInMonth = (int)$monthStart->format('t'); $daysInMonth = (int)$monthStart->format('t');
@@ -167,6 +178,11 @@ function render_calendar_days(string $name, string $selectedDate, DateTimeImmuta
continue; continue;
} }
$date = $monthStart->setDate((int)$monthStart->format('Y'), (int)$monthStart->format('m'), $day)->format('Y-m-d'); $date = $monthStart->setDate((int)$monthStart->format('Y'), (int)$monthStart->format('m'), $day)->format('Y-m-d');
if ($date < $minDate) {
$html .= '<td><span class="br-cal-day-disabled">' . $day . '</span></td>';
$day++;
continue;
}
$class = $date === $selectedDate ? ' is-selected' : ''; $class = $date === $selectedDate ? ' is-selected' : '';
$html .= '<td><button type="button" class="br-cal-day' . $class . '" data-date-target="' . AppContext::e($name . '_date') . '" data-date-label="' . AppContext::e($name . '_label') . '" data-date-value="' . AppContext::e($date) . '">' . $day . '</button></td>'; $html .= '<td><button type="button" class="br-cal-day' . $class . '" data-date-target="' . AppContext::e($name . '_date') . '" data-date-label="' . AppContext::e($name . '_label') . '" data-date-value="' . AppContext::e($date) . '">' . $day . '</button></td>';
$day++; $day++;
+10 -4
View File
@@ -58,14 +58,14 @@ final class AppContext
public function csrfField(string $intent): string public function csrfField(string $intent): string
{ {
$issued = $this->csrf->issue($intent); $issued = $this->csrf->issue($intent);
return '<input type="hidden" name="csrf_ts" value="' . self::e((string)$issued['ts']) . '">' . return '<input type="hidden" name="gar_csrf_ts" value="' . self::e((string)$issued['ts']) . '">' .
'<input type="hidden" name="csrf_sid" value="' . self::e($issued['sid']) . '">' . '<input type="hidden" name="gar_csrf_sid" value="' . self::e($issued['sid']) . '">' .
'<input type="hidden" name="csrf_token" value="' . self::e($issued['token']) . '">'; '<input type="hidden" name="gar_csrf_token" value="' . self::e($issued['token']) . '">';
} }
public function requireCsrf(string $intent): void 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.'); throw new RuntimeException('Nieprawidłowy lub wygasły token CSRF.');
} }
} }
@@ -158,6 +158,7 @@ final class AppContext
} }
function renderMonth(calendar, year, month) { function renderMonth(calendar, year, month) {
var selected = calendar.getAttribute('data-selected-date') || ''; var selected = calendar.getAttribute('data-selected-date') || '';
var minDate = calendar.getAttribute('data-min-date') || '';
var tbody = calendar.querySelector('[data-calendar-days]'); var tbody = calendar.querySelector('[data-calendar-days]');
var label = calendar.querySelector('[data-calendar-month-label]'); var label = calendar.querySelector('[data-calendar-month-label]');
if (!tbody || !label) { return; } if (!tbody || !label) { return; }
@@ -174,6 +175,11 @@ final class AppContext
continue; continue;
} }
var value = year + '-' + pad(month) + '-' + pad(day); var value = year + '-' + pad(month) + '-' + pad(day);
if (minDate && value < minDate) {
html += '<td><span class="br-cal-day-disabled">' + day + '</span></td>';
day++;
continue;
}
var cls = value === selected ? ' is-selected' : ''; var cls = value === selected ? ' is-selected' : '';
html += '<td><button type="button" class="br-cal-day' + cls + '" data-date-target="' + calendar.getAttribute('data-calendar-name') + '_date" data-date-label="' + calendar.getAttribute('data-calendar-name') + '_label" data-date-value="' + value + '">' + day + '</button></td>'; html += '<td><button type="button" class="br-cal-day' + cls + '" data-date-target="' + calendar.getAttribute('data-calendar-name') + '_date" data-date-label="' + calendar.getAttribute('data-calendar-name') + '_label" data-date-value="' + value + '">' + day + '</button></td>';
day++; day++;
+3
View File
@@ -39,6 +39,9 @@ final class RuleValidator
$tz = new DateTimeZone($settings->timezone()); $tz = new DateTimeZone($settings->timezone());
[$startTs, $startValue] = self::parseScheduleValue((string)($input['start_value'] ?? ''), $mode, $tz, true); [$startTs, $startValue] = self::parseScheduleValue((string)($input['start_value'] ?? ''), $mode, $tz, true);
[$endTs, $endValue] = self::parseScheduleValue((string)($input['end_value'] ?? ''), $mode, $tz, false); [$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) { if ($endTs <= $startTs) {
throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.'); throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.');
} }
+1 -1
View File
@@ -2,7 +2,7 @@ name=global-autoresponder
id=global-autoresponder id=global-autoresponder
type=user type=user
author=HITME.PL author=HITME.PL
version=1.0.8 version=1.0.9
active=no active=no
installed=no installed=no
user_run_as=root user_run_as=root
+24
View File
@@ -2,6 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/CsrfGuard.php'; 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 { test('csrf token is bound to user session action and time', function (): void {
$guard = new CsrfGuard('secret', 'alice', 'session-1'); $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', (string)($issued['ts'] - 7200), $issued['token'], 3600, 'session-1'));
assert_false($guard->validate('create', 'bad', $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"'));
});
+18
View File
@@ -119,6 +119,24 @@ test('calendar picker is based on selected rule month and supports month navigat
assert_false(strpos($html, 'Czerwiec 2026') !== false); 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 { test('rule form localizes calendar and preview labels without translating user content globally', function (): void {
$settingsPath = TEST_TMP . '/form-i18n.conf'; $settingsPath = TEST_TMP . '/form-i18n.conf';
test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=en\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=en\nGLOBAL_AUTORESPONDER_BACKEND=exim\n");
+23
View File
@@ -75,3 +75,26 @@ test('rule validator rejects unsupported response frequency', function (): void
} }
throw new RuntimeException('Expected invalid frequency to fail'); 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');
});
+1 -1
View File
@@ -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-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, .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.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-date-label { font-weight: 600; color: #1f2a37; margin: 6px 0 10px; }
.br-time-picker { margin: 8px 0 14px; } .br-time-picker { margin: 8px 0 14px; }
.br-time-title { font-weight: 600; margin-bottom: 6px; color: var(--muted); } .br-time-title { font-weight: 600; margin-bottom: 6px; color: var(--muted); }