Skip to content
131 changes: 131 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Seguridad en CronJob para CodeIgniter 4

## Vulnerabilidades Corregidas

### 🚨 CRÍTICAS

#### 1. Inyección de Comandos del Sistema (CVE Potencial)
- **Estado**: ✅ CORREGIDO
- **Archivo**: `src/Job.php`
- **Mejoras implementadas**:
- Validación de comandos peligrosos mediante patrones regex
- Uso de `escapeshellcmd()` para escapar comandos
- Lista negra de comandos destructivos y caracteres de control
- Verificación de códigos de retorno

#### 2. Credenciales Hardcodeadas Débiles
- **Estado**: ✅ CORREGIDO
- **Archivo**: `src/Config/CronJob.php`
- **Mejoras implementadas**:
- Eliminación de credenciales por defecto "admin/admin"
- Campos de configuración vacíos que requieren configuración manual
- Validación obligatoria de credenciales configuradas

### 🔒 ALTAS

#### 3. Autenticación Insegura
- **Estado**: ✅ CORREGIDO
- **Archivos**: `src/Controllers/Login.php`, `src/Controllers/BaseCronJob.php`
- **Mejoras implementadas**:
- Protección contra ataques de fuerza bruta (rate limiting)
- Bloqueo temporal de cuentas tras intentos fallidos
- Validación segura de credenciales con `hash_equals()`
- Regeneración de ID de sesión (prevención de session fixation)
- Timeout automático de sesiones
- Logging de eventos de seguridad
- Protección CSRF opcional

#### 4. Ataques SSRF (Server-Side Request Forgery)
- **Estado**: ✅ CORREGIDO
- **Archivo**: `src/Job.php`
- **Mejoras implementadas**:
- Validación estricta de URLs
- Bloqueo de IPs privadas y localhost
- Configuración segura de cURL (timeouts, verificación SSL)
- Limitación de redirects

## Configuraciones de Seguridad Nuevas

### En `src/Config/CronJob.php`:
```php
// Configuraciones de autenticación
public string $username = ''; // DEBE configurarse
public string $password = ''; // DEBE configurarse

// Protección contra fuerza bruta
public int $maxLoginAttempts = 5;
public int $lockoutTime = 300; // 5 minutos

// Protección de sesiones
public bool $enableCSRFProtection = true;
public int $sessionTimeout = 3600; // 1 hora
```

## Recomendaciones de Implementación

### 1. Configuración Obligatoria
Antes de usar en producción, configurar obligatoriamente:
```php
// En app/Config/CronJob.php
public string $username = 'tu_usuario_seguro';
public string $password = 'tu_contraseña_fuerte';
public bool $enableDashboard = true; // Solo si es necesario
```

### 2. Uso Seguro de Shell Commands
```php
// ❌ PELIGROSO - No usar comandos con caracteres especiales
$schedule->shell('rm -rf / && echo "hacked"');

// ✅ SEGURO - Comandos simples y validados
$schedule->shell('php backup.php');
$schedule->shell('ls -la /var/log');
```

### 3. URLs Seguras
```php
// ❌ PELIGROSO - URLs internas
$schedule->url('http://localhost:8080/admin');
$schedule->url('http://192.168.1.1/config');

// ✅ SEGURO - URLs externas públicas
$schedule->url('https://api.ejemplo.com/webhook');
```

## Logging de Seguridad

El sistema ahora registra automáticamente:
- Intentos de login fallidos con IP
- Bloqueos por fuerza bruta
- Comandos peligrosos detectados
- URLs maliciosas bloqueadas
- Sesiones expiradas
- Cambios de IP en sesiones activas

## Lista de Verificación de Seguridad

### Antes de Producción:
- [ ] Configurar credenciales únicas (no usar admin/admin)
- [ ] Revisar todos los comandos shell programados
- [ ] Validar todas las URLs en trabajos de tipo 'url'
- [ ] Configurar timeouts apropiados
- [ ] Habilitar CSRF si es necesario
- [ ] Revisar logs de seguridad regularmente
- [ ] Deshabilitar dashboard si no se usa (`enableDashboard = false`)

### Monitoreo Continuo:
- [ ] Revisar logs de intentos de login fallidos
- [ ] Monitorear comandos bloqueados por seguridad
- [ ] Verificar URLs bloqueadas por SSRF
- [ ] Auditar trabajos programados regularmente

## Contacto de Seguridad

Si encuentras vulnerabilidades adicionales, por favor reporta de forma responsable a través de:
- Issues de GitHub (para vulnerabilidades no críticas)
- Email directo al mantenedor (para vulnerabilidades críticas)

