diff --git a/README.md b/README.md index 3d579af..93d47af 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,11 @@ docker compose exec api php bin/seed open http://localhost:8080 ``` -The first user to log in via Jakach Auth is automatically added as admin. +Set `ALLOW_FIRST_USER_BOOTSTRAP=true` only during initial setup if you want the first Jakach Auth user to be automatically added as admin. ## Authentication -Uses [Jakach Login](https://github.com/jakani24/jakach-login) for authentication. Users authenticate via `auth.jakach.ch`. The first user is automatically authorized; subsequent users must have their `user_token` added in Settings > Security. +Uses [Jakach Login](https://github.com/jakani24/jakach-login) for authentication. Users authenticate via `auth.jakach.ch`. Authorized users must have their `user_token` added in Settings > Security. First-user auto-authorization is disabled unless `ALLOW_FIRST_USER_BOOTSTRAP=true` is set for initial setup. ## Log Sources (Ingestion) @@ -97,11 +97,7 @@ services: ``` ### HTTP POST -```bash -curl -X POST http://localhost:8080/ingest \ - -H 'Content-Type: application/json' \ - -d '{"line":"2024-01-01 ERROR: something broke","source":"my-app"}' -``` +`/ingest` requires an authenticated session and CSRF token. For unauthenticated service-to-service ingestion, put it behind a trusted reverse proxy or add a dedicated ingest-token flow before exposing it publicly. ### Shared volume ```yaml @@ -164,7 +160,7 @@ Quick actions in the table: ✓ (acknowledge), ✓✓ (resolve). Click any row f | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/health` | No | Health check | -| POST | `/ingest` | No | Ingest a log line | +| POST | `/ingest` | Yes | Ingest a log line | | GET | `/auth/me` | No | Current session user | | POST | `/auth/logout` | No | Destroy session | | GET | `/sources` | Yes | List sources | @@ -242,4 +238,4 @@ composer serve ├── FileWatcher.php ├── Orchestrator.php └── SocketListener.php -``` \ No newline at end of file +``` diff --git a/public/index.html b/public/index.html index 2e05039..2d48c9f 100644 --- a/public/index.html +++ b/public/index.html @@ -626,6 +626,9 @@ async function api(path, opts = {}) { if (opts.body) { headers['Content-Type'] = 'application/json'; } + if (opts.method && opts.method !== 'GET' && !opts.noCsrf && !csrfToken) { + await fetchCsrf(); + } if (opts.method && opts.method !== 'GET' && !opts.noCsrf && csrfToken) { headers['X-CSRF-TOKEN'] = csrfToken; } @@ -1044,11 +1047,10 @@ async function loadSettings() { try { const res = await api('/config/telegram'); - 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('telegramBotToken').value = ''; + document.getElementById('telegramBotToken').placeholder = res.bot_token_configured + ? (res.bot_token_masked || 'Token configured') + : 'Enter bot token'; document.getElementById('telegramChatId').value = res.chat_id || ''; } catch (e) { console.error('load telegram error', e); } @@ -1090,13 +1092,15 @@ document.getElementById('saveTokensBtn').addEventListener('click', async () => { document.getElementById('saveTelegramBtn').addEventListener('click', async () => { const botToken = document.getElementById('telegramBotToken').value.trim(); const chatId = document.getElementById('telegramChatId').value.trim(); + const payload = { chat_id: chatId }; + if (botToken) payload.bot_token = botToken; const statusEl = document.getElementById('telegramSaveStatus'); statusEl.textContent = 'Saving...'; statusEl.className = 'ms-2 text-secondary'; try { await api('/config/telegram', { method: 'PUT', - body: JSON.stringify({ bot_token: botToken, chat_id: chatId }), + body: JSON.stringify(payload), }); statusEl.textContent = 'Saved'; statusEl.className = 'ms-2 text-success'; @@ -1445,4 +1449,4 @@ function initApp() { checkAuth(); - \ No newline at end of file + diff --git a/public/oauth.php b/public/oauth.php index 4a4cde0..3b3444a 100644 --- a/public/oauth.php +++ b/public/oauth.php @@ -67,6 +67,12 @@ $repo = new Repository($db); $allowedTokens = $repo->getAllowedUserTokens(); if (empty($allowedTokens)) { + $bootstrapAllowed = filter_var(getenv('ALLOW_FIRST_USER_BOOTSTRAP') ?: 'false', FILTER_VALIDATE_BOOL); + if (!$bootstrapAllowed) { + $_SESSION['auth_error'] = 'No users are authorized for this system. Set allowed_user_tokens or enable first-user bootstrap during initial setup.'; + header('Location: ' . $errorRedirect); + exit; + } $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.'; @@ -86,4 +92,4 @@ unset($_SESSION['auth_error']); $redirect = isSafeRedirect($rawRedirect) ? $rawRedirect : '/'; header('Location: ' . $redirect); -exit; \ No newline at end of file +exit; diff --git a/src/Api/Router.php b/src/Api/Router.php index 0797ce9..383efe8 100644 --- a/src/Api/Router.php +++ b/src/Api/Router.php @@ -39,7 +39,7 @@ class Router header('Content-Type: application/json'); try { - $publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/auth/csrf', '/ingest']; + $publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/auth/csrf']; $isPublic = false; foreach ($publicPaths as $pp) { if ($path === $pp || str_starts_with($path, $pp . '/')) { @@ -63,7 +63,7 @@ class Router $path === '/auth/csrf' && $method === 'GET' => $this->csrfToken(), - $path === '/ingest' && $method === 'POST' => $this->ingest(), + $path === '/ingest' && $method === 'POST' => $this->requireCsrf(fn() => $this->ingest()), $path === '/auth/me' && $method === 'GET' => $this->getMe(), $path === '/auth/logout' && $method === 'POST' => $this->logout(), @@ -189,10 +189,23 @@ class Router private function loginUrl(): string { - $redirect = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/oauth.php'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + if (!$this->isTrustedHost($host)) { + $host = 'localhost'; + } + $redirect = 'https://' . $host . '/oauth.php'; return 'https://auth.jakach.ch/?send_to=' . urlencode($redirect); } + private function isTrustedHost(string $host): bool + { + $host = strtolower(preg_replace('/:\d+$/', '', $host)); + return $host === 'localhost' + || $host === '127.0.0.1' + || $host === 'jakach.ch' + || str_ends_with($host, '.jakach.ch'); + } + private function getMe(): array { $user = $this->auth->requireAuth(); @@ -294,8 +307,8 @@ class Router private function getAlerts(): mixed { - $limit = (int) ($_GET['limit'] ?? 100); - $offset = (int) ($_GET['offset'] ?? 0); + $limit = $this->boundedInt($_GET['limit'] ?? 100, 1, 500); + $offset = $this->boundedInt($_GET['offset'] ?? 0, 0, 100000); $status = $_GET['status'] ?? null; $severity = $_GET['severity'] ?? null; return $this->repo->getAlerts($limit, $offset, $status, $severity, $_GET['since'] ?? null, $_GET['until'] ?? null); @@ -327,7 +340,7 @@ class Router if (empty($query)) { return []; } - $limit = (int) ($_GET['limit'] ?? 100); + $limit = $this->boundedInt($_GET['limit'] ?? 100, 1, 500); return $this->repo->searchAlerts($query, $limit); } @@ -337,8 +350,8 @@ class Router if (empty($query)) { return ['data' => []]; } - $limit = (int) ($_GET['limit'] ?? 200); - $offset = (int) ($_GET['offset'] ?? 0); + $limit = $this->boundedInt($_GET['limit'] ?? 200, 1, 1000); + $offset = $this->boundedInt($_GET['offset'] ?? 0, 0, 100000); return ['data' => $this->repo->searchLogEntries($query, $limit, $offset, $_GET['since'] ?? null, $_GET['until'] ?? null)]; } @@ -361,6 +374,10 @@ class Router http_response_code(400); return ['error' => 'Missing "line" field']; } + if (strlen($line) > 65535) { + http_response_code(413); + return ['error' => 'Log line too large']; + } $alert = $this->engine->evaluate($line, null); @@ -378,16 +395,18 @@ class Router ? substr($token, 0, 8) . '...' : ($token ? substr($token, 0, 3) . '...' : ''); return [ - 'bot_token' => $token, 'bot_token_masked' => $masked, + 'bot_token_configured' => $token !== '', 'chat_id' => $this->repo->getConfig('telegram_chat_id', ''), ]; } private function updateTelegramConfig(): array { - $body = json_decode(file_get_contents('php://input'), true); - $this->repo->setConfig('telegram_bot_token', $body['bot_token'] ?? ''); + $body = json_decode(file_get_contents('php://input'), true) ?: []; + if (array_key_exists('bot_token', $body) && trim((string) $body['bot_token']) !== '') { + $this->repo->setConfig('telegram_bot_token', trim((string) $body['bot_token'])); + } $this->repo->setConfig('telegram_chat_id', $body['chat_id'] ?? ''); $this->repo->logAudit('update', 'config', null, 'Telegram config updated', $this->user()); return $this->getTelegramConfig(); @@ -547,7 +566,16 @@ class Router private function getAuditLog(): array { - $limit = (int) ($_GET['limit'] ?? 50); + $limit = $this->boundedInt($_GET['limit'] ?? 50, 1, 500); return ['data' => $this->repo->getAuditLog($limit)]; } -} \ No newline at end of file + + private function boundedInt(mixed $value, int $min, int $max): int + { + $value = filter_var($value, FILTER_VALIDATE_INT); + if ($value === false) { + return $min; + } + return max($min, min($max, $value)); + } +}