+23
-5
@@ -471,6 +471,7 @@ async function checkAuth() {
|
|||||||
document.getElementById('settingsUser').textContent = res.user.username + ' (' + res.user.user_token.substring(0, 12) + '...)';
|
document.getElementById('settingsUser').textContent = res.user.username + ' (' + res.user.user_token.substring(0, 12) + '...)';
|
||||||
document.getElementById('appLogin').style.display = 'none';
|
document.getElementById('appLogin').style.display = 'none';
|
||||||
document.getElementById('appMain').style.display = '';
|
document.getElementById('appMain').style.display = '';
|
||||||
|
fetchCsrf();
|
||||||
initApp();
|
initApp();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -547,11 +548,24 @@ function loadPage(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- API Helpers ---
|
// --- 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 = {}) {
|
async function api(path, opts = {}) {
|
||||||
const res = await fetch(API + path, {
|
const headers = { 'Accept': 'application/json' };
|
||||||
headers: { 'Accept': 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) },
|
if (opts.body) {
|
||||||
...opts,
|
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();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(data.error || 'Request failed');
|
const err = new Error(data.error || 'Request failed');
|
||||||
@@ -963,7 +977,11 @@ async function loadSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/config/telegram');
|
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 || '';
|
document.getElementById('telegramChatId').value = res.chat_id || '';
|
||||||
} catch (e) { console.error('load telegram error', e); }
|
} catch (e) { console.error('load telegram error', e); }
|
||||||
|
|
||||||
|
|||||||
+14
-19
@@ -5,47 +5,47 @@ require_once __DIR__ . '/../vendor/autoload.php';
|
|||||||
use Jakach\Logging\Storage\Database;
|
use Jakach\Logging\Storage\Database;
|
||||||
use Jakach\Logging\Storage\Repository;
|
use Jakach\Logging\Storage\Repository;
|
||||||
|
|
||||||
$logFile = '/tmp/oauth_debug.log';
|
function isSafeRedirect(string $url): bool
|
||||||
file_put_contents($logFile, date('c') . " oauth.php called\n", FILE_APPEND);
|
{
|
||||||
file_put_contents($logFile, "GET: " . json_encode($_GET) . "\n", FILE_APPEND);
|
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([
|
session_set_cookie_params([
|
||||||
'lifetime' => 86400 * 7,
|
'lifetime' => 86400 * 7,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
|
'secure' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
]);
|
]);
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
$authToken = $_GET['auth'] ?? '';
|
$authToken = $_GET['auth'] ?? '';
|
||||||
$errorRedirect = $_GET['redirect'] ?? '/';
|
$errorRedirect = isSafeRedirect($_GET['redirect'] ?? '') ? $_GET['redirect'] : '/';
|
||||||
|
|
||||||
file_put_contents($logFile, "authToken: $authToken\n", FILE_APPEND);
|
|
||||||
|
|
||||||
if (!$authToken) {
|
if (!$authToken) {
|
||||||
$_SESSION['auth_error'] = 'Missing authentication token.';
|
$_SESSION['auth_error'] = 'Missing authentication token.';
|
||||||
file_put_contents($logFile, "ERROR: missing auth token\n", FILE_APPEND);
|
|
||||||
header('Location: ' . $errorRedirect);
|
header('Location: ' . $errorRedirect);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkUrl = 'https://auth.jakach.ch/api/auth/check_auth_key.php?auth_token=' . urlencode($authToken);
|
$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();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, $checkUrl);
|
curl_setopt($ch, CURLOPT_URL, $checkUrl);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$curlError = curl_error($ch);
|
$curlError = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
file_put_contents($logFile, "httpCode: $httpCode response: " . substr($response, 0, 500) . " curlError: $curlError\n", FILE_APPEND);
|
|
||||||
|
|
||||||
if ($httpCode !== 200 || !$response) {
|
if ($httpCode !== 200 || !$response) {
|
||||||
$_SESSION['auth_error'] = "Auth server unreachable ($httpCode)";
|
$_SESSION['auth_error'] = "Auth server unreachable ($httpCode)";
|
||||||
file_put_contents($logFile, "ERROR: bad response $httpCode\n", FILE_APPEND);
|
|
||||||
header('Location: ' . $errorRedirect);
|
header('Location: ' . $errorRedirect);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -54,30 +54,27 @@ $data = json_decode($response, true);
|
|||||||
|
|
||||||
if (!isset($data['status']) || $data['status'] !== 'success') {
|
if (!isset($data['status']) || $data['status'] !== 'success') {
|
||||||
$_SESSION['auth_error'] = 'Authentication failed: ' . ($data['msg'] ?? 'Unknown error');
|
$_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);
|
header('Location: ' . $errorRedirect);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$userToken = $data['user_token'] ?? '';
|
$userToken = $data['user_token'] ?? '';
|
||||||
file_put_contents($logFile, "Auth success, user_token: $userToken\n", FILE_APPEND);
|
|
||||||
|
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
$repo = new Repository($db);
|
$repo = new Repository($db);
|
||||||
|
|
||||||
$allowedTokens = $repo->getAllowedUserTokens();
|
$allowedTokens = $repo->getAllowedUserTokens();
|
||||||
file_put_contents($logFile, "allowedTokens: " . json_encode($allowedTokens) . "\n", FILE_APPEND);
|
|
||||||
|
|
||||||
if (empty($allowedTokens)) {
|
if (empty($allowedTokens)) {
|
||||||
file_put_contents($logFile, "First user, adding to allowed tokens\n", FILE_APPEND);
|
|
||||||
$repo->setAllowedUserTokens([$userToken]);
|
$repo->setAllowedUserTokens([$userToken]);
|
||||||
} elseif (!in_array($userToken, $allowedTokens, true)) {
|
} elseif (!in_array($userToken, $allowedTokens, true)) {
|
||||||
$_SESSION['auth_error'] = 'Your Jakach account is not authorized to access this system. Contact an administrator.';
|
$_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);
|
header('Location: ' . $errorRedirect);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
$_SESSION['loggedin'] = true;
|
$_SESSION['loggedin'] = true;
|
||||||
$_SESSION['username'] = $data['username'] ?? 'unknown';
|
$_SESSION['username'] = $data['username'] ?? 'unknown';
|
||||||
$_SESSION['id'] = $data['id'] ?? '';
|
$_SESSION['id'] = $data['id'] ?? '';
|
||||||
@@ -86,8 +83,6 @@ $_SESSION['telegram_id'] = $data['telegram_id'] ?? '';
|
|||||||
$_SESSION['user_token'] = $userToken;
|
$_SESSION['user_token'] = $userToken;
|
||||||
unset($_SESSION['auth_error']);
|
unset($_SESSION['auth_error']);
|
||||||
|
|
||||||
file_put_contents($logFile, "Session set, redirecting to: $errorRedirect\n", FILE_APPEND);
|
$redirect = isSafeRedirect($_GET['redirect'] ?? '') ? $_GET['redirect'] : '/';
|
||||||
|
|
||||||
$redirect = $_GET['redirect'] ?? '/';
|
|
||||||
header('Location: ' . $redirect);
|
header('Location: ' . $redirect);
|
||||||
exit;
|
exit;
|
||||||
@@ -20,6 +20,7 @@ class AuthMiddleware
|
|||||||
'lifetime' => 86400 * 7,
|
'lifetime' => 86400 * 7,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
|
'secure' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
]);
|
]);
|
||||||
session_start();
|
session_start();
|
||||||
@@ -29,6 +30,10 @@ class AuthMiddleware
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
$allowedTokens = $this->repo->getAllowedUserTokens();
|
$allowedTokens = $this->repo->getAllowedUserTokens();
|
||||||
$userToken = $_SESSION['user_token'] ?? '';
|
$userToken = $_SESSION['user_token'] ?? '';
|
||||||
|
|
||||||
@@ -41,6 +46,15 @@ class AuthMiddleware
|
|||||||
'username' => $_SESSION['username'] ?? 'unknown',
|
'username' => $_SESSION['username'] ?? 'unknown',
|
||||||
'user_token' => $userToken,
|
'user_token' => $userToken,
|
||||||
'email' => $_SESSION['email'] ?? '',
|
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+36
-14
@@ -33,7 +33,7 @@ class Router
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/ingest'];
|
$publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/auth/csrf', '/ingest'];
|
||||||
$isPublic = false;
|
$isPublic = false;
|
||||||
foreach ($publicPaths as $pp) {
|
foreach ($publicPaths as $pp) {
|
||||||
if ($path === $pp || str_starts_with($path, $pp . '/')) {
|
if ($path === $pp || str_starts_with($path, $pp . '/')) {
|
||||||
@@ -54,31 +54,33 @@ class Router
|
|||||||
$result = match (true) {
|
$result = match (true) {
|
||||||
$path === '/health' && $method === 'GET' => ['status' => 'ok'],
|
$path === '/health' && $method === 'GET' => ['status' => 'ok'],
|
||||||
|
|
||||||
|
$path === '/auth/csrf' && $method === 'GET' => $this->csrfToken(),
|
||||||
|
|
||||||
$path === '/ingest' && $method === 'POST' => $this->ingest(),
|
$path === '/ingest' && $method === 'POST' => $this->ingest(),
|
||||||
|
|
||||||
$path === '/auth/me' && $method === 'GET' => $this->getMe(),
|
$path === '/auth/me' && $method === 'GET' => $this->getMe(),
|
||||||
$path === '/auth/logout' && $method === 'POST' => $this->logout(),
|
$path === '/auth/logout' && $method === 'POST' => $this->logout(),
|
||||||
|
|
||||||
$path === '/sources' && $method === 'GET' => $this->repo->getSources(),
|
$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'
|
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'
|
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 === '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'
|
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'
|
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' && $method === 'GET' => $this->getAlerts(),
|
||||||
$path === '/alerts/search' && $method === 'GET' => $this->searchAlerts(),
|
$path === '/alerts/search' && $method === 'GET' => $this->searchAlerts(),
|
||||||
preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST'
|
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'
|
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'
|
preg_match('#^/alerts/counts$#', $path) && $method === 'GET'
|
||||||
=> $this->repo->getAlertCounts(),
|
=> $this->repo->getAlertCounts(),
|
||||||
|
|
||||||
@@ -87,19 +89,19 @@ class Router
|
|||||||
$path === '/config/allowed_tokens' && $method === 'GET'
|
$path === '/config/allowed_tokens' && $method === 'GET'
|
||||||
=> ['tokens' => $this->repo->getAllowedUserTokens()],
|
=> ['tokens' => $this->repo->getAllowedUserTokens()],
|
||||||
$path === '/config/allowed_tokens' && $method === 'PUT'
|
$path === '/config/allowed_tokens' && $method === 'PUT'
|
||||||
=> $this->updateAllowedTokens(),
|
=> $this->requireCsrf(fn() => $this->updateAllowedTokens()),
|
||||||
|
|
||||||
$path === '/config/telegram' && $method === 'GET'
|
$path === '/config/telegram' && $method === 'GET'
|
||||||
=> $this->getTelegramConfig(),
|
=> $this->getTelegramConfig(),
|
||||||
$path === '/config/telegram' && $method === 'PUT'
|
$path === '/config/telegram' && $method === 'PUT'
|
||||||
=> $this->updateTelegramConfig(),
|
=> $this->requireCsrf(fn() => $this->updateTelegramConfig()),
|
||||||
|
|
||||||
$path === '/false-positives' && $method === 'GET'
|
$path === '/false-positives' && $method === 'GET'
|
||||||
=> $this->getFalsePositives(),
|
=> $this->getFalsePositives(),
|
||||||
$path === '/false-positives' && $method === 'POST'
|
$path === '/false-positives' && $method === 'POST'
|
||||||
=> $this->createFalsePositive(),
|
=> $this->requireCsrf(fn() => $this->createFalsePositive()),
|
||||||
preg_match('#^/false-positives/(\d+)$#', $path, $m) && $method === 'DELETE'
|
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),
|
default => throw new \RuntimeException('Not found', 404),
|
||||||
};
|
};
|
||||||
@@ -309,8 +311,13 @@ class Router
|
|||||||
|
|
||||||
private function getTelegramConfig(): array
|
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 [
|
return [
|
||||||
'bot_token' => $this->repo->getConfig('telegram_bot_token', ''),
|
'bot_token' => $token,
|
||||||
|
'bot_token_masked' => $masked,
|
||||||
'chat_id' => $this->repo->getConfig('telegram_chat_id', ''),
|
'chat_id' => $this->repo->getConfig('telegram_chat_id', ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -347,4 +354,19 @@ class Router
|
|||||||
$this->repo->deleteFalsePositive($id);
|
$this->repo->deleteFalsePositive($id);
|
||||||
return ['status' => 'deleted', 'id' => $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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +157,22 @@ $this->pdo->exec("
|
|||||||
SELECT id, line, source_name FROM log_entries
|
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("
|
$this->pdo->exec("
|
||||||
CREATE TABLE IF NOT EXISTS false_positives (
|
CREATE TABLE IF NOT EXISTS false_positives (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ class FileWatcher
|
|||||||
private array $patterns = [];
|
private array $patterns = [];
|
||||||
private int $checkInterval;
|
private int $checkInterval;
|
||||||
|
|
||||||
|
private const ALLOWED_DIRS = [
|
||||||
|
'/collect',
|
||||||
|
'/var/log',
|
||||||
|
'/app/logs',
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private \Closure $onLine,
|
private \Closure $onLine,
|
||||||
int $checkInterval = 500000,
|
int $checkInterval = 500000,
|
||||||
@@ -18,6 +24,19 @@ class FileWatcher
|
|||||||
$this->checkInterval = $checkInterval;
|
$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
|
public function watch(LogSource $source): void
|
||||||
{
|
{
|
||||||
if ($source->type !== LogSourceType::File) {
|
if ($source->type !== LogSourceType::File) {
|
||||||
@@ -26,6 +45,11 @@ class FileWatcher
|
|||||||
|
|
||||||
$path = $source->address;
|
$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, '?')) {
|
if (str_contains($path, '*') || str_contains($path, '?')) {
|
||||||
$dir = dirname($path);
|
$dir = dirname($path);
|
||||||
$pattern = basename($path);
|
$pattern = basename($path);
|
||||||
@@ -48,7 +72,7 @@ class FileWatcher
|
|||||||
|
|
||||||
private function scanPattern(int $sourceId, string $dir, string $pattern): void
|
private function scanPattern(int $sourceId, string $dir, string $pattern): void
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir) || !$this->isPathSafe($dir)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$files = glob($dir . '/' . $pattern);
|
$files = glob($dir . '/' . $pattern);
|
||||||
@@ -56,6 +80,7 @@ class FileWatcher
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
if (!$this->isPathSafe($file)) continue;
|
||||||
$key = $sourceId . ':' . $file;
|
$key = $sourceId . ':' . $file;
|
||||||
if (!isset($this->handles[$key])) {
|
if (!isset($this->handles[$key])) {
|
||||||
$this->openFile($key, $file);
|
$this->openFile($key, $file);
|
||||||
@@ -66,6 +91,10 @@ class FileWatcher
|
|||||||
|
|
||||||
private function openFile(string|int $key, string $path): void
|
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');
|
$handle = fopen($path, 'r');
|
||||||
if (!$handle) {
|
if (!$handle) {
|
||||||
fprintf(STDERR, "Cannot open file: %s\n", $path);
|
fprintf(STDERR, "Cannot open file: %s\n", $path);
|
||||||
|
|||||||
Reference in New Issue
Block a user