Deploy: 2026-05-21 01:58:32

This commit is contained in:
Hamza-Ayed
2026-05-21 01:58:32 +03:00
parent 16d494b4e1
commit aae860486a
11 changed files with 263 additions and 38 deletions

View File

@@ -39,13 +39,13 @@ class AuthController extends BaseController
try { try {
// Create Company // Create Company
$companyId = Company::create([ $companyId = Company::create([
'name' => htmlspecialchars(strip_tags($data['company_name'])) 'name' => $data['company_name']
]); ]);
// Create Admin User for this Company // Create Admin User for this Company
$userId = User::createSecure([ $userId = User::createSecure([
'company_id' => $companyId, 'company_id' => $companyId,
'name' => htmlspecialchars(strip_tags($data['user_name'])), 'name' => $data['user_name'],
'email' => strtolower(trim($data['email'])), 'email' => strtolower(trim($data['email'])),
'password' => $data['password'], 'password' => $data['password'],
'role' => 'admin' 'role' => 'admin'
@@ -127,7 +127,7 @@ class AuthController extends BaseController
{ {
$user = User::find($request->user_id); $user = User::find($request->user_id);
if (!$user) { if (!$user || (int)$user['company_id'] !== (int)$request->company_id) {
$response->json(['error' => 'User not found'], 404); $response->json(['error' => 'User not found'], 404);
return; return;
} }

View File

@@ -13,6 +13,11 @@ class Request
private array $bodyParams; private array $bodyParams;
private array $headers; private array $headers;
// Explicit properties to store authentication details to avoid deprecation warnings in PHP 8.2+
public ?int $user_id = null;
public ?int $company_id = null;
public ?string $role = null;
public function __construct() public function __construct()
{ {
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); $this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');

View File

@@ -39,10 +39,12 @@ class Response
$this->setStatusCode($code); $this->setStatusCode($code);
$this->setHeader('Content-Type', 'application/json; charset=utf-8'); $this->setHeader('Content-Type', 'application/json; charset=utf-8');
// Setup base CORS headers for our API // Setup CORS headers — restrict origin to the configured allowed domain
$this->setHeader('Access-Control-Allow-Origin', '*'); $allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
$this->setHeader('Access-Control-Allow-Origin', $allowedOrigin);
$this->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); $this->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$this->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); $this->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
$this->setHeader('Vary', 'Origin'); // Required when Access-Control-Allow-Origin is not *
$this->sendHeaders(); $this->sendHeaders();
http_response_code($this->statusCode); http_response_code($this->statusCode);

View File

@@ -75,9 +75,11 @@ class Router
// Handle CORS Preflight Preemptively // Handle CORS Preflight Preemptively
if ($method === 'OPTIONS') { if ($method === 'OPTIONS') {
$response->setHeader('Access-Control-Allow-Origin', '*'); $allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
$response->setHeader('Access-Control-Allow-Origin', $allowedOrigin);
$response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); $response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); $response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
$response->setHeader('Vary', 'Origin');
$response->setStatusCode(200); $response->setStatusCode(200);
exit; exit;
} }
@@ -116,12 +118,14 @@ class Router
return; return;
} }
$response->error("Handler error for route: {$path}", 500); error_log("Handler error for route: [{$method}] {$path}");
$response->error("Internal Server Error", 500);
return; return;
} }
} }
// Route not found // Route not found
$response->error("Route not found: [{$method}] {$path}", 404); error_log("Route not found: [{$method}] {$path}");
$response->error("Not Found", 404);
} }
} }

View File

