Extra security for php part of mailstats web

This commit is contained in:
2025-09-02 11:23:48 +01:00
parent f86021b8c9
commit 5deb31cd92
3 changed files with 237 additions and 111 deletions

View File

@@ -0,0 +1,7 @@
<?php
return [
'host' => 'localhost',
'user' => 'mailstats', //Should be mailstat-ro
'pass' => 'mailstats', //Will be randon strong password
'name' => 'mailstats',
];

View File

@@ -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 023 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) ?>">&laquo; 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 &raquo;</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>

View File

@@ -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]