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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user