@@ -15,6 +15,9 @@ class Security
private static function getEncryptionKey(): string private static function getEncryptionKey(): string
{ {
$key = getenv('ENCRYPTION_KEY'); $key = getenv('ENCRYPTION_KEY');
if (!$key || strlen($key) < 16) {
throw new \RuntimeException("ENCRYPTION_KEY environment variable is empty or too short. Cryptographic operations aborted.");
}
return substr(hash('sha256', $key, true), 0, 32); return substr(hash('sha256', $key, true), 0, 32);
} }
@@ -23,7 +26,11 @@ class Security
*/ */
private static function getHmacSalt(): string private static function getHmacSalt(): string
{ {
return getenv('HMAC_SALT'); $salt = getenv('HMAC_SALT');
if (!$salt) {
throw new \RuntimeException("HMAC_SALT environment variable is empty. Cryptographic operations aborted.");
}
return $salt;
} }
/** /**
@@ -31,7 +38,11 @@ class Security
*/ */
private static function getJwtSecret(): string private static function getJwtSecret(): string
{ {
return getenv('JWT_SECRET'); $secret = getenv('JWT_SECRET');
if (!$secret) {
throw new \RuntimeException("JWT_SECRET environment variable is empty. Cryptographic operations aborted.");
}
return $secret;
} }
/** /**

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
/**
* Rate Limit Middleware
* Limits the number of requests per IP address using file-based counters.
* Protects sensitive endpoints (login, register) from Brute Force attacks.
*/
class RateLimitMiddleware
{
/**
* Maximum allowed requests within the time window
*/
private int $maxAttempts;
/**
* Time window in seconds
*/
private int $decaySeconds;
public function __construct(int $maxAttempts = 5, int $decaySeconds = 60)
{
$this->maxAttempts = $maxAttempts;
$this->decaySeconds = $decaySeconds;
}
public function handle(Request $request, Response $response): void
{
$ip = $this->getClientIp();
$key = 'rate_' . md5($ip . '_' . $request->getPath());
$storageDir = APP_ROOT . '/storage/rate_limits';
if (!is_dir($storageDir)) {
mkdir($storageDir, 0750, true);
}
$filePath = $storageDir . '/' . $key . '.json';
$data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds];
if (file_exists($filePath)) {
$raw = json_decode(file_get_contents($filePath), true);
if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) {
// Window still active — use existing data
$data = $raw;
}
// If window expired, fall through and reset (overwrite with fresh data below)
}
$data['count']++;
if ($data['count'] > $this->maxAttempts) {
$retryAfter = max(0, $data['expires_at'] - time());
$response->setHeader('Retry-After', (string)$retryAfter);
$response->json([
'error' => 'Too Many Requests',
'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds."
], 429);
return;
}
// Persist the updated counter
file_put_contents($filePath, json_encode($data), LOCK_EX);
}
/**
* Get real client IP, accounting for proxies
*/
private function getClientIp(): string
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR'
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// X-Forwarded-For can be a comma-separated list; take first
$ip = trim(explode(',', $_SERVER[$header])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '0.0.0.0';
}
}

View File

