Extra security for php part of mailstats web
This commit is contained in:
7
root/etc/mailstats/db.php
Normal file
7
root/etc/mailstats/db.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'host' => 'localhost',
|
||||||
|
'user' => 'mailstats', //Should be mailstat-ro
|
||||||
|
'pass' => 'mailstats', //Will be randon strong password
|
||||||
|
'name' => 'mailstats',
|
||||||
|
];
|
@@ -1,102 +1,190 @@
|
|||||||
<?php
|
<?php
|
||||||
// Database configuration
|
// Set security headers (must be sent before output)
|
||||||
$servername = "localhost";
|
header('Content-Type: text/html; charset=UTF-8');
|
||||||
$username = "mailstats";
|
header("Content-Security-Policy: default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; object-src 'none'; frame-ancestors 'none'");
|
||||||
$password = "mailstats";
|
header('X-Content-Type-Options: nosniff');
|
||||||
$dbname = "mailstats";
|
header('Referrer-Policy: no-referrer');
|
||||||
|
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
|
||||||
// Default date to yesterday
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
$date = isset($_GET['date']) ? $_GET['date'] : date('Y-m-d', strtotime('-1 day'));
|
header('Pragma: no-cache');
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
// Default hour to 99 (means all the hours)
|
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||||
$hour = isset($_GET['hour']) ? $_GET['hour'] : 99;
|
|
||||||
|
|
||||||
// Create connection
|
|
||||||
$conn = new mysqli($servername, $username, $password, $dbname);
|
|
||||||
|
|
||||||
// Check connection
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
die("Connection failed: " . $conn->connect_error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare and execute the query
|
// Helper for safe HTML encoding
|
||||||
if ($hour == 99){
|
function e($s) {
|
||||||
$sql = "SELECT * FROM SummaryLogs WHERE Date = ?";
|
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
$stmt = $conn->prepare($sql);
|
}
|
||||||
$stmt->bind_param("s", $date);
|
|
||||||
} else {
|
// Configuration: read DB credentials from environment
|
||||||
$sql = "SELECT * FROM SummaryLogs WHERE Date = ? AND Hour = ?";
|
$servername = getenv('MAILSTATS_DB_HOST') ?: '';
|
||||||
$stmt = $conn->prepare($sql);
|
$username = getenv('MAILSTATS_DB_USER') ?: '';
|
||||||
$stmt->bind_param("si", $date, $hour);
|
$password = getenv('MAILSTATS_DB_PASS') ?: '';
|
||||||
|
$dbname = getenv('MAILSTATS_DB_NAME') ?: '';
|
||||||
|
|
||||||
|
// Otherwise try config in /etc/mailstats
|
||||||
|
if ($username === '' || $password === '' || $dbname === '') {
|
||||||
|
$cfgPath = '/etc/mailstats/db.php';
|
||||||
|
if (is_readable($cfgPath)) {
|
||||||
|
$cfg = include $cfgPath;
|
||||||
|
$servername = $cfg['host'] ?? $servername ?: 'localhost';
|
||||||
|
$username = $cfg['user'] ?? $username;
|
||||||
|
$password = $cfg['pass'] ?? $password;
|
||||||
|
$dbname = $cfg['name'] ?? $dbname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail fast if credentials are not provided via environment
|
||||||
|
if ($username === '' || $password === '' || $dbname === '') {
|
||||||
|
error_log('Configuration error: DB credentials not set via environment.');
|
||||||
|
http_response_code(500);
|
||||||
|
exit('Service temporarily unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robust input handling
|
||||||
|
$defaultDate = date('Y-m-d', strtotime('-1 day'));
|
||||||
|
$date = isset($_GET['date']) ? $_GET['date'] : $defaultDate;
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('Invalid date');
|
||||||
|
}
|
||||||
|
|
||||||
|
// hour: allow 0–23 or special 99 meaning “all hours”
|
||||||
|
$hour = isset($_GET['hour']) ? filter_var($_GET['hour'], FILTER_VALIDATE_INT) : 99;
|
||||||
|
if ($hour === false || ($hour !== 99 && ($hour < 0 || $hour > 23))) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('Invalid hour');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
$page = isset($_GET['page']) ? filter_var($_GET['page'], FILTER_VALIDATE_INT) : 1;
|
||||||
|
if ($page === false || $page < 1) { $page = 1; }
|
||||||
|
$pageSize = isset($_GET['page_size']) ? filter_var($_GET['page_size'], FILTER_VALIDATE_INT) : 50;
|
||||||
|
if ($pageSize === false) { $pageSize = 50; }
|
||||||
|
// Bound page size to prevent huge result sets
|
||||||
|
if ($pageSize < 1) { $pageSize = 1; }
|
||||||
|
if ($pageSize > 100) { $pageSize = 100; }
|
||||||
|
$limit = $pageSize;
|
||||||
|
$offset = ($page - 1) * $pageSize;
|
||||||
|
|
||||||
|
// Use mysqli with exceptions and UTF-8
|
||||||
|
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||||
|
try {
|
||||||
|
$conn = new mysqli($servername, $username, $password, $dbname);
|
||||||
|
$conn->set_charset('utf8mb4');
|
||||||
|
} catch (mysqli_sql_exception $e) {
|
||||||
|
error_log('DB connect failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
exit('Service temporarily unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WHERE clause and bind parameters safely
|
||||||
|
$where = 'Date = ?';
|
||||||
|
$bindTypesCount = 's';
|
||||||
|
$bindValuesCount = [$date];
|
||||||
|
|
||||||
|
if ($hour !== 99) {
|
||||||
|
$where .= ' AND Hour = ?';
|
||||||
|
$bindTypesCount .= 'i';
|
||||||
|
$bindValuesCount[] = $hour;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count query for total rows (for display/pagination info)
|
||||||
|
try {
|
||||||
|
$sqlCount = "SELECT COUNT(*) AS total FROM SummaryLogs WHERE $where";
|
||||||
|
$stmtCount = $conn->prepare($sqlCount);
|
||||||
|
$stmtCount->bind_param($bindTypesCount, ...$bindValuesCount);
|
||||||
|
$stmtCount->execute();
|
||||||
|
$resultCount = $stmtCount->get_result();
|
||||||
|
$rowCount = $resultCount->fetch_assoc();
|
||||||
|
$totalRows = (int)$rowCount['total'];
|
||||||
|
$stmtCount->close();
|
||||||
|
} catch (mysqli_sql_exception $e) {
|
||||||
|
error_log('Count query failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
exit('Service temporarily unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data query with ORDER and LIMIT/OFFSET
|
||||||
|
try {
|
||||||
|
$sql = "SELECT id, logData FROM SummaryLogs WHERE $where ORDER BY id DESC LIMIT ? OFFSET ?";
|
||||||
|
// Bind types: existing where types + limit (i) + offset (i)
|
||||||
|
$bindTypesData = $bindTypesCount . 'ii';
|
||||||
|
$bindValuesData = $bindValuesCount;
|
||||||
|
$bindValuesData[] = $limit;
|
||||||
|
$bindValuesData[] = $offset;
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
$stmt->bind_param($bindTypesData, ...$bindValuesData);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
} catch (mysqli_sql_exception $e) {
|
||||||
|
error_log('Data query failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
exit('Service temporarily unavailable.');
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
|
||||||
$result = $stmt->get_result();
|
|
||||||
$result_count = $result->num_rows;
|
|
||||||
|
|
||||||
function generateLogDataTable($logData) {
|
function generateLogDataTable($logData) {
|
||||||
$data = json_decode($logData, true);
|
// Defensive decode with substitution for invalid UTF-8
|
||||||
if (is_null($data)) {
|
$data = json_decode($logData, true, 512, JSON_INVALID_UTF8_SUBSTITUTE);
|
||||||
return "Invalid JSON data";
|
|
||||||
}
|
|
||||||
|
|
||||||
//// Remove entries with the key "logterse"
|
|
||||||
//if (isset($data['logterse'])) {
|
|
||||||
//unset($data['logterse']);
|
|
||||||
//}
|
|
||||||
|
|
||||||
// Remove entries with the key "logterse" and remove entries with empty values
|
if (!is_array($data)) {
|
||||||
|
return '<em>Invalid JSON data</em>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove entries with key 'logterse' and entries with empty values
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
if ($key === 'logterse' || empty($value)) {
|
if ($key === 'logterse' || $value === '' || $value === null) {
|
||||||
unset($data[$key]);
|
unset($data[$key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle adjacent duplicates by merging keys
|
// Merge adjacent duplicates by value
|
||||||
$mergedData = [];
|
$mergedData = [];
|
||||||
$previousValue = null;
|
$previousValue = null;
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
if ($value === $previousValue) {
|
// Normalize non-scalar values for display
|
||||||
// Merge the current key with the previous key
|
if (is_array($value) || is_object($value)) {
|
||||||
|
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
$valueStr = (string)$value;
|
||||||
|
|
||||||
|
if ($valueStr === $previousValue) {
|
||||||
end($mergedData);
|
end($mergedData);
|
||||||
$lastKey = key($mergedData);
|
$lastKey = key($mergedData);
|
||||||
$newKey = "$lastKey/$key";
|
$newKey = $lastKey . '/' . $key;
|
||||||
$mergedData[$newKey] = $value;
|
$mergedData[$newKey] = $valueStr;
|
||||||
// Remove the old entry
|
|
||||||
unset($mergedData[$lastKey]);
|
unset($mergedData[$lastKey]);
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, add a new entry
|
$mergedData[$key] = $valueStr;
|
||||||
$mergedData[$key] = $value;
|
}
|
||||||
|
$previousValue = $valueStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional truncation to keep rendering safe
|
||||||
|
$maxValueLen = 500;
|
||||||
|
foreach ($mergedData as $k => $v) {
|
||||||
|
if (mb_strlen($v, 'UTF-8') > $maxValueLen) {
|
||||||
|
$mergedData[$k] = mb_substr($v, 0, $maxValueLen, 'UTF-8') . '…';
|
||||||
}
|
}
|
||||||
$previousValue = $value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$keys = array_keys($mergedData);
|
$keys = array_keys($mergedData);
|
||||||
$values = array_values($mergedData);
|
$values = array_values($mergedData);
|
||||||
|
|
||||||
$output = '<table class="stripes" style="border-collapse: collapse; width:95%;overflow-x:auto; margin: 0.6% auto 0.6% auto;"><tbody>';
|
$output = '<table class="stripes" style="border-collapse: collapse; width:95%; overflow-x:auto; margin: 0.6% auto 0.6% auto;"><tbody>';
|
||||||
#$output = '<table class="stripes" style="border-collapse: collapse; width:95%;overflow-x:auto; margin:2%"><tbody>';
|
|
||||||
|
|
||||||
// Divide keys and values into sets of 6
|
// Divide keys and values into sets of 6
|
||||||
$chunks = array_chunk($keys, 6);
|
$chunks = array_chunk($keys, 6);
|
||||||
foreach ($chunks as $chunkIndex => $chunk) {
|
foreach ($chunks as $chunkIndex => $chunk) {
|
||||||
if ($chunkIndex > 0) {
|
|
||||||
// Add spacing between different sets
|
|
||||||
#$output .= '<tr><td colspan="6" style="height: 1em;"></td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$output .= '<tr>';
|
$output .= '<tr>';
|
||||||
foreach ($chunk as $key) {
|
foreach ($chunk as $key) {
|
||||||
$output .= '<th>' . htmlspecialchars($key) . '</th>';
|
$output .= '<th>' . e($key) . '</th>';
|
||||||
}
|
}
|
||||||
$output .= '</tr><tr>';
|
$output .= '</tr><tr>';
|
||||||
foreach ($chunk as $i => $key) {
|
foreach ($chunk as $i => $key) {
|
||||||
$val = htmlspecialchars($values[$chunkIndex * 6+ $i]);
|
$val = $values[$chunkIndex * 6 + $i];
|
||||||
if ($key == 'id'){
|
$output .= '<td>' . e($val) . '</td>';
|
||||||
$output .= '<td>' . "<a href='./ShowDetailedLogs.php?id=".$val."'</a>".$val."</td>";
|
|
||||||
} else {
|
|
||||||
$output .= '<td>' . $val . '</td>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$output .= '</tr>';
|
$output .= '</tr>';
|
||||||
}
|
}
|
||||||
@@ -106,61 +194,87 @@ function generateLogDataTable($logData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel='stylesheet' type='text/css' href='css/mailstats.css' />
|
|
||||||
<title>Summary Logs</title>
|
<title>Summary Logs</title>
|
||||||
<!-- <style>
|
<link rel="stylesheet" type="text/css" href="css/mailstats.css" />
|
||||||
table {
|
|
||||||
xxwidth: 100%;
|
|
||||||
xxborder-collapse: collapse;
|
|
||||||
}
|
|
||||||
table, th, td {
|
|
||||||
xxborder: 1px solid black;
|
|
||||||
}
|
|
||||||
th, td {
|
|
||||||
xxpadding: 8px;
|
|
||||||
xxtext-align: left;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
-->
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div style="width:100%;overflow-x:auto;font-size:0.726cqw">"
|
<div style="width:100%; overflow-x:auto; font-size:0.726cqw">
|
||||||
<h1>Summary Logs for Date: <?= htmlspecialchars($date) ?> <?= $hour == 99 ? 'for All Hours' : 'and Hour: ' . htmlspecialchars($hour) ?></h1>
|
<h1>
|
||||||
<h3>Found <?= $result_count ?> records.</h3>
|
Summary Logs for Date: <?= e($date) ?>
|
||||||
<table style="border-collapse:collapse;width:98%">
|
<?= $hour === 99 ? ' (All Hours)' : ' at Hour: ' . e($hour) ?>
|
||||||
<thead>
|
</h1>
|
||||||
<tr>
|
<?php
|
||||||
<th>Id</th>
|
$startRow = $totalRows > 0 ? ($offset + 1) : 0;
|
||||||
<!--<th>Date</th>-->
|
$endRow = min($offset + $limit, $totalRows);
|
||||||
<!--<th>Hour</th>-->
|
?>
|
||||||
<th>Log Data</th>
|
<h3>Found <?= e($totalRows) ?> records. Showing <?= e($startRow) ?>–<?= e($endRow) ?>.</h3>
|
||||||
</tr>
|
|
||||||
</thead>
|
<table style="border-collapse:collapse; width:98%">
|
||||||
<tbody>
|
<thead>
|
||||||
<?php if ($result->num_rows > 0): ?>
|
|
||||||
<?php while($row = $result->fetch_assoc()): ?>
|
|
||||||
<tr>
|
|
||||||
<td><?= htmlspecialchars($row['id']) ?></td>
|
|
||||||
<td><?= generateLogDataTable($row['logData']) ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endwhile; ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4">No records found for the specified date and hour.</td>
|
<th>Id</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Log Data</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if ($result && $result->num_rows > 0): ?>
|
||||||
|
<?php while ($row = $result->fetch_assoc()): ?>
|
||||||
|
<?php
|
||||||
|
$id = (int)$row['id'];
|
||||||
|
$detailUrl = './ShowDetailedLogs.php?id=' . rawurlencode((string)$id);
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($id) ?></td>
|
||||||
|
<td><a href="<?= e($detailUrl) ?>">View details</a></td>
|
||||||
|
<td><?= generateLogDataTable($row['logData']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endwhile; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3">No records found for the specified date and hour.</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// Simple pagination links
|
||||||
|
$baseParams = [
|
||||||
|
'date' => $date,
|
||||||
|
'hour' => $hour,
|
||||||
|
'page_size' => $pageSize
|
||||||
|
];
|
||||||
|
$prevPage = $page > 1 ? $page - 1 : null;
|
||||||
|
$nextPage = ($offset + $limit) < $totalRows ? $page + 1 : null;
|
||||||
|
?>
|
||||||
|
<div style="margin-top: 1em;">
|
||||||
|
<?php if ($prevPage !== null): ?>
|
||||||
|
<?php
|
||||||
|
$paramsPrev = $baseParams; $paramsPrev['page'] = $prevPage;
|
||||||
|
$urlPrev = '?' . http_build_query($paramsPrev, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
?>
|
||||||
|
<a href="<?= e($urlPrev) ?>">« Previous</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
|
||||||
</table>
|
<?php if ($nextPage !== null): ?>
|
||||||
|
<?php
|
||||||
|
$paramsNext = $baseParams; $paramsNext['page'] = $nextPage;
|
||||||
|
$urlNext = '?' . http_build_query($paramsNext, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
?>
|
||||||
|
<?php if ($prevPage !== null): ?> | <?php endif; ?>
|
||||||
|
<a href="<?= e($urlNext) ?>">Next »</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
// Close the connection
|
// Clean up
|
||||||
$stmt->close();
|
if (isset($stmt) && $stmt instanceof mysqli_stmt) { $stmt->close(); }
|
||||||
$conn->close();
|
if (isset($conn) && $conn instanceof mysqli) { $conn->close(); }
|
||||||
?>
|
?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@@ -6,7 +6,7 @@ Summary: Daily mail statistics for SME Server
|
|||||||
%define name smeserver-mailstats
|
%define name smeserver-mailstats
|
||||||
Name: %{name}
|
Name: %{name}
|
||||||
%define version 11.1
|
%define version 11.1
|
||||||
%define release 4
|
%define release 5
|
||||||
Version: %{version}
|
Version: %{version}
|
||||||
Release: %{release}%{?dist}
|
Release: %{release}%{?dist}
|
||||||
License: GPL
|
License: GPL
|
||||||
@@ -35,6 +35,11 @@ A script that via cron.d e-mails mail statistics to admin on a daily basis.
|
|||||||
See http://www.contribs.org/bugzilla/show_bug.cgi?id=819
|
See http://www.contribs.org/bugzilla/show_bug.cgi?id=819
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Tue Sep 02 2025 Brian Read <brianr@koozali.org> 11.1-5.sme
|
||||||
|
- Speed up Journal access [SME: 13121]
|
||||||
|
- Fix missing blacklist URL [SME: 13121]
|
||||||
|
|
||||||
|
|
||||||
* Mon Sep 01 2025 Brian Read <brianr@koozali.org> 11.1-4.sme
|
* Mon Sep 01 2025 Brian Read <brianr@koozali.org> 11.1-4.sme
|
||||||
- More fixes for Journal bytes instead of characters [SME: 13117]
|
- More fixes for Journal bytes instead of characters [SME: 13117]
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user