From 214d96ee8d26071b1e15f2cab133844ef36a66fd Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 3 May 2026 21:06:17 +0300 Subject: [PATCH] Security Hardening: Phase 1-3 complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C1: Hash refresh tokens before DB storage (sha256) - C2: Remove JWT_SECRET fallback, fail hard if missing - H1: Enforce HTTP methods per route (405 on mismatch) - H2: CORS with origin whitelist from CORS_ORIGIN env var - H3: Redact sensitive fields (tokens, passwords) from logs - M1: Build HmacMiddleware with replay attack prevention - M2: Fix rate limiter race condition with flock LOCK_EX - M3: Guard dd() — suppressed in production - M4: Remove .env from git tracking, strengthen .gitignore - I1: Add HSTS header (max-age=31536000) --- .env | 46 --------------- .gitignore | 11 +++- app/bootstrap/init.php | 52 ++++++++++++----- app/bootstrap/response.php | 48 ++++++++++------ app/helpers/helpers.php | 12 +++- app/middleware/AuthMiddleware.php | 5 ++ app/middleware/HmacMiddleware.php | 62 ++++++++++++++++++++ app/middleware/RateLimitMiddleware.php | 80 ++++++++++++++++---------- app/modules_app/auth/login.php | 11 +++- app/modules_app/auth/refresh.php | 12 +++- public/index.php | 27 +++++---- 11 files changed, 236 insertions(+), 130 deletions(-) delete mode 100644 .env create mode 100644 app/middleware/HmacMiddleware.php diff --git a/.env b/.env deleted file mode 100644 index ea25aa7..0000000 --- a/.env +++ /dev/null @@ -1,46 +0,0 @@ -APP_NAME="مُصادَق" -APP_ENV=development -APP_URL=http://localhost:8000 -APP_TIMEZONE=Asia/Amman - -# MySQL (CloudPanel managed) -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=musadaqDb -DB_USERNAME=musadaqUser -DB_PASSWORD=FWVG3vx2fhrwUULXa6E4 -DB_CHARSET=utf8mb4 - -# Redis (system service) -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_PASSWORD= - -# JWT -JWT_SECRET=751139b2b6feb81d5a208a22a624a2f13269eef71044d6147484c85c1111c359 -HMAC_SECRET_KEY=6eae97d9aa6c6732c1882a4eb62da79e7530d8a0dc93f7d03b6e80b15c6f9c55 -ENCRYPTION_KEY_B64=0AEcpckd2g6eMA3ofBXRpgrDbV6ExWkB+D1Hl5pE+I0= -JWT_ACCESS_EXPIRY=900 -JWT_REFRESH_EXPIRY=604800 - -# AI Providers -GEMINI_API_KEY= -GEMINI_MODEL=gemini-2.0-flash -OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o - -# JoFotara -JOFOTARA_BASE_URL=https://backend.jofotara.gov.jo/core/invoices -JOFOTARA_ENV=sandbox - -# Email -MAIL_HOST=smtp.mailtrap.io -MAIL_PORT=2525 -MAIL_USERNAME= -MAIL_PASSWORD= -MAIL_FROM=noreply@musadaq.app -MAIL_FROM_NAME="مُصادَق" - -# Storage -STORAGE_PATH=/Users/hamzaaleghwairyeen/development/App/musadeq/storage -UPLOAD_MAX_SIZE=20971520 diff --git a/.gitignore b/.gitignore index ef27cf8..933690a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,21 @@ +# Secrets — NEVER commit these .env +.env.* config/secrets.php + +# Storage — runtime data, not code storage/invoices/ storage/logs/ storage/exports/ +storage/cache/ + +# Dependencies vendor/ +node_modules/ + +# Dev tools scratch.js describe.php .DS_Store .idea/ .vscode/ -node_modules/ diff --git a/app/bootstrap/init.php b/app/bootstrap/init.php index 0562658..6221fe3 100644 --- a/app/bootstrap/init.php +++ b/app/bootstrap/init.php @@ -10,11 +10,11 @@ define('ROOT_PATH', dirname(__DIR__, 2)); define('APP_PATH', ROOT_PATH . '/app'); define('STORAGE_PATH', ROOT_PATH . '/storage'); -// 2. Load Environment Loader & Helpers FIRST +// 2. Load Environment & Helpers FIRST require_once APP_PATH . '/bootstrap/env.php'; require_once APP_PATH . '/helpers/helpers.php'; -// 3. Error Reporting (Secure for production - Now we can use env()) +// 3. Error Reporting (Secure for production) if (env('APP_DEBUG', 'false') === 'true') { error_reporting(E_ALL); ini_set('display_errors', '1'); @@ -23,36 +23,58 @@ if (env('APP_DEBUG', 'false') === 'true') { ini_set('display_errors', '0'); } -// 4. Security Headers +// 4. H2 Fix: CORS — Whitelist only known origins +$allowedOrigins = array_filter(array_map('trim', explode(',', env('CORS_ORIGIN', 'https://musadaq.intaleqapp.com')))); +$requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? ''; + +if (in_array($requestOrigin, $allowedOrigins, true)) { + header("Access-Control-Allow-Origin: {$requestOrigin}"); +} else { + // Fallback to first allowed origin (for non-browser API clients) + header("Access-Control-Allow-Origin: " . ($allowedOrigins[0] ?? '')); +} + +header("Access-Control-Allow-Methods: GET, POST, OPTIONS"); +header("Access-Control-Allow-Headers: Content-Type, Authorization, X-HMAC-Signature, X-Timestamp"); +header("Access-Control-Allow-Credentials: true"); +header("Vary: Origin"); + +// Handle CORS preflight +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +// 5. Security Headers header("X-Content-Type-Options: nosniff"); header("X-Frame-Options: DENY"); header("X-XSS-Protection: 1; mode=block"); header("Referrer-Policy: strict-origin-when-cross-origin"); +header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); // I1 Fix: HSTS -// 5. Intelligent Autoloader (Case-Insensitive for directories) +// 6. Intelligent Autoloader (Case-Insensitive for directories) spl_autoload_register(function ($class) { - $prefix = 'App\\'; + $prefix = 'App\\'; $base_dir = APP_PATH . '/'; - + $len = strlen($prefix); if (strncmp($prefix, $class, $len) !== 0) return; - + $relative_class = substr($class, $len); - - // Normalize path to lowercase for directories, keep filename case - $parts = explode('\\', $relative_class); + + $parts = explode('\\', $relative_class); $filename = array_pop($parts) . '.php'; - $dir = strtolower(implode('/', $parts)); - + $dir = strtolower(implode('/', $parts)); + $file = $base_dir . ($dir ? $dir . '/' : '') . $filename; - + if (file_exists($file)) { require $file; } }); -// 6. Response Utility +// 7. Response Utility require_once APP_PATH . '/bootstrap/response.php'; -// 7. Global Auth Helper +// 8. Global Auth Helper require_once APP_PATH . '/bootstrap/auth.php'; diff --git a/app/bootstrap/response.php b/app/bootstrap/response.php index 80cdde4..4482e84 100644 --- a/app/bootstrap/response.php +++ b/app/bootstrap/response.php @@ -1,51 +1,65 @@ $success, - 'data' => $data, + 'data' => $data, // Return real data to client 'message' => $message, 'timestamp' => date('c') ], JSON_UNESCAPED_UNICODE); - + exit; } diff --git a/app/helpers/helpers.php b/app/helpers/helpers.php index c798127..6d3f8ec 100644 --- a/app/helpers/helpers.php +++ b/app/helpers/helpers.php @@ -13,19 +13,25 @@ if (!function_exists('input')) { function input(string $key = null, $default = null) { static $inputData = null; if ($inputData === null) { - $json = file_get_contents('php://input'); + $json = file_get_contents('php://input'); $inputData = array_merge($_GET, $_POST, json_decode($json, true) ?? []); } - + if ($key === null) return $inputData; return $inputData[$key] ?? $default; } } if (!function_exists('dd')) { + // M3 Fix: Guard dd() so it never leaks data in production function dd(...$vars) { + if (env('APP_DEBUG', 'false') !== 'true') { + error_log('dd() called in production — suppressed. Check your code.'); + json_error('Internal Server Error', 500); + } + header('Content-Type: text/html; charset=utf-8'); foreach ($vars as $v) { - echo "
";
+            echo "
";
             var_dump($v);
             echo "
