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
|
||||
// Database configuration
|
||||
$servername = "localhost";
|
||||
$username = "mailstats";
|
||||
$password = "mailstats";
|
||||
$dbname = "mailstats";
|
||||
|
||||
// Default date to yesterday
|
||||
$date = isset($_GET['date']) ? $_GET['date'] : date('Y-m-d', strtotime('-1 day'));
|
||||
|
||||
// Default hour to 99 (means all the hours)
|
||||
$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);
|
||||
// Set security headers (must be sent before output)
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
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'");
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
// Prepare and execute the query
|
||||
if ($hour == 99){
|
||||
$sql = "SELECT * FROM SummaryLogs WHERE Date = ?";
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param("s", $date);
|
||||
} else {
|
||||
$sql = "SELECT * FROM SummaryLogs WHERE Date = ? AND Hour = ?";
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param("si", $date, $hour);
|
||||
// Helper for safe HTML encoding
|
||||
function e($s) {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
// Configuration: read DB credentials from environment
|
||||
$servername = getenv('MAILSTATS_DB_HOST') ?: '';
|
||||
$username = getenv('MAILSTATS_DB_USER') ?: '';
|
||||
$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) {
|
||||
$data = json_decode($logData, true);
|
||||
if (is_null($data)) {
|
||||
return "Invalid JSON data";
|
||||
// Defensive decode with substitution for invalid UTF-8
|
||||
$data = json_decode($logData, true, 512, JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return '<em>Invalid JSON data</em>';
|
||||
}
|
||||
|
||||
//// 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
|
||||
// Remove entries with key 'logterse' and entries with empty values
|
||||
foreach ($data as $key => $value) {
|
||||
if ($key === 'logterse' || empty($value)) {
|
||||
if ($key === 'logterse' || $value === '' || $value === null) {
|
||||
unset($data[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle adjacent duplicates by merging keys
|
||||
// Merge adjacent duplicates by value
|
||||
$mergedData = [];
|
||||
$previousValue = null;
|
||||
foreach ($data as $key => $value) {
|
||||
if ($value === $previousValue) {
|
||||
// Merge the current key with the previous key
|
||||
// Normalize non-scalar values for display
|
||||
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);
|
||||
$lastKey = key($mergedData);
|
||||
$newKey = "$lastKey/$key";
|
||||
$mergedData[$newKey] = $value;
|
||||
// Remove the old entry
|
||||
$newKey = $lastKey . '/' . $key;
|
||||
$mergedData[$newKey] = $valueStr;
|
||||
unset($mergedData[$lastKey]);
|
||||
} else {
|
||||
// Otherwise, add a new entry
|
||||
$mergedData[$key] = $value;
|
||||
$mergedData[$key] = $valueStr;
|
||||
}
|
||||
$previousValue = $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') . '…';
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array_keys($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:2%"><tbody>';
|
||||
$output = '<table class="stripes" style="border-collapse: collapse; width:95%; overflow-x:auto; margin: 0.6% auto 0.6% auto;"><tbody>';
|
||||
|
||||
// Divide keys and values into sets of 6
|
||||
$chunks = array_chunk($keys, 6);
|
||||
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>';
|
||||
foreach ($chunk as $key) {
|
||||
$output .= '<th>' . htmlspecialchars($key) . '</th>';
|
||||
$output .= '<th>' . e($key) . '</th>';
|
||||
}
|
||||
$output .= '</tr><tr>';
|
||||
foreach ($chunk as $i => $key) {
|
||||
$val = htmlspecialchars($values[$chunkIndex * 6+ $i]);
|
||||
if ($key == 'id'){
|
||||
$output .= '<td>' . "<a href='./ShowDetailedLogs.php?id=".$val."'</a>".$val."</td>";
|
||||
} else {
|
||||
$output .= '<td>' . $val . '</td>';
|
||||
}
|
||||
$val = $values[$chunkIndex * 6 + $i];
|
||||
$output .= '<td>' . e($val) . '</td>';
|
||||
}
|
||||
$output .= '</tr>';
|
||||
}
|
||||
@@ -106,61 +194,87 @@ function generateLogDataTable($logData) {
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel='stylesheet' type='text/css' href='css/mailstats.css' />
|
||||
<title>Summary Logs</title>
|
||||
<!-- <style>
|
||||
table {
|
||||
xxwidth: 100%;
|
||||
xxborder-collapse: collapse;
|
||||
}
|
||||
table, th, td {
|
||||
xxborder: 1px solid black;
|
||||
}
|
||||
th, td {
|
||||
xxpadding: 8px;
|
||||
xxtext-align: left;
|
||||
}
|
||||
</style>
|
||||
-->
|
||||
<link rel="stylesheet" type="text/css" href="css/mailstats.css" />
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<h3>Found <?= $result_count ?> records.</h3>
|
||||
<table style="border-collapse:collapse;width:98%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<!--<th>Date</th>-->
|
||||
<!--<th>Hour</th>-->
|
||||
<th>Log Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?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: ?>
|
||||
<div style="width:100%; overflow-x:auto; font-size:0.726cqw">
|
||||
<h1>
|
||||
Summary Logs for Date: <?= e($date) ?>
|
||||
<?= $hour === 99 ? ' (All Hours)' : ' at Hour: ' . e($hour) ?>
|
||||
</h1>
|
||||
<?php
|
||||
$startRow = $totalRows > 0 ? ($offset + 1) : 0;
|
||||
$endRow = min($offset + $limit, $totalRows);
|
||||
?>
|
||||
<h3>Found <?= e($totalRows) ?> records. Showing <?= e($startRow) ?>–<?= e($endRow) ?>.</h3>
|
||||
|
||||
<table style="border-collapse:collapse; width:98%">
|
||||
<thead>
|
||||
<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>
|
||||
</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; ?>
|
||||
</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>
|
||||
<?php
|
||||
// Close the connection
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
// Clean up
|
||||
if (isset($stmt) && $stmt instanceof mysqli_stmt) { $stmt->close(); }
|
||||
if (isset($conn) && $conn instanceof mysqli) { $conn->close(); }
|
||||
?>
|
||||
</body>
|
||||
</html>
|
@@ -6,7 +6,7 @@ Summary: Daily mail statistics for SME Server
|
||||
%define name smeserver-mailstats
|
||||
Name: %{name}
|
||||
%define version 11.1
|
||||
%define release 4
|
||||
%define release 5
|
||||
Version: %{version}
|
||||
Release: %{release}%{?dist}
|
||||
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
|
||||
|
||||
%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
|
||||
- More fixes for Journal bytes instead of characters [SME: 13117]
|
||||
|
||||
|
Reference in New Issue
Block a user