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:
@@ -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);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>',
|
||||
|
||||
@@ -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' : ''; ?>"
|
||||
|
||||
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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('');
|
||||
|
||||
Reference in New Issue
Block a user