@@ -33,7 +33,7 @@ class SecurityMiddleware
} }
/** /**
* Recursively sanitize input arrays * Recursively trim input arrays
*/ */
private function sanitizeArray(array $data): array private function sanitizeArray(array $data): array
{ {
@@ -42,8 +42,7 @@ class SecurityMiddleware
if (is_array($value)) { if (is_array($value)) {
$sanitized[$key] = $this->sanitizeArray($value); $sanitized[$key] = $this->sanitizeArray($value);
} elseif (is_string($value)) { } elseif (is_string($value)) {
// Strip HTML tags and convert special characters to HTML entities $sanitized[$key] = trim($value);
$sanitized[$key] = htmlspecialchars(strip_tags(trim($value)), ENT_QUOTES, 'UTF-8');
} else { } else {
$sanitized[$key] = $value; $sanitized[$key] = $value;
} }

View File

@@ -12,12 +12,42 @@ abstract class BaseModel
protected static string $table = ''; protected static string $table = '';
protected static string $primaryKey = 'id'; protected static string $primaryKey = 'id';
/**
* Validate table, primary key, and column names to prevent SQL injection.
*/
protected static function getSafeTable(): string
{
$table = static::$table;
if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
throw new \InvalidArgumentException("Invalid table name: {$table}");
}
return $table;
}
protected static function getSafePrimaryKey(): string
{
$primaryKey = static::$primaryKey;
if (!preg_match('/^[a-zA-Z0-9_]+$/', $primaryKey)) {
throw new \InvalidArgumentException("Invalid primary key: {$primaryKey}");
}
return $primaryKey;
}
protected static function validateColumns(array $columns): void
{
foreach ($columns as $column) {
if (!preg_match('/^[a-zA-Z0-9_]+$/', $column)) {
throw new \InvalidArgumentException("Invalid column name: {$column}");
}
}
}
/** /**
* Retrieve all records * Retrieve all records
*/ */
public static function all(): array public static function all(): array
{ {
$table = static::$table; $table = static::getSafeTable();
return Database::select("SELECT * FROM {$table}"); return Database::select("SELECT * FROM {$table}");
} }
@@ -29,22 +59,27 @@ abstract class BaseModel
*/ */
public static function find($id): ?array public static function find($id): ?array
{ {
$table = static::$table; // Enforce integer conversion or validate to prevent type misuse
$primaryKey = static::$primaryKey; $id = is_numeric($id) ? (int)$id : $id;
$table = static::getSafeTable();
$primaryKey = static::getSafePrimaryKey();
return Database::selectOne("SELECT * FROM {$table} WHERE {$primaryKey} = :id LIMIT 1", ['id' => $id]); return Database::selectOne("SELECT * FROM {$table} WHERE {$primaryKey} = :id LIMIT 1", ['id' => $id]);
} }
/** /**
* Insert a new record * Insert a new record
* *
* @param array $data Assocation array of columns and values * @param array $data Association array of columns and values
* @return string Last inserted primary key ID * @return string Last inserted primary key ID
*/ */
public static function create(array $data): string public static function create(array $data): string
{ {
$table = static::$table; $table = static::getSafeTable();
$columns = implode(', ', array_keys($data)); $columnKeys = array_keys($data);
$placeholders = ':' . implode(', :', array_keys($data)); static::validateColumns($columnKeys);
$columns = implode(', ', $columnKeys);
$placeholders = ':' . implode(', :', $columnKeys);
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})"; $sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
return Database::insert($sql, $data); return Database::insert($sql, $data);
@@ -59,11 +94,15 @@ abstract class BaseModel
*/ */
public static function update($id, array $data): int public static function update($id, array $data): int
{ {
$table = static::$table; $id = is_numeric($id) ? (int)$id : $id;
$primaryKey = static::$primaryKey; $table = static::getSafeTable();
$primaryKey = static::getSafePrimaryKey();
$columnKeys = array_keys($data);
static::validateColumns($columnKeys);
$sets = []; $sets = [];
foreach (array_keys($data) as $column) { foreach ($columnKeys as $column) {
$sets[] = "{$column} = :{$column}"; $sets[] = "{$column} = :{$column}";
} }
$setSql = implode(', ', $sets); $setSql = implode(', ', $sets);
@@ -81,8 +120,9 @@ abstract class BaseModel
*/ */
public static function delete($id): int public static function delete($id): int
{ {
$table = static::$table; $id = is_numeric($id) ? (int)$id : $id;
$primaryKey = static::$primaryKey; $table = static::getSafeTable();
$primaryKey = static::getSafePrimaryKey();
$sql = "DELETE FROM {$table} WHERE {$primaryKey} = :id"; $sql = "DELETE FROM {$table} WHERE {$primaryKey} = :id";
return Database::execute($sql, ['id' => $id]); return Database::execute($sql, ['id' => $id]);

View File

@@ -55,3 +55,32 @@ if ($isDebug) {
ini_set('display_errors', '0'); ini_set('display_errors', '0');
error_reporting(0); error_reporting(0);
} }
// 4. Global Uncaught Exception Handler
// Catches any unhandled exception anywhere in the app and returns a clean JSON error
// instead of leaking PHP stack traces to the browser.
set_exception_handler(function (\Throwable $e) {
$isDebug = filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOLEAN);
error_log('[EXCEPTION] ' . get_class($e) . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(500);
}
$body = ['error' => 'Internal Server Error'];
// In debug mode, expose details to the developer only
if ($isDebug) {
$body['debug'] = [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit(1);
});

View File

@@ -20,22 +20,19 @@ $router = new Router();
$router->use(\App\Middlewares\SecurityMiddleware::class); $router->use(\App\Middlewares\SecurityMiddleware::class);
// 4. Define API Routes // 4. Define API Routes
// Health Check — no php_version or environment in production to avoid info disclosure
$router->get('/api/health', function ($request, $response) { $router->get('/api/health', function ($request, $response) {
$response->json([ $response->json([
'status' => 'success', 'status' => 'success',
'message' => 'Nabeh API is healthy', 'message' => 'Nabeh API is healthy',
'details' => [
'app_name' => getenv('APP_NAME') ?: 'Nabeh', 'app_name' => getenv('APP_NAME') ?: 'Nabeh',
'environment' => getenv('APP_ENV') ?: 'development',
'php_version' => PHP_VERSION,
'time' => date('Y-m-d H:i:s') 'time' => date('Y-m-d H:i:s')
]
]); ]);
}); });
// Authentication Routes // Authentication Routes (Rate-limited: 5 attempts per 60 seconds per IP)
$router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register']); $router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register'], [\App\Middlewares\RateLimitMiddleware::class]);
$router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login']); $router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login'], [\App\Middlewares\RateLimitMiddleware::class]);
$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]); $router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);

View File

