Harden web root, secrets and realtime auth

- .htaccess: refuse .git, .env, docker, nodejs, sdlc*, scripts, archives and
  app internals; security headers, HSTS over TLS, optional HTTPS redirect
- uploads served through app/file.php to signed-in users only
- Apache/PHP hardening config for the container (ServerTokens, expose_php)
- least-privilege DB account and APP_SECRET_KEY via docker/php/provision.php;
  SMTP passwords re-encrypted with a random IV (secret_box.php)
- Socket.IO rooms from a PHP-signed token; /emit and cron refuse empty secrets
- escape notification text; CLI guards on build scripts; no fixed demo password
This commit is contained in:
Thanakorn
2026-09-24 14:53:40 +07:00
parent e579dd596c
commit ae98dcdcdd
29 changed files with 525 additions and 42 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/../module/mailer.php';
require_once __DIR__ . '/../secret_box.php';
class SmtpManager
{
@@ -173,7 +174,7 @@ class SmtpManager
private function encryptPassword(string $plain): string
{
return openssl_encrypt($plain, $this->method, $this->pinkey, 0, $this->iv);
return secret_encrypt($plain, $this->pinkey);
}
}
?>
+3 -2
View File
@@ -11,8 +11,9 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../dbconn.php';
$secret = $_SERVER['HTTP_X_CRON_SECRET'] ?? '';
if (!defined('NODE_EMIT_SECRET') || $secret !== NODE_EMIT_SECRET) {
// An empty configured secret must never match an empty header.
$secret = (string)($_SERVER['HTTP_X_CRON_SECRET'] ?? '');
if (!defined('NODE_EMIT_SECRET') || NODE_EMIT_SECRET === '' || !hash_equals((string)NODE_EMIT_SECRET, $secret)) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
}
+2 -1
View File
@@ -72,7 +72,8 @@ class mailer{
"input" => $input
]);
return openssl_decrypt(trim($input["data"]), "AES-256-CBC", $input["key"], 0, "1234567890123456" );
require_once __DIR__ . '/../secret_box.php';
return secret_decrypt((string)$input["data"], (string)$input["key"]);
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* secret_box.php — reversible encryption for stored credentials (SMTP passwords).
*
* Format "v2:<base64(iv . ciphertext)>": AES-256-CBC with a random IV per value and
* a key derived from APP_SECRET_KEY (config.php, from the deployment environment).
*
* Older values were encrypted with the fixed key $pinkey ("wms") and a constant IV,
* which anyone reading the source can undo. secret_decrypt() still reads that legacy
* format so existing rows keep working; setup.php re-encrypts them to v2 and every
* save writes v2. Without APP_SECRET_KEY the legacy format is written (and logged)
* so a deployment that has not set the key yet keeps sending mail.
*/
const SECRET_BOX_LEGACY_IV = '1234567890123456';
function secret_box_key(): ?string {
if (!defined('APP_SECRET_KEY') || APP_SECRET_KEY === '') return null;
return hash('sha256', APP_SECRET_KEY, true);
}
function secret_encrypt(string $plain, string $legacy_key = 'wms'): string {
$key = secret_box_key();
if ($key === null) {
error_log('[secret_box] APP_SECRET_KEY is not set; storing a credential in the legacy format.');
return openssl_encrypt($plain, 'AES-256-CBC', $legacy_key, 0, SECRET_BOX_LEGACY_IV);
}
$iv = random_bytes(16);
$ct = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
return 'v2:' . base64_encode($iv . $ct);
}
/** Returns the plain text, or false when the value cannot be decrypted. */
function secret_decrypt(string $stored, string $legacy_key = 'wms') {
$stored = trim($stored);
if (strncmp($stored, 'v2:', 3) === 0) {
$key = secret_box_key();
$raw = base64_decode(substr($stored, 3), true);
if ($key === null || $raw === false || strlen($raw) <= 16) return false;
return openssl_decrypt(substr($raw, 16), 'AES-256-CBC', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16));
}
return openssl_decrypt($stored, 'AES-256-CBC', $legacy_key, 0, SECRET_BOX_LEGACY_IV);
}
/** Whether a stored value still uses the legacy fixed-key format. */
function secret_is_legacy(string $stored): bool {
return $stored !== '' && strncmp(trim($stored), 'v2:', 3) !== 0;
}
+11 -3
View File
@@ -2,13 +2,15 @@
//<><><><><><><><> MAIN CONFIG <><><><><><><><>//
if(true){
$isTest = "master";
$base_url = "/your-app-folder/"; // e.g. "/wms-app/" — folder name under web root
$server_url = $base_url."app/";
$include_url = $_SERVER['DOCUMENT_ROOT'].$server_url;
$db_server = "localhost";
$db_user = "root";
// Use a dedicated account with only SELECT, INSERT, UPDATE, DELETE, CREATE,
// INDEX, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE on wms and wms2 —
// never root (docker/php/provision.php shows the grants).
$db_user = "wms_app";
$db_pass = "YOUR_DB_PASSWORD";
$db_type = "mysql";
$db_database = "wms";
@@ -69,7 +71,13 @@ $packages = [
// unique key — used for SMTP password encryption, keep consistent across deploys
// Key for stored SMTP passwords (assets/utils/secret_box.php): a long random
// string, e.g. `openssl rand -hex 32`. Keep it stable across deploys — changing it
// makes saved SMTP passwords unreadable. Never commit the real value.
if (!defined('APP_SECRET_KEY')) {
define('APP_SECRET_KEY', 'YOUR_APP_SECRET_KEY');
}
// Legacy fixed key: only used to read SMTP passwords saved before APP_SECRET_KEY.
$pinkey = "wms";
$SMTP = [];
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* file.php — serves files from app/uploads/ to signed-in users only.
*
* The root .htaccess rewrites every /app/uploads/<path> request here, so the
* existing <img src=".../uploads/profile/x.png"> URLs keep working but an
* anonymous visitor gets 401 instead of the file. Only the upload types that
* FileUploader accepts are served, and never anything that could execute.
*/
require_once __DIR__ . '/session.php';
if (empty($_SESSION['login_company_id'])) {
http_response_code(401);
exit;
}
// The session is only read from here on; release its lock so pages that load
// many images do not queue behind each other.
session_write_close();
$types = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
];
$base = realpath(__DIR__ . '/uploads');
$rel = (string)($_GET['path'] ?? '');
$file = $base ? realpath($base . '/' . $rel) : false;
// realpath() resolves ../ and symlinks; anything outside uploads/ is refused.
if ($base === false || $file === false || !is_file($file) || strpos($file, $base . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(404);
exit;
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!isset($types[$ext])) {
http_response_code(404);
exit;
}
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: ' . $types[$ext]);
header('Content-Length: ' . filesize($file));
header('Content-Disposition: ' . ($ext === 'pdf' ? 'attachment' : 'inline') . '; filename="' . basename($file) . '"');
header('Cache-Control: private, max-age=3600');
header("Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
header('X-Content-Type-Options: nosniff');
readfile($file);
+29 -5
View File
@@ -7,17 +7,34 @@
</div>
<?php
// Signed socket token: the Node server joins rooms from these claims only, so a
// browser cannot pick another company's room. Must match verifySocketToken() in
// nodejs/server.js (key derived from NODE_EMIT_SECRET, HMAC-SHA256, base64url).
$_socket_token = '';
if (defined('NODE_EMIT_SECRET') && NODE_EMIT_SECRET !== '' && !empty($_SESSION['login_company_id'])) {
$_b64url = fn ($s) => rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
$_payload = $_b64url(json_encode([
'c' => (int)$_SESSION['login_company_id'],
'u' => (int)($_SESSION['login_user_id'] ?? 0),
'r' => (string)($_SESSION['login_role'] ?? 'viewer'),
'exp' => time() + 8 * 3600,
]));
$_socket_key = hash_hmac('sha256', 'socket-token', NODE_EMIT_SECRET, true);
$_socket_token = $_payload . '.' . $_b64url(hash_hmac('sha256', $_payload, $_socket_key, true));
}
?>
<!-- ── Real-time WebSocket connection ──────────────────────────────────────── -->
<!-- Socket.io client is served by the Node.js server itself -->
<script src="<?php echo NODE_PUBLIC_URL; ?>/socket.io/socket.io.js"></script>
<script src="<?php echo htmlspecialchars(NODE_PUBLIC_URL, ENT_QUOTES, 'UTF-8'); ?>/socket.io/socket.io.js"></script>
<script>
(function () {
// company_id is set in include_topbar.php as a JS global
if (typeof company_id === 'undefined' || !company_id) return;
if (typeof io === 'undefined') return;
window._socket = io('<?php echo NODE_PUBLIC_URL; ?>', {
query: { company_id: company_id, user_id: user_id, role: user_role },
window._socket = io(<?php echo json_encode(NODE_PUBLIC_URL, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES); ?>, {
auth: { token: <?php echo json_encode($_socket_token, JSON_HEX_TAG); ?> },
reconnection: true,
reconnectionDelay: 2000,
});
@@ -57,6 +74,13 @@ function show_toast(title, message, type) {
};
var icon = icon_map[type] || icon_map.info;
// Notification text is data, never markup.
var esc = function (v) {
return String(v).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
};
var container = document.getElementById('_toast_container');
if (!container) {
container = document.createElement('div');
@@ -77,8 +101,8 @@ function show_toast(title, message, type) {
' <div class="toast-body d-flex align-items-start gap-2">',
' <i class="ti ' + icon + ' fs-5 mt-1 flex-shrink-0"></i>',
' <div>',
(title ? '<div class="fw-semibold lh-sm">' + title + '</div>' : ''),
(message ? '<div class="small text-muted">' + message + '</div>' : ''),
(title ? '<div class="fw-semibold lh-sm">' + esc(title) + '</div>' : ''),
(message ? '<div class="small text-muted">' + esc(message) + '</div>' : ''),
' </div>',
' </div>',
' <button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>',
+5
View File
@@ -1,6 +1,9 @@
<?php
$current_page = basename($_SERVER['PHP_SELF']);
$setting_role = $_SESSION['login_role'] ?? 'viewer';
// Users Access and SMTP are owner/admin only (their API engines enforce it);
// don't offer other roles a page that can only answer "Access denied".
$setting_can_admin = in_array($setting_role, ['owner', 'admin'], true);
?>
<!-- SIDEBAR -->
@@ -36,6 +39,7 @@
</a>
</li>
<?php if ($setting_can_admin): ?>
<li>
<a class="nav-link <?php echo $current_page === 'users.php' ? 'active' : ''; ?>"
href="<?php echo $server_url?>setting/users.php">
@@ -51,6 +55,7 @@
<span class="nav-text">SMTP Setting</span>
</a>
</li>
<?php endif; ?>
<li>
<a class="nav-link <?php echo $current_page === 'system_config.php' ? 'active' : ''; ?>"
+8 -2
View File
@@ -359,13 +359,19 @@ function _render_notif_list() {
el.innerHTML = '<div class="text-center text-muted small py-4">No notifications</div>';
return;
}
// Notification text arrives over the socket: escape it, never render it as markup.
var esc = function (v) {
return String(v).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
};
el.innerHTML = _notif_items.map(function(n) {
var icon = _notif_icon_map[n.type] || _notif_icon_map.info;
return '<div class="d-flex align-items-start gap-2 px-3 py-2 border-bottom">' +
'<i class="ti ' + icon + ' fs-5 flex-shrink-0 mt-1"></i>' +
'<div class="flex-grow-1 overflow-hidden">' +
(n.title ? '<div class="fw-semibold small text-truncate">' + n.title + '</div>' : '') +
(n.message ? '<div class="small text-muted text-truncate">' + n.message + '</div>' : '') +
(n.title ? '<div class="fw-semibold small text-truncate">' + esc(n.title) + '</div>' : '') +
(n.message ? '<div class="small text-muted text-truncate">' + esc(n.message) + '</div>' : '') +
'<div class="small text-muted opacity-75 mt-1">' + n.time + '</div>' +
'</div></div>';
}).join('');