fix: scope csrf fields and block past dates
This commit is contained in:
@@ -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
|
||||
'</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 */
|
||||
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
|
||||
'<option value="evening"' . ($selectedPeriod === 'evening' ? ' selected' : '') . '>' . AppContext::e($ctx->t('Evening')) . '</option>' .
|
||||
'</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>‹</button><span class="br-month-label" data-calendar-month-label>' . AppContext::e($monthLabel) . '</span><button type="button" class="br-month-btn" data-calendar-next>›</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>‹</button><span class="br-month-label" data-calendar-month-label>' . AppContext::e($monthLabel) . '</span><button type="button" class="br-month-btn" data-calendar-next>›</button></div>' .
|
||||
'<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-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;
|
||||
}
|
||||
|
||||
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 .= '<td><span class="br-cal-day-disabled">' . $day . '</span></td>';
|
||||
$day++;
|
||||
continue;
|
||||
}
|
||||
$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>';
|
||||
$day++;
|
||||
|
||||
+10
-4
@@ -58,14 +58,14 @@ final class AppContext
|
||||
public function csrfField(string $intent): string
|
||||
{
|
||||
$issued = $this->csrf->issue($intent);
|
||||
return '<input type="hidden" name="csrf_ts" value="' . self::e((string)$issued['ts']) . '">' .
|
||||
'<input type="hidden" name="csrf_sid" value="' . self::e($issued['sid']) . '">' .
|
||||
'<input type="hidden" name="csrf_token" value="' . self::e($issued['token']) . '">';
|
||||
return '<input type="hidden" name="gar_csrf_ts" value="' . self::e((string)$issued['ts']) . '">' .
|
||||
'<input type="hidden" name="gar_csrf_sid" value="' . self::e($issued['sid']) . '">' .
|
||||
'<input type="hidden" name="gar_csrf_token" value="' . self::e($issued['token']) . '">';
|
||||
}
|
||||
|
||||
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 += '<td><span class="br-cal-day-disabled">' + day + '</span></td>';
|
||||
day++;
|
||||
continue;
|
||||
}
|
||||
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>';
|
||||
day++;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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"'));
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+1
-1
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user