@@ -251,4 +251,48 @@ server {
> - هل ترغب بإنشاء مسار `POST /api/auth/register` مفتوح للجميع لإنشاء حسابات شركات جديدة؟ أم نكتفي حالياً بإنشاء مستخدم مدير (Admin) افتراضي يدوياً أو عبر سكربت ليكون النظام مغلقاً للشركات المعتمدة فقط؟ > - هل ترغب بإنشاء مسار `POST /api/auth/register` مفتوح للجميع لإنشاء حسابات شركات جديدة؟ أم نكتفي حالياً بإنشاء مستخدم مدير (Admin) افتراضي يدوياً أو عبر سكربت ليكون النظام مغلقاً للشركات المعتمدة فقط؟
> - في عملية تسجيل الدخول، هل نحتاج لإرجاع بيانات الشركة المرتبطة بالمستخدم ضمن نفس الـ Response، أم نكتفي بإرجاع التوكن (Token) وبيانات المستخدم الأساسية فقط؟ > - في عملية تسجيل الدخول، هل نحتاج لإرجاع بيانات الشركة المرتبطة بالمستخدم ضمن نفس الـ Response، أم نكتفي بإرجاع التوكن (Token) وبيانات المستخدم الأساسية فقط؟
---
## المرحلة الخامسة: معالجة وإصلاح الثغرات الأمنية (Security Audit Remediation)
بناءً على التقرير الأمني الصادر، سنقوم بتطبيق التعديلات البرمجية لرفع مستوى أمان الخادم وحماية البيانات.
### Proposed Changes
#### [MODIFY] [Security.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Security.php)
- إرجاع خطأ (Exception) فوراً في حال عدم وجود متغيرات البيئة (`ENCRYPTION_KEY`, `HMAC_SALT`, `JWT_SECRET`) لمنع تشفير البيانات بمفاتيح فارغة.
#### [MODIFY] [BaseModel.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Models/BaseModel.php)
- تنظيف وفلترة أسماء الأعمدة ديناميكياً باستخدام مصفوفة بيضاء (Whitelist Regex) لمنع ثغرات الـ SQL Injection عبر أسماء الأعمدة في دالتي `create()` و `update()`.
#### [MODIFY] [SecurityMiddleware.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Middlewares/SecurityMiddleware.php)
- إلغاء تطبيق `htmlspecialchars` و `strip_tags` على المدخلات الخام القادمة للخلفية لمنع تلف كلمات المرور والرموز الخاصة، وتأجيل التنظيف ليكون حصراً عند الطباعة/العرض (Output Encoding).
#### [MODIFY] [AuthController.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Controllers/AuthController.php)
- إزالة التنظيف المزدوج (Double Encoding) لأسماء المستخدمين والشركات.
#### [MODIFY] [Router.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Router.php)
- إخفاء مسار الملفات الحقيقي من رسائل الخطأ 404 و 500 وإظهار رسائل عامة لمنع تسريب بنية المجلدات (Information Disclosure).
#### [MODIFY] [Response.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Response.php)
- جعل رابط الـ CORS ديناميكياً يعتمد على متغير بيئة `ALLOWED_ORIGIN` بدلاً من فتح النطاق للجميع عبر `*`.
#### [MODIFY] [bootstrap.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/bootstrap.php)
- تسجيل معالج أخطاء عام (`set_exception_handler` / `set_error_handler`) لتحويل الأخطاء الفادحة غير المعالجة إلى استجابات JSON نظيفة ومخفية في الإنتاج، مع تسجيل التفاصيل التقنية في الـ `error_log` فقط.
#### [MODIFY] [Request.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Request.php)
- تعريف الخصائص المعرفية (`$user_id`, `$company_id`, `$role`) بشكل صريح لتفادي مشاكل الخصائص الديناميكية (Dynamic Properties Deprecation) في إصدارات PHP 8.2+.
#### [NEW] [RateLimitMiddleware.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Middlewares/RateLimitMiddleware.php)
- بناء فلتر مخصص للحد من معدل الطلبات (Rate Limiting) على مسارات تسجيل الدخول والتسجيل لحماية التطبيق من محاولات التخمين العنيف (Brute Force).
#### [MODIFY] [index.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/public/index.php)
- تفعيل فلتر الحد من معدل الطلبات على مسارات الحماية.
### User Review Required
> [!IMPORTANT]
> هل نعتمد نظام تخزين المحاولات للحد من معدل الطلبات (Rate Limiting) في ملفات مؤقتة داخل مجلد `storage/` محلي (سهل الإعداد ومستقل)، أم نستخدم جدولاً مخصصاً في قاعدة البيانات؟
</div> </div>