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
+46 -2
View File
@@ -26,8 +26,10 @@ if (!file_exists($config)) {
require $config;
$host = $db_server ?? 'localhost';
$user = $db_user ?? 'root';
$pass = $db_pass ?? '';
// The app's own account may be least-privilege; schema changes need an admin
// account, which the docker entrypoint passes as DB_SETUP_USER / DB_SETUP_PASSWORD.
$user = getenv('DB_SETUP_USER') ?: ($db_user ?? 'root');
$pass = getenv('DB_SETUP_USER') ? (string)getenv('DB_SETUP_PASSWORD') : ($db_pass ?? '');
$db1 = $db_database ?? 'wms';
$db2 = $db_database2 ?? 'wms2';
@@ -225,6 +227,20 @@ CREATE TABLE IF NOT EXISTS `whitelist` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'whitelist');
// Request throttle for the login / OTP / registration endpoints
// (app/assets/utils/rate_limit.php). key_hash is SHA-256 of bucket|key, so raw
// IPs and emails are never stored.
run($pdo, "
CREATE TABLE IF NOT EXISTS `auth_throttle` (
`bucket` varchar(40) NOT NULL,
`key_hash` char(64) NOT NULL,
`window_start` datetime NOT NULL,
`hits` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`bucket`,`key_hash`),
KEY `idx_window_start` (`window_start`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'auth_throttle');
// ─────────────────────────────────────────────────────────────────────────────
// WMS2 (business data) tables
// ─────────────────────────────────────────────────────────────────────────────
@@ -1271,6 +1287,34 @@ foreach (['subtotal', 'discount', 'tax', 'shipping_fee', 'grand_total'] as $col)
run($pdo, "ALTER TABLE `{$db2}`.`td_purchase_order` MODIFY `{$col}` decimal(18,4) NOT NULL DEFAULT 0.0000", "td_purchase_order.{$col} decimal(18,4)");
}
// ── SMTP passwords: legacy fixed key → APP_SECRET_KEY ─────────────────────────
// Rows saved before APP_SECRET_KEY existed are encrypted with the public fixed key
// "wms". Once the deployment sets APP_SECRET_KEY, re-encrypt them (idempotent: rows
// already in the v2 format are skipped).
require_once __DIR__ . '/app/assets/utils/secret_box.php';
if (secret_box_key() === null) {
skip('SMTP passwords: APP_SECRET_KEY not set, left in the legacy format');
} else {
try {
$rows = $pdo->query("SELECT smtp_id, password FROM `{$db1}`.`company_smtp`")->fetchAll(PDO::FETCH_ASSOC);
$upd = $pdo->prepare("UPDATE `{$db1}`.`company_smtp` SET password = :p WHERE smtp_id = :id");
$moved = 0;
foreach ($rows as $row) {
if (!secret_is_legacy((string)$row['password'])) continue;
$plain = secret_decrypt((string)$row['password'], $pinkey ?? 'wms');
if ($plain === false) {
fail("SMTP password for smtp_id {$row['smtp_id']} could not be decrypted; left unchanged");
continue;
}
$upd->execute([':p' => secret_encrypt($plain), ':id' => $row['smtp_id']]);
$moved++;
}
$moved ? ok("SMTP passwords re-encrypted with APP_SECRET_KEY ({$moved})") : skip('SMTP passwords already use APP_SECRET_KEY');
} catch (PDOException $e) {
fail('SMTP password re-encryption: ' . $e->getMessage());
}
}
// ── Summary ───────────────────────────────────────────────────────────────────
$total = $ok_count + $skip_count + $err_count;
echo "\n\033[1m=== Done ===\033[0m\n";