diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..ee92cda --- /dev/null +++ b/.htaccess @@ -0,0 +1,53 @@ +# wms-app — web server rules for the repository root. +# +# The whole repository sits under the web root (/wms-app/), so everything that is +# not part of the running app must be refused here: git history, .env files, +# deployment and build folders, SDLC documents, the Node server source, CLI-only +# PHP scripts and library internals. Needs AllowOverride All (docker/php/apache-wms.conf +# enables it for the container) plus mod_rewrite and mod_headers. + +Options -Indexes + + +RewriteEngine On + +# HTTP → HTTPS when the deployment says TLS is available (FORCE_HTTPS=true in the +# environment). Honours X-Forwarded-Proto so it also works behind a TLS proxy. +RewriteCond %{ENV:FORCE_HTTPS} ^true$ +RewriteCond %{HTTPS} !=on +RewriteCond %{HTTP:X-Forwarded-Proto} !=https +RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + +# Dotfiles and dot-folders anywhere: .git, .env, .claude, .htaccess, .mcp.json … +RewriteRule (^|/)\. - [R=404,L] + +# Folders that are never served. +RewriteRule ^(nodejs|docker|sdlc|sdlc-delivery|scripts|lib|notes|docs|vendor|node_modules)(/|$) - [R=404,L] + +# Repository files at the root: build/deploy config, CLI scripts, archives, docs. +RewriteRule ^(composer\.(json|lock)|docker-compose\.ya?ml|setup\.php|demo_seed[^/]*\.php)$ - [R=404,L] +RewriteRule \.(zip|tar|gz|tgz|sql|sh|md|log|bak|old|orig|swp|dist|example|ini|yml|yaml|lock|env|pem|key|crt|map)$ - [R=404,L] + +# App internals included by the entry points, never requested directly: config, +# DB connection, shared utilities, manager classes, bundled libraries (PHPMailer +# ships get_oauth_token.php), and the page fragments. +RewriteRule ^app/(config[^/]*\.php|dbconn\.php|preset\.php)$ - [R=404,L] +RewriteRule ^app/assets/utils/ - [R=404,L] +RewriteRule ^app/include_[^/]+\.php$ - [R=404,L] + +# Uploaded files are served through a PHP gate that requires a signed-in session. +RewriteRule ^app/uploads/(.+)$ app/file.php?path=$1 [L,QSA,B] + + + +# Sent on every response (pages, API JSON, static files). Pages add a +# Content-Security-Policy of their own from include_header.php. +Header always set X-Content-Type-Options "nosniff" +Header always set X-Frame-Options "SAMEORIGIN" +Header always set Referrer-Policy "strict-origin-when-cross-origin" +Header always set Permissions-Policy "geolocation=(), microphone=(), payment=(), usb=()" +Header always unset X-Powered-By +Header unset X-Powered-By +# HSTS only means anything over HTTPS; browsers ignore it on plain HTTP. +Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on' || %{HTTP:X-Forwarded-Proto} == 'https'" + diff --git a/app/assets/utils/classes/SmtpManager.php b/app/assets/utils/classes/SmtpManager.php index f01e28a..b41556b 100644 --- a/app/assets/utils/classes/SmtpManager.php +++ b/app/assets/utils/classes/SmtpManager.php @@ -1,5 +1,6 @@ method, $this->pinkey, 0, $this->iv); + return secret_encrypt($plain, $this->pinkey); } } ?> diff --git a/app/assets/utils/cron_auth.php b/app/assets/utils/cron_auth.php index 1930b81..252c1bb 100644 --- a/app/assets/utils/cron_auth.php +++ b/app/assets/utils/cron_auth.php @@ -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'])); } diff --git a/app/assets/utils/module/mailer.php b/app/assets/utils/module/mailer.php index c53b124..fea5969 100644 --- a/app/assets/utils/module/mailer.php +++ b/app/assets/utils/module/mailer.php @@ -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"]); } diff --git a/app/assets/utils/secret_box.php b/app/assets/utils/secret_box.php new file mode 100644 index 0000000..af0a325 --- /dev/null +++ b/app/assets/utils/secret_box.php @@ -0,0 +1,48 @@ +": 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; +} diff --git a/app/config.example.php b/app/config.example.php index 691b6f4..f2d1c4e 100644 --- a/app/config.example.php +++ b/app/config.example.php @@ -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 = []; diff --git a/app/file.php b/app/file.php new file mode 100644 index 0000000..9ddaa21 --- /dev/null +++ b/app/file.php @@ -0,0 +1,56 @@ + request here, so the + * existing 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); diff --git a/app/include_ending.php b/app/include_ending.php index fbbbd77..fce2fe9 100644 --- a/app/include_ending.php +++ b/app/include_ending.php @@ -7,17 +7,34 @@ + 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)); +} +?> - +