---

**Fecha de última actualización**: 2025-01-06
**Versión del análisis**: 1.0
13 changes: 11 additions & 2 deletions src/Config/CronJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,17 @@ class CronJob extends BaseConfig
|--------------------------------------------------------------------------
*/
public bool $enableDashboard = false;
public string $username = 'admin';
public string $password = 'admin';

// SECURITY: No usar credenciales por defecto en producción
// Estas credenciales deben ser cambiadas obligatoriamente
public string $username = ''; // Debe configurarse en el archivo de configuración local
public string $password = ''; // Debe configurarse en el archivo de configuración local

// Configuraciones de seguridad adicionales
public int $maxLoginAttempts = 5; // Máximo intentos de login
public int $lockoutTime = 300; // Tiempo de bloqueo en segundos (5 minutos)
public bool $enableCSRFProtection = true; // Protección CSRF
public int $sessionTimeout = 3600; // Timeout de sesión en segundos (1 hora)

/*
|--------------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions src/Controllers/BaseCronJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,27 @@ public function initController(RequestInterface $request, ResponseInterface $res
protected function checkCronJobSession()
{
$result = false;
$config = config('CronJob');

if ($this->session->get('cronjob')) {
// Verificar timeout de sesión
$loginTime = $this->session->get('cronjob_login_time');
if ($loginTime && (time() - $loginTime) > $config->sessionTimeout) {
$this->session->destroy();
log_message('info', 'CronJob: Sesión expirada por timeout');
return false;
}

// Verificar IP (opcional - podría ser problemático con proxies)
$sessionIP = $this->session->get('cronjob_ip');
$currentIP = $this->request->getIPAddress();
if ($sessionIP && $sessionIP !== $currentIP) {
log_message('warning', 'CronJob: Intento de acceso desde IP diferente. IP de sesión: ' . $sessionIP . ', IP actual: ' . $currentIP);
// Opcional: descomentar para forzar logout en cambio de IP
// $this->session->destroy();
// return false;
}

$result = true;
}

Expand Down
146 changes: 138 additions & 8 deletions src/Controllers/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

class Login extends BaseCronJob
{
private const SESSION_LOGIN_ATTEMPTS = 'cronjob_login_attempts';
private const SESSION_LOCKOUT_UNTIL = 'cronjob_lockout_until';

/**
* Displays the form the login to the site.
*/
Expand All @@ -17,36 +20,163 @@ public function index()
return redirect()->to('cronjob/dashboard');
}

// Verificar si está bloqueado por intentos fallidos
if ($this->isLockedOut()) {
$lockoutTime = $this->session->get(self::SESSION_LOCKOUT_UNTIL);
$remainingTime = $lockoutTime - time();

$this->viewData['error'] = "Cuenta bloqueada por múltiples intentos fallidos. Intente nuevamente en " .
ceil($remainingTime / 60) . " minutos.";
}

return view(config('CronJob')->views['login'], $this->viewData);
}

