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
+5 -1
View File
@@ -5,12 +5,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpng-dev libjpeg-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
&& a2enmod rewrite \
&& a2enmod rewrite headers \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache-recommended.ini
COPY docker/php/security.ini /usr/local/etc/php/conf.d/zz-security.ini
COPY docker/php/apache-wms.conf /etc/apache2/conf-available/wms.conf
RUN a2enconf wms
COPY docker/php/entrypoint.sh /usr/local/bin/docker-entrypoint-wms.sh
COPY docker/php/config.php.template /usr/local/etc/wms/config.php.template
COPY docker/php/provision.php /usr/local/etc/wms/provision.php
RUN chmod +x /usr/local/bin/docker-entrypoint-wms.sh
WORKDIR /var/www/html/wms-app
+22
View File
@@ -0,0 +1,22 @@
# Apache hardening for the wms-app container.
# No version banners in headers or error pages.
ServerTokens Prod
ServerSignature Off
TraceEnable Off
# The repository is mounted under the web root; its .htaccess refuses .git, .env,
# deployment folders and app internals, so it must be honoured.
<Directory /var/www/html/wms-app>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Nothing else under the default web root is part of this app.
<Directory /var/www/html>
Options -Indexes
</Directory>
# FORCE_HTTPS from docker-compose is read by the .htaccess redirect rule.
PassEnv FORCE_HTTPS
+10 -4
View File
@@ -2,14 +2,15 @@
//<><><><><><><><> MAIN CONFIG (docker-generated) <><><><><><><><>//
if(true){
$isTest = "master";
$base_url = "/wms-app/";
$server_url = $base_url."app/";
$include_url = $_SERVER['DOCUMENT_ROOT'].$server_url;
$db_server = "db";
$db_user = "root";
$db_pass = "${DB_ROOT_PASSWORD}";
// Least-privilege account created by the entrypoint (docker/php/entrypoint.sh);
// root is only used there and by setup.php.
$db_user = "${DB_APP_USER}";
$db_pass = "${DB_APP_PASSWORD}";
$db_type = "mysql";
$db_database = "wms";
$db_database2 = "wms2";
@@ -58,7 +59,12 @@ $packages = [
],
];
// unique key — used for SMTP password encryption, keep consistent across deploys
// Key for stored SMTP passwords (assets/utils/secret_box.php). Keep it stable
// across deploys: changing it makes saved SMTP passwords unreadable.
if (!defined('APP_SECRET_KEY')) {
define('APP_SECRET_KEY', '${APP_SECRET_KEY}');
}
// Legacy fixed key: only used to read SMTP passwords saved before APP_SECRET_KEY.
$pinkey = "wms";
$SMTP = [];
+25 -2
View File
@@ -10,12 +10,32 @@ CONFIG=$APP_DIR/app/config.php
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
export OTP_REQUIRED
# An empty EMIT_SECRET would let anyone call Node's /emit and the PHP cron
# endpoints, so refuse to start without one.
if [ -z "$EMIT_SECRET" ]; then
echo "[entrypoint] ERROR: EMIT_SECRET is empty. Set it in .env (docker/init-env.sh generates one)." >&2
exit 1
fi
if [ -z "$APP_SECRET_KEY" ]; then
echo "[entrypoint] WARNING: APP_SECRET_KEY is not set; SMTP passwords stay in the legacy fixed-key format."
fi
# The app connects as a least-privilege account (see provision.php). Without
# DB_APP_PASSWORD it keeps connecting as root, as before.
: "${DB_APP_USER:=wms_app}"
if [ -z "$DB_APP_PASSWORD" ]; then
echo "[entrypoint] WARNING: DB_APP_PASSWORD is not set; the app connects as root. Re-run docker/init-env.sh to add it."
DB_APP_USER=root
DB_APP_PASSWORD=$DB_ROOT_PASSWORD
fi
export DB_APP_USER DB_APP_PASSWORD APP_SECRET_KEY CONFIG
# Generate app/config.php from template on first run only.
# Restrict envsubst to known placeholders so it never touches the app's own
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
if [ ! -f "$CONFIG" ]; then
echo "[entrypoint] generating app/config.php"
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
envsubst '${DB_APP_USER} ${DB_APP_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED} ${APP_SECRET_KEY}' \
< /usr/local/etc/wms/config.php.template > "$CONFIG"
fi
@@ -45,7 +65,10 @@ until mysqladmin ping -h db -u root -p"$DB_ROOT_PASSWORD" --silent 2>/dev/null;
sleep 2
done
php /usr/local/etc/wms/provision.php
# setup.php creates databases and tables, so it runs as root, not the app account.
echo "[entrypoint] running setup.php (idempotent schema sync)"
php "$APP_DIR/setup.php" || true
DB_SETUP_USER=root DB_SETUP_PASSWORD="$DB_ROOT_PASSWORD" php "$APP_DIR/setup.php" || true
exec "$@"
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* provision.php — run by the container entrypoint before setup.php (CLI only).
*
* 1. Creates/updates the least-privilege database account the app connects as
* (DML + CREATE/INDEX on wms and wms2 only — the app creates td_stock_<id>
* tables with CREATE TABLE … LIKE; no DROP, ALTER, GRANT or other databases).
* 2. Brings an existing app/config.php (generated once, never regenerated) onto
* that account and adds APP_SECRET_KEY if it is missing.
*
* All values come from the environment and are passed to MariaDB as bound
* parameters or written with var_export(), so no password is placed on a
* command line or interpolated into SQL or PHP source.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
$root_pass = (string)getenv('DB_ROOT_PASSWORD');
$app_user = (string)getenv('DB_APP_USER');
$app_pass = (string)getenv('DB_APP_PASSWORD');
$secret = (string)getenv('APP_SECRET_KEY');
$config = (string)getenv('CONFIG');
// ── 1. Database account ──────────────────────────────────────────────────────
if ($app_user !== 'root') {
if (!preg_match('/^[A-Za-z0-9_]{1,32}$/', $app_user)) {
fwrite(STDERR, "[provision] DB_APP_USER must be letters, digits or _\n");
exit(1);
}
$pdo = new PDO('mysql:host=db', 'root', $root_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE DATABASE IF NOT EXISTS `wms`');
$pdo->exec('CREATE DATABASE IF NOT EXISTS `wms2`');
$pdo->prepare("CREATE USER IF NOT EXISTS ?@'%' IDENTIFIED BY ?")->execute([$app_user, $app_pass]);
$pdo->prepare("ALTER USER ?@'%' IDENTIFIED BY ?")->execute([$app_user, $app_pass]);
foreach (['wms', 'wms2'] as $db) {
$pdo->exec("GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, INDEX, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE"
. " ON `{$db}`.* TO " . $pdo->quote($app_user) . "@'%'");
}
echo "[provision] database account {$app_user} is ready\n";
}
// ── 2. Existing config.php ───────────────────────────────────────────────────
if ($config === '' || !is_file($config)) {
exit(0);
}
$src = file_get_contents($config);
$orig = $src;
$set = function (string $var, string $value) use (&$src) {
$src = preg_replace_callback(
'/^(\s*\$' . $var . '\s*=\s*)[^;]*;/m',
fn ($m) => $m[1] . var_export($value, true) . ';',
$src,
1
);
};
$set('db_user', $app_user);
$set('db_pass', $app_pass);
if ($secret !== '' && strpos($src, 'APP_SECRET_KEY') === false) {
$src = preg_replace('/\?>\s*$/', '', $src);
$src .= "\nif (!defined('APP_SECRET_KEY')) {\n\tdefine('APP_SECRET_KEY', " . var_export($secret, true) . ");\n}\n";
}
if ($src !== $orig) {
file_put_contents($config, $src);
echo "[provision] app/config.php updated (database account / secret key)\n";
}
+11
View File
@@ -0,0 +1,11 @@
; PHP hardening for the wms-app container.
expose_php = Off
display_errors = Off
display_startup_errors = Off
log_errors = On
; Session defaults; app/session.php sets the same per request (plus Secure over HTTPS).
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax