diff --git a/public/index.html b/public/index.html index b005b77..3345067 100644 --- a/public/index.html +++ b/public/index.html @@ -471,6 +471,7 @@ async function checkAuth() { document.getElementById('settingsUser').textContent = res.user.username + ' (' + res.user.user_token.substring(0, 12) + '...)'; document.getElementById('appLogin').style.display = 'none'; document.getElementById('appMain').style.display = ''; + fetchCsrf(); initApp(); return true; } @@ -547,11 +548,24 @@ function loadPage(name) { } // --- API Helpers --- +let csrfToken = ''; + +async function fetchCsrf() { + try { + const res = await api('/auth/csrf', { method: 'GET', noCsrf: true }); + csrfToken = res.csrf_token || ''; + } catch {} +} + async function api(path, opts = {}) { - const res = await fetch(API + path, { - headers: { 'Accept': 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) }, - ...opts, - }); + const headers = { 'Accept': 'application/json' }; + if (opts.body) { + headers['Content-Type'] = 'application/json'; + } + if (opts.method && opts.method !== 'GET' && !opts.noCsrf && csrfToken) { + headers['X-CSRF-TOKEN'] = csrfToken; + } + const res = await fetch(API + path, { headers, ...opts }); const data = await res.json(); if (!res.ok) { const err = new Error(data.error || 'Request failed'); @@ -963,7 +977,11 @@ async function loadSettings() { try { const res = await api('/config/telegram'); - document.getElementById('telegramBotToken').value = res.bot_token || ''; + if (res.bot_token) { + document.getElementById('telegramBotToken').value = res.bot_token; + } else { + document.getElementById('telegramBotToken').placeholder = res.bot_token_masked || 'Enter bot token'; + } document.getElementById('telegramChatId').value = res.chat_id || ''; } catch (e) { console.error('load telegram error', e); } diff --git a/public/oauth.php b/public/oauth.php index 966cad1..6e2b79f 100644 --- a/public/oauth.php +++ b/public/oauth.php @@ -5,47 +5,47 @@ require_once __DIR__ . '/../vendor/autoload.php'; use Jakach\Logging\Storage\Database; use Jakach\Logging\Storage\Repository; -$logFile = '/tmp/oauth_debug.log'; -file_put_contents($logFile, date('c') . " oauth.php called\n", FILE_APPEND); -file_put_contents($logFile, "GET: " . json_encode($_GET) . "\n", FILE_APPEND); +function isSafeRedirect(string $url): bool +{ + if ($url === '' || $url === '/') return true; + $host = parse_url($url, PHP_URL_HOST); + if ($host === null || $host === '') return true; + return str_ends_with($host, '.jakach.ch') || $host === 'jakach.ch'; +} session_set_cookie_params([ 'lifetime' => 86400 * 7, 'path' => '/', 'httponly' => true, + 'secure' => true, 'samesite' => 'Lax', ]); session_start(); $authToken = $_GET['auth'] ?? ''; -$errorRedirect = $_GET['redirect'] ?? '/'; - -file_put_contents($logFile, "authToken: $authToken\n", FILE_APPEND); +$errorRedirect = isSafeRedirect($_GET['redirect'] ?? '') ? $_GET['redirect'] : '/'; if (!$authToken) { $_SESSION['auth_error'] = 'Missing authentication token.'; - file_put_contents($logFile, "ERROR: missing auth token\n", FILE_APPEND); header('Location: ' . $errorRedirect); exit; } $checkUrl = 'https://auth.jakach.ch/api/auth/check_auth_key.php?auth_token=' . urlencode($authToken); -file_put_contents($logFile, "checkUrl: $checkUrl\n", FILE_APPEND); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $checkUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 15); +curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); +curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); -file_put_contents($logFile, "httpCode: $httpCode response: " . substr($response, 0, 500) . " curlError: $curlError\n", FILE_APPEND); - if ($httpCode !== 200 || !$response) { $_SESSION['auth_error'] = "Auth server unreachable ($httpCode)"; - file_put_contents($logFile, "ERROR: bad response $httpCode\n", FILE_APPEND); header('Location: ' . $errorRedirect); exit; } @@ -54,30 +54,27 @@ $data = json_decode($response, true); if (!isset($data['status']) || $data['status'] !== 'success') { $_SESSION['auth_error'] = 'Authentication failed: ' . ($data['msg'] ?? 'Unknown error'); - file_put_contents($logFile, "ERROR: auth failed: " . json_encode($data) . "\n", FILE_APPEND); header('Location: ' . $errorRedirect); exit; } $userToken = $data['user_token'] ?? ''; -file_put_contents($logFile, "Auth success, user_token: $userToken\n", FILE_APPEND); $db = new Database(); $repo = new Repository($db); $allowedTokens = $repo->getAllowedUserTokens(); -file_put_contents($logFile, "allowedTokens: " . json_encode($allowedTokens) . "\n", FILE_APPEND); if (empty($allowedTokens)) { - file_put_contents($logFile, "First user, adding to allowed tokens\n", FILE_APPEND); $repo->setAllowedUserTokens([$userToken]); } elseif (!in_array($userToken, $allowedTokens, true)) { $_SESSION['auth_error'] = 'Your Jakach account is not authorized to access this system. Contact an administrator.'; - file_put_contents($logFile, "ERROR: user not allowed\n", FILE_APPEND); header('Location: ' . $errorRedirect); exit; } +session_regenerate_id(true); + $_SESSION['loggedin'] = true; $_SESSION['username'] = $data['username'] ?? 'unknown'; $_SESSION['id'] = $data['id'] ?? ''; @@ -86,8 +83,6 @@ $_SESSION['telegram_id'] = $data['telegram_id'] ?? ''; $_SESSION['user_token'] = $userToken; unset($_SESSION['auth_error']); -file_put_contents($logFile, "Session set, redirecting to: $errorRedirect\n", FILE_APPEND); - -$redirect = $_GET['redirect'] ?? '/'; +$redirect = isSafeRedirect($_GET['redirect'] ?? '') ? $_GET['redirect'] : '/'; header('Location: ' . $redirect); exit; \ No newline at end of file diff --git a/src/Api/AuthMiddleware.php b/src/Api/AuthMiddleware.php index 06672a3..a2318c2 100644 --- a/src/Api/AuthMiddleware.php +++ b/src/Api/AuthMiddleware.php @@ -20,6 +20,7 @@ class AuthMiddleware 'lifetime' => 86400 * 7, 'path' => '/', 'httponly' => true, + 'secure' => true, 'samesite' => 'Lax', ]); session_start(); @@ -29,6 +30,10 @@ class AuthMiddleware return null; } + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + $allowedTokens = $this->repo->getAllowedUserTokens(); $userToken = $_SESSION['user_token'] ?? ''; @@ -41,6 +46,15 @@ class AuthMiddleware 'username' => $_SESSION['username'] ?? 'unknown', 'user_token' => $userToken, 'email' => $_SESSION['email'] ?? '', + 'csrf_token' => $_SESSION['csrf_token'] ?? '', ]; } + + public function validateCsrf(): bool + { + if (empty($_SESSION['csrf_token'])) return false; + $body = json_decode(file_get_contents('php://input'), true); + $token = $body['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + return hash_equals($_SESSION['csrf_token'], $token); + } } \ No newline at end of file diff --git a/src/Api/Router.php b/src/Api/Router.php index 84fad2b..6f77ac0 100644 --- a/src/Api/Router.php +++ b/src/Api/Router.php @@ -33,7 +33,7 @@ class Router header('Content-Type: application/json'); try { - $publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/ingest']; + $publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/auth/csrf', '/ingest']; $isPublic = false; foreach ($publicPaths as $pp) { if ($path === $pp || str_starts_with($path, $pp . '/')) { @@ -54,31 +54,33 @@ class Router $result = match (true) { $path === '/health' && $method === 'GET' => ['status' => 'ok'], + $path === '/auth/csrf' && $method === 'GET' => $this->csrfToken(), + $path === '/ingest' && $method === 'POST' => $this->ingest(), $path === '/auth/me' && $method === 'GET' => $this->getMe(), $path === '/auth/logout' && $method === 'POST' => $this->logout(), $path === '/sources' && $method === 'GET' => $this->repo->getSources(), - $path === '/sources' && $method === 'POST' => $this->createSource(), + $path === '/sources' && $method === 'POST' => $this->requireCsrf(fn() => $this->createSource()), preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'DELETE' - => $this->deleteEntity('source', (int) $m[1]), + => $this->requireCsrf(fn() => $this->deleteEntity('source', (int) $m[1])), preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'PUT' - => $this->updateSource((int) $m[1]), + => $this->requireCsrf(fn() => $this->updateSource((int) $m[1])), $path === '/rules' && $method === 'GET' => $this->repo->getRules(), - $path === '/rules' && $method === 'POST' => $this->createRule(), + $path === '/rules' && $method === 'POST' => $this->requireCsrf(fn() => $this->createRule()), preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'DELETE' - => $this->deleteEntity('rule', (int) $m[1]), + => $this->requireCsrf(fn() => $this->deleteEntity('rule', (int) $m[1])), preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'PUT' - => $this->updateRule((int) $m[1]), + => $this->requireCsrf(fn() => $this->updateRule((int) $m[1])), $path === '/alerts' && $method === 'GET' => $this->getAlerts(), $path === '/alerts/search' && $method === 'GET' => $this->searchAlerts(), preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST' - => $this->ackAlert((int) $m[1]), + => $this->requireCsrf(fn() => $this->ackAlert((int) $m[1])), preg_match('#^/alerts/(\d+)/status$#', $path, $m) && $method === 'POST' - => $this->updateAlertStatus((int) $m[1]), + => $this->requireCsrf(fn() => $this->updateAlertStatus((int) $m[1])), preg_match('#^/alerts/counts$#', $path) && $method === 'GET' => $this->repo->getAlertCounts(), @@ -87,19 +89,19 @@ class Router $path === '/config/allowed_tokens' && $method === 'GET' => ['tokens' => $this->repo->getAllowedUserTokens()], $path === '/config/allowed_tokens' && $method === 'PUT' - => $this->updateAllowedTokens(), + => $this->requireCsrf(fn() => $this->updateAllowedTokens()), $path === '/config/telegram' && $method === 'GET' => $this->getTelegramConfig(), $path === '/config/telegram' && $method === 'PUT' - => $this->updateTelegramConfig(), + => $this->requireCsrf(fn() => $this->updateTelegramConfig()), $path === '/false-positives' && $method === 'GET' => $this->getFalsePositives(), $path === '/false-positives' && $method === 'POST' - => $this->createFalsePositive(), + => $this->requireCsrf(fn() => $this->createFalsePositive()), preg_match('#^/false-positives/(\d+)$#', $path, $m) && $method === 'DELETE' - => $this->deleteFalsePositive((int) $m[1]), + => $this->requireCsrf(fn() => $this->deleteFalsePositive((int) $m[1])), default => throw new \RuntimeException('Not found', 404), }; @@ -309,8 +311,13 @@ class Router private function getTelegramConfig(): array { + $token = $this->repo->getConfig('telegram_bot_token', ''); + $masked = strlen($token) > 8 + ? substr($token, 0, 8) . '...' + : ($token ? substr($token, 0, 3) . '...' : ''); return [ - 'bot_token' => $this->repo->getConfig('telegram_bot_token', ''), + 'bot_token' => $token, + 'bot_token_masked' => $masked, 'chat_id' => $this->repo->getConfig('telegram_chat_id', ''), ]; } @@ -347,4 +354,19 @@ class Router $this->repo->deleteFalsePositive($id); return ['status' => 'deleted', 'id' => $id]; } + + private function csrfToken(): array + { + $this->auth->requireAuth(); + return ['csrf_token' => $_SESSION['csrf_token'] ?? '']; + } + + private function requireCsrf(callable $fn): mixed + { + if (!$this->auth->validateCsrf()) { + http_response_code(403); + return ['error' => 'Invalid or missing CSRF token']; + } + return $fn(); + } } \ No newline at end of file diff --git a/src/Storage/Database.php b/src/Storage/Database.php index 96cda86..445f981 100644 --- a/src/Storage/Database.php +++ b/src/Storage/Database.php @@ -157,6 +157,22 @@ $this->pdo->exec(" SELECT id, line, source_name FROM log_entries "); + $this->pdo->exec(" + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + "); + + $this->pdo->exec(" + CREATE TABLE IF NOT EXISTS rate_limiter ( + rule_id INTEGER NOT NULL, + window_start INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (rule_id, window_start) + ) + "); + $this->pdo->exec(" CREATE TABLE IF NOT EXISTS false_positives ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/Worker/FileWatcher.php b/src/Worker/FileWatcher.php index 0663921..269438e 100644 --- a/src/Worker/FileWatcher.php +++ b/src/Worker/FileWatcher.php @@ -11,6 +11,12 @@ class FileWatcher private array $patterns = []; private int $checkInterval; + private const ALLOWED_DIRS = [ + '/collect', + '/var/log', + '/app/logs', + ]; + public function __construct( private \Closure $onLine, int $checkInterval = 500000, @@ -18,6 +24,19 @@ class FileWatcher $this->checkInterval = $checkInterval; } + private function isPathSafe(string $path): bool + { + $real = realpath($path); + if ($real === false) return false; + foreach (self::ALLOWED_DIRS as $dir) { + $realDir = realpath($dir); + if ($realDir !== false && str_starts_with($real, $realDir)) { + return true; + } + } + return false; + } + public function watch(LogSource $source): void { if ($source->type !== LogSourceType::File) { @@ -26,6 +45,11 @@ class FileWatcher $path = $source->address; + if (!str_starts_with($path, '/')) { + fprintf(STDERR, "Rejected relative path: %s (source: %s)\n", $path, $source->name); + return; + } + if (str_contains($path, '*') || str_contains($path, '?')) { $dir = dirname($path); $pattern = basename($path); @@ -48,7 +72,7 @@ class FileWatcher private function scanPattern(int $sourceId, string $dir, string $pattern): void { - if (!is_dir($dir)) { + if (!is_dir($dir) || !$this->isPathSafe($dir)) { return; } $files = glob($dir . '/' . $pattern); @@ -56,6 +80,7 @@ class FileWatcher return; } foreach ($files as $file) { + if (!$this->isPathSafe($file)) continue; $key = $sourceId . ':' . $file; if (!isset($this->handles[$key])) { $this->openFile($key, $file); @@ -66,6 +91,10 @@ class FileWatcher private function openFile(string|int $key, string $path): void { + if (!$this->isPathSafe($path)) { + fprintf(STDERR, "Rejected unsafe path: %s\n", $path); + return; + } $handle = fopen($path, 'r'); if (!$handle) { fprintf(STDERR, "Cannot open file: %s\n", $path);