Files
domnitor/core/TwigService.php
Hosteroid e3006738a9 Improve security, validation, and isolation checks
Add multiple security and validation improvements across the app:

- Prevent session fixation: regenerate session ID on login and after successful 2FA; tighten session cookie params (Secure, HttpOnly, SameSite=Lax).
- Harden installer: add CSRF checks for install/update flows and use PDO::quote when injecting admin credentials into SQL migration to avoid injection; add csrf_field() to installer templates.
- Template hardening: add safe_url and safe_mailto Twig filters, escape tag names for JS, and add rel="noopener noreferrer" to external links to mitigate XSS/opener risks.
- Domain controller: validate referrer to avoid open redirects, enforce user isolation mode when finding/deleting/updating domains and when assigning notification groups (ensures users only affect their own resources).
- Notification groups: verify channel belongs to group before deleting or toggling to prevent unauthorized access.
- ErrorLog: whitelist allowed sort columns to avoid arbitrary column injection in ORDER BY.
- Routes: move the debug whois route to protected/admin area.

These changes collectively reduce attack surface (XSS, open redirect, session fixation, SQL injection) and enforce proper resource isolation and input validation.
2026-03-11 00:03:54 +02:00

274 lines
11 KiB
PHP

<?php
namespace Core;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Twig\Extension\DebugExtension;
use Twig\TwigFunction;
use Twig\TwigFilter;
class TwigService
{
private static ?self $instance = null;
private Environment $twig;
private function __construct()
{
$viewsPath = PATH_ROOT . 'app/Views';
$loader = new FilesystemLoader($viewsPath);
$isDev = ($_ENV['APP_ENV'] ?? 'development') === 'development';
$cachePath = $isDev ? false : PATH_ROOT . 'cache/twig';
$this->twig = new Environment($loader, [
'cache' => $cachePath,
'debug' => $isDev,
'auto_reload' => $isDev,
'strict_variables' => false,
'autoescape' => 'html',
]);
if ($isDev) {
$this->twig->addExtension(new DebugExtension());
}
$this->registerFunctions();
$this->registerFilters();
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getEnvironment(): Environment
{
return $this->twig;
}
/**
* Render a Twig template with data + automatically injected globals.
*/
public function render(string $template, array $data = []): string
{
$globals = $this->getGlobals();
$context = array_merge($globals, $data);
return $this->twig->render($template, $context);
}
/**
* Collect layout-level data that every template may need.
* Computed on each render so values are always fresh.
*/
private function getGlobals(): array
{
// Session flash messages (read & clear) — always safe
$flash = [];
foreach (['success', 'error', 'warning', 'info'] as $type) {
if (isset($_SESSION[$type])) {
$flash[$type] = $_SESSION[$type];
unset($_SESSION[$type]);
}
}
// Database-dependent globals are wrapped in try/catch so standalone
// pages (installer, error pages) still render when the DB is absent.
try {
$userId = Auth::id();
if ($userId) {
$notificationData = \App\Helpers\LayoutHelper::getNotifications($userId);
$recentNotifications = $notificationData['items'];
$unreadNotifications = $notificationData['unread_count'];
$updateBadge = Auth::isAdmin()
? \App\Helpers\LayoutHelper::getUpdateBadgeInfo()
: ['show' => false, 'available' => false, 'label' => ''];
} else {
$recentNotifications = [];
$unreadNotifications = 0;
$updateBadge = ['show' => false, 'available' => false, 'label' => ''];
}
$domainStats = \App\Helpers\LayoutHelper::getDomainStats();
$appSettings = \App\Helpers\LayoutHelper::getAppSettings();
$avatar = null;
if ($userId) {
$userModel = new \App\Models\User();
$user = $userModel->find($userId);
if ($user) {
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 36);
}
}
return [
'auth' => [
'check' => Auth::check(),
'id' => $userId,
'username' => Auth::username(),
'fullName' => Auth::fullName(),
'role' => Auth::role(),
'isAdmin' => Auth::isAdmin(),
],
'session' => $_SESSION ?? [],
'flash' => $flash,
'recentNotifications' => $recentNotifications,
'unreadNotifications' => $unreadNotifications,
'updateBadge' => $updateBadge,
'domainStats' => $domainStats,
'appName' => $appSettings['app_name'],
'appTimezone' => $appSettings['app_timezone'],
'appVersion' => $appSettings['app_version'],
'avatar' => $avatar,
'currentUrl' => $_SERVER['REQUEST_URI'] ?? '/',
'appEnv' => $_ENV['APP_ENV'] ?? 'development',
];
} catch (\Throwable $e) {
return [
'auth' => ['check' => false, 'id' => null, 'username' => '', 'fullName' => '', 'role' => '', 'isAdmin' => false],
'session' => $_SESSION ?? [],
'flash' => $flash,
'recentNotifications' => [],
'unreadNotifications' => 0,
'updateBadge' => ['show' => false, 'available' => false, 'label' => ''],
'domainStats' => [],
'appName' => 'Domain Monitor',
'appTimezone' => 'UTC',
'appVersion' => '',
'avatar' => null,
'currentUrl' => $_SERVER['REQUEST_URI'] ?? '/',
'appEnv' => $_ENV['APP_ENV'] ?? 'development',
];
}
}
private function registerFunctions(): void
{
$this->twig->addFunction(new TwigFunction('csrf_field', function (): string {
return \Core\Csrf::field();
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('csrf_token', function (): string {
return \Core\Csrf::getToken();
}));
$this->twig->addFunction(new TwigFunction('old', function (string $key, string $default = ''): string {
return htmlspecialchars($_POST[$key] ?? $default, ENT_QUOTES, 'UTF-8');
}));
$this->twig->addFunction(new TwigFunction('asset', function (string $path): string {
return '/' . ltrim($path, '/');
}));
$this->twig->addFunction(new TwigFunction('is_active', function (string $path): bool {
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
return $current === $path;
}));
$this->twig->addFunction(new TwigFunction('is_active_prefix', function (string $prefix): bool {
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
return str_starts_with($current, $prefix);
}));
$this->twig->addFunction(new TwigFunction('sort_url', function (string $column, string $currentSort, string $currentOrder, array $filters = []): string {
return \App\Helpers\ViewHelper::sortUrl($column, $currentSort, $currentOrder, $filters);
}));
$this->twig->addFunction(new TwigFunction('sort_icon', function (string $column, string $currentSort, string $currentOrder): string {
return \App\Helpers\ViewHelper::sortIcon($column, $currentSort, $currentOrder);
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('pagination_url', function (int $page, array $filters, int $perPage): string {
return \App\Helpers\ViewHelper::paginationUrl($page, $filters, $perPage);
}));
$this->twig->addFunction(new TwigFunction('status_badge', function (string $status): string {
return \App\Helpers\ViewHelper::statusBadge($status);
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('alert', function (string $type, string $message): string {
return \App\Helpers\ViewHelper::alert($type, $message);
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('breadcrumbs', function (array $items): string {
return \App\Helpers\ViewHelper::breadcrumbs($items);
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('format_login_dropdown', function (array $loginData): string {
return \App\Helpers\LayoutHelper::formatLoginDropdown($loginData);
}));
$this->twig->addFunction(new TwigFunction('max_upload_size', function (): string {
return \App\Helpers\ViewHelper::getMaxUploadSize();
}));
$this->twig->addFunction(new TwigFunction('role_badge', function (string $role, string $size = 'sm'): string {
$isAdmin = $role === 'admin';
$color = $isAdmin ? 'amber' : 'blue';
$icon = $isAdmin ? 'crown' : 'user';
$label = ucfirst($role);
if ($size === 'xs') {
$padding = 'px-2 py-0.5';
} else {
$padding = 'px-2.5 py-1';
}
return '<span class="inline-flex items-center ' . $padding . ' rounded-full text-xs font-semibold '
. 'bg-' . $color . '-100 dark:bg-' . $color . '-500/10 '
. 'text-' . $color . '-700 dark:text-' . $color . '-400 '
. 'border border-' . $color . '-200 dark:border-' . $color . '-500/20">'
. '<i class="fas fa-' . $icon . ' mr-1"></i>'
. htmlspecialchars($label)
. '</span>';
}, ['is_safe' => ['html']]));
}
private function registerFilters(): void
{
$this->twig->addFilter(new TwigFilter('truncate', function (string $text, int $length = 50, string $suffix = '...'): string {
return \App\Helpers\ViewHelper::truncate($text, $length, $suffix);
}));
$this->twig->addFilter(new TwigFilter('format_bytes', function (int $bytes, int $precision = 2): string {
return \App\Helpers\ViewHelper::formatBytes($bytes, $precision);
}));
$this->twig->addFilter(new TwigFilter('safe_url', function (?string $url): string {
if ($url === null || $url === '') {
return '#';
}
if (preg_match('#^https?://#i', $url)) {
return $url;
}
return '#';
}));
$this->twig->addFilter(new TwigFilter('safe_mailto', function (?string $email): string {
if ($email === null || $email === '') {
return '#';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
return 'mailto:' . $email;
}
return '#';
}));
$this->twig->addFilter(new TwigFilter('from_json', function ($value) {
if ($value === null || $value === '') {
return [];
}
if (is_array($value) || is_object($value)) {
return $value;
}
$decoded = json_decode((string) $value, true);
return $decoded ?? [];
}));
}
}