public function validation()
{
$config = config('CronJob');

// Verificar configuración de seguridad
if (empty($config->username) || empty($config->password)) {
log_message('error', 'CronJob: Credenciales no configuradas en el archivo de configuración');
return redirect()->to('cronjob')->with('error', 'Configuración de seguridad incompleta');
}

// Verificar si está bloqueado
if ($this->isLockedOut()) {
return redirect()->to('cronjob');
}

// Validación CSRF si está habilitada
if ($config->enableCSRFProtection && !$this->validateCSRF()) {
log_message('warning', 'CronJob: Intento de acceso con token CSRF inválido desde IP: ' . $this->request->getIPAddress());
return redirect()->to('cronjob')->with('error', 'Token de seguridad inválido');
}

$validation = Services::validation();
$validation->setRule('username', 'Username', 'required');
$validation->setRule('password', 'Password', 'required');
$validation->setRule('username', 'Username', 'required|max_length[100]');
$validation->setRule('password', 'Password', 'required|max_length[255]');

if (! $validation->withRequest($this->request)->run()) {
if (!$validation->withRequest($this->request)->run()) {
return redirect()->to('cronjob');
}

$username = $this->request->getPost('username');
$password = $this->request->getPost('password');

$config = config('CronJob');
if ($username !== $config->username || $password !== $config->password) {
return redirect()->to('cronjob');
// Validar credenciales de forma segura
if (!$this->validateCredentials($username, $password, $config)) {
$this->recordFailedAttempt();
log_message('warning', 'CronJob: Intento de login fallido para usuario: ' . $username . ' desde IP: ' . $this->request->getIPAddress());

return redirect()->to('cronjob')->with('error', 'Credenciales incorrectas');
}

$this->session->set('cronjob', true);
// Login exitoso - limpiar intentos fallidos
$this->clearFailedAttempts();

// Configurar sesión segura
$this->setupSecureSession();

log_message('info', 'CronJob: Login exitoso para usuario: ' . $username . ' desde IP: ' . $this->request->getIPAddress());

return redirect()->to('cronjob/dashboard');
}

public function logout()
{
$this->session->remove('cronjob');
// Destruir sesión completamente
$this->session->destroy();

return redirect()->to('cronjob');
}

/**
* Verifica si la cuenta está bloqueada por intentos fallidos
*/
private function isLockedOut(): bool
{
$lockoutUntil = $this->session->get(self::SESSION_LOCKOUT_UNTIL);

if ($lockoutUntil && time() < $lockoutUntil) {
return true;
}

// Si ya pasó el tiempo de bloqueo, limpiar
if ($lockoutUntil && time() >= $lockoutUntil) {
$this->clearFailedAttempts();
}

return false;
}

/**
* Registra un intento de login fallido
*/
private function recordFailedAttempt(): void
{
$config = config('CronJob');
$attempts = $this->session->get(self::SESSION_LOGIN_ATTEMPTS, 0) + 1;

$this->session->set(self::SESSION_LOGIN_ATTEMPTS, $attempts);

if ($attempts >= $config->maxLoginAttempts) {
$lockoutUntil = time() + $config->lockoutTime;
$this->session->set(self::SESSION_LOCKOUT_UNTIL, $lockoutUntil);
}
}

/**
* Limpia los intentos fallidos de login
*/
private function clearFailedAttempts(): void
{
$this->session->remove(self::SESSION_LOGIN_ATTEMPTS);
$this->session->remove(self::SESSION_LOCKOUT_UNTIL);
}

/**
* Valida las credenciales de forma segura
*/
private function validateCredentials(string $username, string $password, $config): bool
{
// Usar hash_equals para prevenir timing attacks
$usernameValid = hash_equals($config->username, $username);
$passwordValid = hash_equals($config->password, $password);

return $usernameValid && $passwordValid;
}

/**
* Configura una sesión segura
*/
private function setupSecureSession(): void
{
$config = config('CronJob');

// Regenerar ID de sesión para prevenir session fixation
$this->session->regenerate();

$this->session->set([
'cronjob' => true,
'cronjob_login_time' => time(),
'cronjob_ip' => $this->request->getIPAddress(),
'cronjob_user_agent' => $this->request->getUserAgent()
]);
}

/**
* Valida el token CSRF
*/
private function validateCSRF(): bool
{
try {
$csrfToken = $this->request->getPost('csrf_token');
$sessionToken = $this->session->get('csrf_token');

return $csrfToken && $sessionToken && hash_equals($sessionToken, $csrfToken);
} catch (\Exception $e) {
return false;
}
}
}
20 changes: 20 additions & 0 deletions src/Exceptions/CronJobException.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,24 @@ public static function forInvalidExpression(string $type)
{
return new self(lang('CronJob.invalidExpression', [$type]));
}

public static function forInvalidCommand(string $command)
{
return new self("Comando peligroso detectado: {$command}. Contiene caracteres o comandos no permitidos.");
}

Comment on lines +26 to +28

Copilot AI Jul 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Embedding raw commands in exception messages may leak sensitive data in logs. Consider sanitizing or redacting parts of the command before including it in the error.

Suggested change
return new self("Comando peligroso detectado: {$command}. Contiene caracteres o comandos no permitidos.");
}
$sanitizedCommand = self::sanitizeCommand($command);
return new self("Comando peligroso detectado: {$sanitizedCommand}. Contiene caracteres o comandos no permitidos.");
}
private static function sanitizeCommand(string $command): string
{
// Redact sensitive data or sanitize the command
return preg_replace('/(--password[= ]\S+|--key[= ]\S+)/i', '--REDACTED', $command);
}

Copilot uses AI. Check for mistakes.
public static function forCommandExecutionFailed(string $command, int $returnCode)
{
return new self("Error al ejecutar comando: {$command}. Código de salida: {$returnCode}");
}

public static function forInvalidUrl(string $url)
{
return new self("URL no válida o peligrosa: {$url}. Posible intento de SSRF.");
}

public static function forUrlRequestFailed(string $url, string $error)
{
return new self("Error al realizar petición HTTP a: {$url}. Error: {$error}");
}
}
Loading