"; } diff --git a/app/middleware/AuthMiddleware.php b/app/middleware/AuthMiddleware.php index 17d6249..f1af32f 100644 --- a/app/middleware/AuthMiddleware.php +++ b/app/middleware/AuthMiddleware.php @@ -23,6 +23,11 @@ final class AuthMiddleware $token = substr($authHeader, 7); $secret = env('JWT_SECRET'); + if (!$secret || strlen($secret) < 32) { + error_log('FATAL: JWT_SECRET is missing or too short'); + json_error('Server configuration error', 500); + } + $decoded = JWT::decode($token, $secret); if (!$decoded) { diff --git a/app/middleware/HmacMiddleware.php b/app/middleware/HmacMiddleware.php new file mode 100644 index 0000000..f265c92 --- /dev/null +++ b/app/middleware/HmacMiddleware.php @@ -0,0 +1,62 @@ + $maxAgeSeconds) { + json_error('Request expired. Check your system clock.', 401); + } + + // 4. Build the expected signature + $body = file_get_contents('php://input'); + $payload = $timestamp . '.' . $body; + $secret = env('HMAC_SECRET_KEY'); + + if (!$secret || strlen($secret) < 32) { + error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env'); + json_error('Server configuration error', 500); + } + + // 5. Verify using constant-time comparison (prevents timing attacks) + if (!Security::verifySignature($payload, $signature, $secret)) { + error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? '')); + json_error('Invalid request signature', 401); + } + } +} diff --git a/app/middleware/RateLimitMiddleware.php b/app/middleware/RateLimitMiddleware.php index 32480d8..aee49bf 100644 --- a/app/middleware/RateLimitMiddleware.php +++ b/app/middleware/RateLimitMiddleware.php @@ -1,6 +1,6 @@ $timestamp > ($now - $timeWindow)); + + // M2 Fix: Use exclusive file lock to prevent race condition + $fp = fopen($cacheFile, 'c+'); + if ($fp === false) { + // If we can't open the file, fail open (don't block all users) + return; + } + + try { + flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired + + $now = time(); + $content = stream_get_contents($fp); + $requests = []; + + if (!empty($content)) { + $decoded = json_decode($content, true); + if (is_array($decoded)) { + // Keep only requests within the time window + $requests = array_values( + array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow)) + ); } } + + if (count($requests) >= $maxRequests) { + flock($fp, LOCK_UN); + fclose($fp); + + header('Retry-After: ' . $timeWindow); + json_error('Too Many Requests. Please slow down.', 429); + } + + // Record this request + $requests[] = $now; + + // Write updated data back + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($requests)); + + } finally { + flock($fp, LOCK_UN); + fclose($fp); } - - // Check limit - if (count($requests) >= $maxRequests) { - json_error('Too Many Requests. Please try again later.', 429); - } - - // Add current request - $requests[] = $now; - - // Save back to file - file_put_contents($cacheFile, json_encode(array_values($requests))); } } diff --git a/app/modules_app/auth/login.php b/app/modules_app/auth/login.php index ed1aba5..f873362 100644 --- a/app/modules_app/auth/login.php +++ b/app/modules_app/auth/login.php @@ -39,7 +39,11 @@ if (!$user || !password_verify($password, $user['password_hash'])) { } // 3. Issue Token -$secret = env('JWT_SECRET', 'super-secret-key'); +$secret = env('JWT_SECRET'); +if (!$secret || strlen($secret) < 32) { + error_log('FATAL: JWT_SECRET is missing or too short in .env'); + json_error('Server configuration error', 500); +} $payload = [ 'user_id' => $user['id'], 'role' => $user['role'], @@ -48,10 +52,11 @@ $payload = [ $token = JWT::encode($payload, $secret); -// 4. Update Refresh Token (Simple stored in DB as requested) +// 4. Update Refresh Token (Hashed before storage for security) $refreshToken = bin2hex(random_bytes(32)); +$refreshTokenHash = hash('sha256', $refreshToken); $stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?"); -$stmt->execute([$refreshToken, $user['id']]); +$stmt->execute([$refreshTokenHash, $user['id']]); json_success([ 'access_token' => $token, diff --git a/app/modules_app/auth/refresh.php b/app/modules_app/auth/refresh.php index a0ed6aa..73844b8 100644 --- a/app/modules_app/auth/refresh.php +++ b/app/modules_app/auth/refresh.php @@ -14,15 +14,20 @@ if (!$refreshToken) { } $db = Database::getInstance(); +$refreshTokenHash = hash('sha256', $refreshToken); $stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1"); -$stmt->execute([$refreshToken]); +$stmt->execute([$refreshTokenHash]); $user = $stmt->fetch(); if (!$user) { json_error('Invalid refresh token', 401); } -$secret = env('JWT_SECRET', 'super-secret-key'); +$secret = env('JWT_SECRET'); +if (!$secret || strlen($secret) < 32) { + error_log('FATAL: JWT_SECRET is missing or too short in .env'); + json_error('Server configuration error', 500); +} $payload = [ 'user_id' => $user['id'], 'role' => $user['role'], @@ -31,9 +36,10 @@ $payload = [ $newToken = JWT::encode($payload, $secret); $newRefreshToken = bin2hex(random_bytes(32)); +$newRefreshTokenHash = hash('sha256', $newRefreshToken); $stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?"); -$stmt->execute([$newRefreshToken, $user['id']]); +$stmt->execute([$newRefreshTokenHash, $user['id']]); json_success([ 'access_token' => $newToken, diff --git a/public/index.php b/public/index.php index 47ee75e..ff028fe 100644 --- a/public/index.php +++ b/public/index.php @@ -5,37 +5,42 @@ require_once __DIR__ . '/../app/bootstrap/init.php'; -// Global Request Logging (for debugging on server) +// Global Request Logging (non-sensitive) error_log("Incoming Request: " . ($_SERVER['REQUEST_METHOD'] ?? 'GET') . " " . ($_SERVER['REQUEST_URI'] ?? '/')); $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $route = $_GET['route'] ?? str_replace('/api/', '', $uri); $route = trim($route, '/'); -// Log for debugging -error_log("Router: Resolved route for URI '{$uri}' is '{$route}'"); +error_log("Router: Resolved route '{$route}'"); -// Mapping routes to modules +// Route map: route => [allowed_method, module_file] $routes = [ - 'v1/auth/login' => 'auth/login.php', - 'v1/auth/refresh' => 'auth/refresh.php', - 'v1/auth/logout' => 'auth/logout.php', - 'v1/users' => 'users/index.php', + 'v1/auth/login' => ['POST', 'auth/login.php'], + 'v1/auth/refresh' => ['POST', 'auth/refresh.php'], + 'v1/auth/logout' => ['POST', 'auth/logout.php'], + 'v1/users' => ['GET', 'users/index.php'], ]; if (isset($routes[$route])) { - $file = APP_PATH . '/modules_app/' . $routes[$route]; + [$allowedMethod, $moduleFile] = $routes[$route]; + + // H1 Fix: Enforce HTTP Method + if ($_SERVER['REQUEST_METHOD'] !== $allowedMethod) { + header("Allow: {$allowedMethod}"); + json_error("Method Not Allowed. Use {$allowedMethod}.", 405); + } + + $file = APP_PATH . '/modules_app/' . $moduleFile; if (file_exists($file)) { require_once $file; } else { json_error("Endpoint file missing: {$route}", 500); } } else { - // If no route matches, maybe it's a SPA request or 404 if (str_starts_with($route, 'v1/')) { json_error("Not Found: {$route}", 404); } else { - // Not an API request — serve the SPA shell include __DIR__ . '/shell.php'; exit; }