Security Best Practices

Overview

This guide outlines security best practices for deploying and maintaining a secure Neuron CMS installation. The CMS implements multiple layers of security, and proper configuration is essential for production environments.

Authentication Security

Password Policies

Enforcing Strong Passwords

The CMS uses the PasswordHasher service which enforces minimum password requirements:

Requirements:

Implementation:

use Neuron\Cms\Auth\PasswordHasher;

$hasher = new PasswordHasher();

// Validate password meets requirements
if( !$hasher->meetsRequirements( $password ) )
{
    throw new Exception( 'Password must be at least 8 characters and contain uppercase, ' .
        'lowercase, number, and special character' );
}

// Hash valid password
$hash = $hasher->hash( $password );

Customizing Requirements:

To increase security, modify PasswordHasher::meetsRequirements():

public function meetsRequirements( string $password ): bool
{
    // Minimum 12 characters (increased from 8)
    if( strlen( $password ) < 12 )
    {
        return false;
    }

    // Must have uppercase
    if( !preg_match( '/[A-Z]/', $password ) )
    {
        return false;
    }

    // Must have lowercase
    if( !preg_match( '/[a-z]/', $password ) )
    {
        return false;
    }

    // Must have number
    if( !preg_match( '/[0-9]/', $password ) )
    {
        return false;
    }

    // Must have special character
    if( !preg_match( '/[!@#$%^&*()_+\-=\[\]{}|;:\'",.<>?]/', $password ) )
    {
        return false;
    }

    return true;
}

Password Hashing

Argon2id Algorithm

The CMS uses Argon2id by default, which is the recommended algorithm as of 2025:

// In PasswordHasher
public function hash( string $password ): string
{
    return password_hash( $password, PASSWORD_ARGON2ID, [
        'memory_cost' => 65536,  // 64 MB
        'time_cost' => 4,         // Iterations
        'threads' => 2            // Parallel threads
    ] );
}

Algorithm Comparison:

Algorithm Memory Hard Year Introduced Recommended
MD5 No 1992 ❌ Never use
SHA1 No 1995 ❌ Never use
Bcrypt Limited 1999 ✓ Acceptable
Argon2id Yes 2015 ✓✓ Best choice

Why Argon2id:

Password Rehashing

The Authentication service automatically rehashes passwords when needed:

// In Authentication::attempt()
if( $this->_passwordHasher->verify( $password, $user->getPasswordHash() ) )
{
    // Check if password needs rehashing with updated algorithm/costs
    if( password_needs_rehash( $user->getPasswordHash(), PASSWORD_ARGON2ID ) )
    {
        $user->setPasswordHash( $this->_passwordHasher->hash( $password ) );
        $this->_userRepo->update( $user );
    }

    // Authentication successful
    return true;
}

This ensures passwords are automatically upgraded to stronger hashing when users log in.

Session Security

Secure Session Configuration

In php.ini or .htaccess:

; Session cookie settings
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Strict"

; Use strong session ID
session.entropy_length = 32
session.hash_function = sha256

; Restrict session to IP address
session.use_strict_mode = 1

; Regenerate session ID
session.use_trans_sid = 0

Session Fixation Prevention

The Authentication service regenerates session IDs on login:

// In Authentication::login()
session_regenerate_id( true );

$_SESSION['user_id'] = $user->getId();
$_SESSION['role'] = $user->getRole();
$_SESSION['logged_in_at'] = microtime( true );

This prevents session fixation attacks.

Session Timeout

Implement automatic session timeout:

// In authentication filter or middleware
$timeout = 30 * 60; // 30 minutes

if( isset( $_SESSION['last_activity'] ) )
{
    if( time() - $_SESSION['last_activity'] > $timeout )
    {
        // Session expired
        session_destroy();
        header( 'Location: /login?reason=timeout' );
        exit;
    }
}

$_SESSION['last_activity'] = time();

Brute Force Protection

The Authentication service includes built-in brute force protection:

Configuration:

$auth = new Authentication( $userRepo, $sessionMgr, $passwordHasher );
$auth->setMaxLoginAttempts( 5 );        // Lock after 5 attempts
$auth->setLockoutDuration( 15 );        // Lock for 15 minutes

How It Works:

  1. Failed login increments failed_login_attempts
  2. After max attempts, sets locked_until timestamp
  3. Subsequent login attempts check if account is locked
  4. Successful login resets counter

Manual Account Unlock:

$user = $userRepo->findByEmail( '[email protected]' );
$user->resetFailedLoginAttempts();
$userRepo->update( $user );

IP-Based Rate Limiting:

For additional protection, implement IP-based rate limiting (see Rate Limiting section).

Two-Factor Authentication

The User model includes 2FA support:

Enable 2FA for User:

use PragmaRX\Google2FA\Google2FA;

$google2fa = new Google2FA();

// Generate secret
$secret = $google2fa->generateSecretKey();
$user->setTwoFactorSecret( $secret );

// Generate recovery codes (10 codes)
$recoveryCodes = [];
for( $i = 0; $i < 10; $i++ )
{
    $recoveryCodes[] = bin2hex( random_bytes( 8 ) );
}
$user->setTwoFactorRecoveryCodes( json_encode( $recoveryCodes ) );

$userRepo->update( $user );

// Display QR code to user
$qrCodeUrl = $google2fa->getQRCodeUrl( 'Neuron CMS',
    $user->getEmail(),
    $secret
);

Verify 2FA Code:

// After password authentication
if( $user->getTwoFactorSecret() )
{
    $code = $_POST['2fa_code'] ?? '';

    if( $google2fa->verifyKey( $user->getTwoFactorSecret(), $code ) )
    {
        // 2FA verified
        $auth->login( $user );
    }
else
    {
        // Check if it's a recovery code
        $recoveryCodes = json_decode( $user->getTwoFactorRecoveryCodes(), true );
        if( in_array( $code, $recoveryCodes ) )
        {
            // Remove used recovery code
            $recoveryCodes = array_filter( $recoveryCodes, fn($c ) => $c !== $code );
            $user->setTwoFactorRecoveryCodes( json_encode( array_values( $recoveryCodes ) ) );
            $userRepo->update( $user );

            $auth->login( $user );
        }
else
        {
            throw new Exception( 'Invalid 2FA code' );
        }
    }
}

Remember Me Security

The Authentication service implements secure "remember me" functionality:

Security Features:

  1. Cryptographically Secure Tokens: 64-character random tokens (32 bytes)
  2. SHA-256 Hashing: Only hashed tokens stored in database
  3. HTTPOnly Cookies: Prevent JavaScript access
  4. Secure Flag: Only transmitted over HTTPS
  5. 30-Day Expiration: Automatic expiration

Implementation:

// In Authentication::setRememberToken()
private function setRememberToken( User $user ): void
{
    // Generate secure random token
    $token = bin2hex( random_bytes( 32 ) ); // 64 characters

    // Store SHA-256 hash in database
    $user->setRememberToken( hash( 'sha256', $token ) );
    $this->_userRepo->update( $user );

    // Set cookie with plain token
    setcookie( 'remember_token',
        $token,
        [
            'expires' => time() + (30 * 24 * 60 * 60), // 30 days
            'path' => '/',
            'domain' => '',
            'secure' => true,   // HTTPS only
            'httponly' => true, // No JavaScript access
            'samesite' => 'Strict'
        ] );
}

Token Rotation:

Remember tokens should be rotated periodically:

// Rotate token every 7 days
if( $user->getRememberTokenCreatedAt() < new DateTimeImmutable( '-7 days' ) )
{
    $this->setRememberToken( $user );
}

Application Security

CSRF Protection

The CMS includes a CsrfToken service for CSRF protection:

Generate Token:

use Neuron\Cms\Services\Auth\CsrfToken;

$csrf = new CsrfToken( $sessionManager );
$token = $csrf->getToken();

In Form:

<form method="POST" action="/admin/post/create">
    <input type="hidden" name="csrf_token" value="<?= $token ?>">
    <!-- other fields -->
    <button type="submit">Create Post</button>
</form>

Validate Token:

if( !$csrf->validate( $_POST['csrf_token'] ?? '' ) )
{
    throw new Exception( 'CSRF validation failed' );
}

// Process form

AJAX Requests:

// Include token in AJAX headers
fetch('/api/posts', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': csrfToken
    },
    body: JSON.stringify(data)
});

Timing-Safe Comparison:

The CsrfToken service uses hash_equals() for timing-safe token comparison:

public function validate( string $token ): bool
{
    $stored = $_SESSION['csrf_token'] ?? '';
    return hash_equals( $stored, $token );
}

This prevents timing attacks that could leak token information.

XSS Prevention

Output Escaping

Always escape output:

// HTML context
echo htmlspecialchars( $userInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );

// JavaScript context
echo json_encode( $userInput, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );

// URL context
echo urlencode( $userInput );

// CSS context
// Avoid user input in CSS, but if necessary, strictly validate

Content Security Policy

Implement Content Security Policy headers:

// In application bootstrap or middleware
header( "Content-Security-Policy: " . implode( '; ', [
    "default-src 'self'",
    "script-src 'self' 'unsafe-inline' https://cdn.example.com",
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
    "img-src 'self' data: https:",
    "font-src 'self' https://fonts.gstatic.com",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'"
] ) );

Sanitizing Editor.js Content

The EditorJsRenderer sanitizes inline HTML:

private function sanitizeInlineHtml( string $html ): string
{
    $allowedTags = '<b><strong><i><em><u><a><code><mark>';
    return strip_tags( $html, $allowedTags );
}

For additional security, use HTML Purifier:

use HTMLPurifier;

$config = HTMLPurifier_Config::createDefault();
$config->set( 'HTML.Allowed', 'b,strong,i,em,u,a[href],code,mark' );
$purifier = new HTMLPurifier( $config );

$clean = $purifier->purify( $html );

SQL Injection Prevention

The CMS uses PDO with prepared statements:

// Safe: Prepared statement with parameter binding
public function findByEmail( string $email ): ?User
{
    $stmt = $this->_db->prepare( "SELECT * FROM users WHERE email = :email" );
    $stmt->bindValue( ':email', $email, PDO::PARAM_STR );
    $stmt->execute();

    return $this->hydrate( $stmt->fetch( PDO::FETCH_ASSOC ) );
}

// NEVER do this:
// $query = "SELECT * FROM users WHERE email = '{$email}'";  // VULNERABLE!

Best Practices:

  1. Always Use Prepared Statements: Never concatenate user input into SQL
  2. Type Hinting: Use appropriate PDO::PARAM_* constants
  3. Whitelist Validation: For dynamic table/column names, use whitelist validation
  4. Least Privilege: Database user should have minimum required permissions

Dynamic Queries:

When you need dynamic table/column names (which can't be parameterized):

public function findByField( string $field, $value ): array
{
    // Whitelist allowed fields
    $allowedFields = ['username', 'email', 'status'];

    if( !in_array( $field, $allowedFields ) )
    {
        throw new Exception( 'Invalid field name' );
    }

    // Safe: $field is whitelisted
    $stmt = $this->_db->prepare( "SELECT * FROM users WHERE {$field} = :value" );
    $stmt->bindValue( ':value', $value );
    $stmt->execute();

    return $stmt->fetchAll( PDO::FETCH_ASSOC );
}

Input Validation

Server-Side Validation

Always validate on the server (never trust client-side validation):

use Neuron\Dto\Dto;

// Use DTOs for validation
$dto = $dtoFactory->createRegisterUser();
$dto->username = $_POST['username'] ?? '';
$dto->email = $_POST['email'] ?? '';
$dto->password = $_POST['password'] ?? '';

if( !$dto->validate() )
{
    $errors = $dto->getValidationErrors();
    // Display errors
    return;
}

// DTO is valid, proceed
$user = $registrationService->registerWithDto( $dto );

Email Validation

if( !filter_var( $email, FILTER_VALIDATE_EMAIL ) )
{
    throw new Exception( 'Invalid email address' );
}

// Additional check: Verify domain exists
[$user, $domain] = explode( '@', $email );
if( !checkdnsrr( $domain, 'MX' ) && !checkdnsrr( $domain, 'A' ) )
{
    throw new Exception( 'Email domain does not exist' );
}

URL Validation

if( !filter_var( $url, FILTER_VALIDATE_URL ) )
{
    throw new Exception( 'Invalid URL' );
}

// Additional check: Verify scheme
$parsed = parse_url( $url );
if( !in_array( $parsed['scheme'] ?? '', ['http', 'https'] ) )
{
    throw new Exception( 'URL must use HTTP or HTTPS' );
}

File Upload Security

File Type Validation

function validateUpload( array $file ): void
{
    // Check for upload errors
    if( $file['error'] !== UPLOAD_ERR_OK )
{
        throw new Exception( 'File upload failed' );
    }

    // Validate file size (5MB max)
    $maxSize = 5 * 1024 * 1024;
    if( $file['size'] > $maxSize )
{
        throw new Exception( 'File too large (max 5MB )' );
    }

    // Validate MIME type
    $allowedMimes = [
        'image/jpeg',
        'image/png',
        'image/gif',
        'application/pdf'
    ];

    $finfo = finfo_open( FILEINFO_MIME_TYPE );
    $mimeType = finfo_file( $finfo, $file['tmp_name'] );
    finfo_close( $finfo );

    if( !in_array( $mimeType, $allowedMimes ) )
    {
        throw new Exception( 'Invalid file type' );
    }

    // Validate file extension
    $allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
    $ext = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );

    if( !in_array( $ext, $allowedExts ) )
    {
        throw new Exception( 'Invalid file extension' );
    }
}

Secure File Storage

// Generate random filename to prevent directory traversal
$extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
$filename = bin2hex( random_bytes( 16 ) ) . '.' . $extension;

// Store outside web root
$uploadDir = '/var/www/uploads/'; // Not in public/
$destination = $uploadDir . $filename;

if( !move_uploaded_file( $file['tmp_name'], $destination ) )
{
    throw new Exception( 'Failed to save file' );
}

// Set restrictive permissions
chmod( $destination, 0644 );

Serve Files Securely

// Never allow direct access to uploads/
// Serve through PHP script with authentication

public function download( Request $request ): string
{
    $filename = $request->getRouteParameter( 'file' );

    // Validate filename (prevent directory traversal)
    if( strpos( $filename, '..' ) !== false || strpos( $filename, '/' ) !== false )
    {
        return $this->render404( $request );
    }

    $filePath = '/var/www/uploads/' . $filename;

    if( !file_exists( $filePath ) )
    {
        return $this->render404( $request );
    }

    // Check authentication/authorization
    if( !$this->_auth->check() )
    {
        return $this->render403( $request );
    }

    // Serve file
    header( 'Content-Type: ' . mime_content_type( $filePath ) );
    header( 'Content-Length: ' . filesize( $filePath ) );
    header( 'Content-Disposition: attachment; filename="' . basename( $filename ) . '"' );
    readfile( $filePath );
    exit;
}

Sensitive Data Protection

Environment Variables

Store sensitive data in environment variables, not in code:

// .env file (never commit to version control)
DB_HOST=localhost
DB_NAME=cms_database
DB_USER=cms_user
DB_PASS=secure_password
SMTP_PASSWORD=email_password
API_KEY=secret_api_key
// Load environment variables
use Neuron\Data\Env;

Env::loadFile( __DIR__ . '/.env' );

$dbPassword = Env::get( 'DB_PASS' );
$apiKey = Env::get( 'API_KEY' );

Encryption

For additional sensitive data, use encryption:

// Generate encryption key (store securely)
$key = sodium_crypto_secretbox_keygen();

// Encrypt
function encrypt( string $data, string $key ): string
{
    $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
    $encrypted = sodium_crypto_secretbox( $data, $nonce, $key );
    return base64_encode( $nonce . $encrypted );
}

// Decrypt
function decrypt( string $encrypted, string $key ): string
{
    $decoded = base64_decode( $encrypted );
    $nonce = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
    $ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
    return sodium_crypto_secretbox_open( $ciphertext, $nonce, $key );
}

Infrastructure Security

HTTPS Enforcement

Force HTTPS

// In application bootstrap
if( !isset( $_SERVER['HTTPS'] ) || $_SERVER['HTTPS'] !== 'on' )
{
    $redirect = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    header( 'Location: ' . $redirect, true, 301 );
    exit;
}

HTTP Strict Transport Security (HSTS)

header( 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload' );

This tells browsers to only connect via HTTPS for the next year.

Additional Security Headers

// Prevent clickjacking
header( 'X-Frame-Options: DENY' );

// Prevent MIME sniffing
header( 'X-Content-Type-Options: nosniff' );

// Enable XSS protection
header( 'X-XSS-Protection: 1; mode=block' );

// Referrer policy
header( 'Referrer-Policy: strict-origin-when-cross-origin' );

// Permissions policy
header( 'Permissions-Policy: geolocation=(), microphone=(), camera=()' );

File Permissions

Recommended Permissions

# Application directory
chown -R www-data:www-data /var/www/cms
chmod -R 755 /var/www/cms

# Writable directories
chmod -R 775 /var/www/cms/storage
chmod -R 775 /var/www/cms/logs
chmod -R 775 /var/www/cms/uploads

# Configuration files
chmod 640 /var/www/cms/config/*.yaml
chmod 600 /var/www/cms/.env

# Public directory
chmod 755 /var/www/cms/public

Verify Permissions Script

#!/bin/bash
# scripts/check-permissions.sh

ERRORS=0

# Check that config files are not world-readable
if find config/ -type f -perm /004 | grep -q .; then
    echo "ERROR: Config files are world-readable"
    ERRORS=$((ERRORS + 1))
fi

# Check that .env is not readable by group/others
if [ -f .env ] && [ "$(stat -c %a .env)" != "600" ]; then
    echo "ERROR: .env has incorrect permissions"
    ERRORS=$((ERRORS + 1))
fi

# Check that storage/logs are writable
if [ ! -w storage ] || [ ! -w logs ]; then
    echo "ERROR: storage/ or logs/ not writable"
    ERRORS=$((ERRORS + 1))
fi

exit $ERRORS

Directory Protection

Block Direct Access

.htaccess for Apache:

# Block access to sensitive directories
<DirectoryMatch "^/(config|storage|logs|vendor)">
    Require all denied
</DirectoryMatch>

# Block access to dot files
<FilesMatch "^\.">
    Require all denied
</FilesMatch>

Nginx configuration:

# Block access to sensitive directories
location ~ ^/(config|storage|logs|vendor) {
    deny all;
    return 404;
}

# Block access to dot files
location ~ /\. {
    deny all;
    return 404;
}

Error Disclosure

Disable Debug Mode in Production

In config/neuron.yaml:

app:
  debug: false
  environment: production

Custom Error Pages

// In Application.php
public function handleException( \Throwable $e ): string
{
    if( $this->getEnvironment() === 'production' )
    {
        // Log error details
        Log::error( "Exception: " . $e->getMessage(), [
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'trace' => $e->getTraceAsString()
        ]);

        // Show generic error page
        http_response_code( 500 );
        return file_get_contents( $this->getBasePath() . '/public/500.html' );
    }
else
    {
        // Show detailed error in development
        return $this->beautifyException( $e );
    }
}

Database Security

Least Privilege Principle

-- Create database user with minimal permissions
CREATE USER 'cms_user'@'localhost' IDENTIFIED BY 'secure_password';

-- Grant only necessary permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON cms_database.* TO 'cms_user'@'localhost';

-- No GRANT, DROP, CREATE, ALTER permissions for application user
-- Use separate admin user for migrations

Network Restrictions

-- Restrict connections to localhost only
CREATE USER 'cms_user'@'localhost' IDENTIFIED BY 'secure_password';

-- Or specific IP
CREATE USER 'cms_user'@'192.168.1.100' IDENTIFIED BY 'secure_password';

Backup Database Credentials

Store backup user separately with read-only access:

CREATE USER 'cms_backup'@'localhost' IDENTIFIED BY 'backup_password';
GRANT SELECT, LOCK TABLES ON cms_database.* TO 'cms_backup'@'localhost';

Access Control

Role-Based Authorization

Implement Permission Checks

// In controller
public function deletePost( Request $request ): string
{
    if( !$this->_auth->isEditorOrHigher() )
    {
        return $this->renderJson( 403, ['error' => 'Forbidden'] );
    }

    $postId = $request->getRouteParameter( 'id' );
    $post = $this->_postRepo->findById( $postId );

    // Authors can only delete their own posts
    if( $this->_auth->user()->getRole() === User::ROLE_AUTHOR )
    {
        if( $post->getAuthorId() !== $this->_auth->id() )
        {
            return $this->renderJson( 403, ['error' => 'Forbidden' );
        }
    }

    $this->_postDeleter->delete( $post );

    return $this->renderJson( 200, ['success' => true] );
}

Route Filters for Authorization

use Neuron\Routing\Attributes\Get;

class AdminController extends Controller
{
    // Requires admin role
    #[Get('/admin/users', name: 'admin.users', filters: ['auth', 'admin'])]
    public function users(Request $request): string
    {
        // Only admins can access
    }
}

class AuthorController extends Controller
{
    // Requires author or higher role
    #[Get('/author/posts', name: 'author.posts', filters: ['auth', 'author'])]
    public function posts(Request $request): string
    {
        // Only authors and admins can access
    }
}

IP Whitelisting

Admin Access Restriction

// Create AdminIpFilter
class AdminIpFilter implements IFilter
{
    private array $_allowedIps = [
        '127.0.0.1',
        '192.168.1.100',
        '203.0.113.0/24'
    ];

    public function execute( Request $request ): bool
    {
        $ip = $_SERVER['REMOTE_ADDR'] ?? '';

        foreach( $this->_allowedIps as $allowed )
{
            if( $this->ipInRange( $ip, $allowed ) )
            {
                return true;
            }
        }

        // IP not allowed
        http_response_code( 403 );
        echo "Access denied";
        return false;
    }

    private function ipInRange( string $ip, string $range ): bool
    {
        if( strpos( $range, '/' ) === false )
        {
            return $ip === $range;
        }

        [$subnet, $bits] = explode( '/', $range );
        $ip = ip2long( $ip );
        $subnet = ip2long( $subnet );
        $mask = -1 << (32 - $bits);

        return ($ip & $mask) === ($subnet & $mask);
    }
}

API Security

API Authentication

// API key authentication
class ApiKeyFilter implements IFilter
{
    private IUserRepository $_userRepo;

    public function execute( Request $request ): bool
    {
        $apiKey = $request->getHeader( 'X-API-Key' );

        if( !$apiKey )
{
            http_response_code( 401 );
            echo json_encode( ['error' => 'API key required'] );
            return false;
        }

        $user = $this->_userRepo->findByApiKey( $apiKey );

        if( !$user )
{
            http_response_code( 401 );
            echo json_encode( ['error' => 'Invalid API key'] );
            return false;
        }

        // Store user in request
        $request->setParameter( 'api_user', $user );
        return true;
    }
}

Rate Limiting

The CMS includes rate limiting support (requires neuron-php/routing rate limit extension):

# config/neuron.yaml
api_limit:
  enabled: true
  max_requests: 100
  window: 60
  scope: ip
// Apply rate limiting with attributes
use Neuron\Routing\Attributes\Get;

class ApiController extends Controller
{
    #[Get('/api/posts', name: 'api.posts', filters: ['api_limit'])]
    public function posts(Request $request): string
    {
        // Rate limited API endpoint
        return $this->renderJson(OK, ['posts' => $this->postRepository->all()]);
    }
}

Email Security

SPF, DKIM, DMARC

SPF Record

Add to DNS:

example.com. IN TXT "v=spf1 mx a ip4:203.0.113.5 include:_spf.google.com ~all"

DKIM Signing

Configure DKIM in PHPMailer:

$mail->DKIM_domain = 'example.com';
$mail->DKIM_private = '/path/to/private.key';
$mail->DKIM_selector = 'default';
$mail->DKIM_passphrase = '';

DMARC Policy

Add to DNS:

_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:[email protected]"

Preventing Email Enumeration

Generic Error Messages

// In PasswordResetter::requestReset()
public function requestReset( string $email ): bool
{
    $user = $this->_userRepo->findByEmail( $email );

    if( !$user )
{
        // Don't reveal that email doesn't exist
        // Return success but don't send email
        return true;
    }

    // Send reset email
    // ...

    return true;
}

Rate Limiting Email Operations

// Limit password reset requests per IP
$key = 'password_reset:' . $_SERVER['REMOTE_ADDR'];
$requests = $cache->get( $key, 0 );

if( $requests >= 5 )
{
    throw new Exception( 'Too many requests. Please try again later.' );
}

$cache->set( $key, $requests + 1, 3600 ); // 1 hour window

Dependency Security

Keeping Dependencies Updated

Regular Updates

# Check for outdated packages
composer outdated

# Update dependencies
composer update

# Update specific package
composer update neuron-php/mvc

Security Audits

# Check for known vulnerabilities
composer audit

# Use Roave Security Advisories
composer require --dev roave/security-advisories:dev-latest

This prevents installation of packages with known vulnerabilities.

Vulnerability Scanning

Automated Scanning

Add to CI/CD pipeline:

# .github/workflows/security.yml
name: Security Audit

on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Security Audit
        run: composer audit

Logging and Monitoring

Security Event Logging

Log Security Events

use Neuron\Log\Log;

// Successful login
Log::info( "User logged in", [
    'user_id' => $user->getId(),
    'username' => $user->getUsername(),
    'ip' => $_SERVER['REMOTE_ADDR'],
    'user_agent' => $_SERVER['HTTP_USER_AGENT']
]);

// Failed login
Log::warning( "Failed login attempt", [
    'username' => $username,
    'ip' => $_SERVER['REMOTE_ADDR'],
    'reason' => 'invalid_password'
] );

// Account lockout
Log::warning( "Account locked due to too many failed attempts", [
    'user_id' => $user->getId(),
    'username' => $user->getUsername(),
    'ip' => $_SERVER['REMOTE_ADDR']
]);

// Privilege escalation attempt
Log::error( "Unauthorized access attempt", [
    'user_id' => $user->getId(),
    'required_role' => 'admin',
    'user_role' => $user->getRole(),
    'route' => $_SERVER['REQUEST_URI'],
    'ip' => $_SERVER['REMOTE_ADDR']
]);

Log Analysis

Identify Attack Patterns

# Multiple failed login attempts from same IP
grep "Failed login attempt" logs/application.log | \
    grep -oP 'ip":\s*"\K[^"]+' | \
    sort | uniq -c | sort -rn | head -20

# Privilege escalation attempts
grep "Unauthorized access attempt" logs/application.log | tail -50

# Account lockouts
grep "Account locked" logs/application.log | tail -20

Automated Alerting

// scripts/monitor-failed-logins.php
<?php
require __DIR__ . '/../vendor/autoload.php';

$logFile = __DIR__ . '/../logs/application.log';
$threshold = 10; // Alert after 10 failed attempts

$handle = fopen( $logFile, 'r' );
$failedLogins = [];

while( ($line = fgets( $handle )) !== false )
{
    if( strpos( $line, 'Failed login attempt' ) !== false )
    {
        preg_match( '/"ip":\s*"([^"]+ )"/', $line, $matches );
        $ip = $matches[1] ?? 'unknown';
        $failedLogins[$ip] = ($failedLogins[$ip] ?? 0) + 1;
    }
}

fclose( $handle );

foreach( $failedLogins as $ip => $count )
{
    if( $count >= $threshold )
{
        // Send alert
        mail( '[email protected]',
            "Security Alert: Multiple Failed Logins",
            "IP {$ip} has {$count} failed login attempts" );
    }
}

Run via cron:

*/5 * * * * /usr/bin/php /path/to/scripts/monitor-failed-logins.php

Intrusion Detection

File Integrity Monitoring

# Create baseline
find /var/www/cms -type f -exec sha256sum {} \; > /var/backups/file-integrity.txt

# Check for changes (run daily)
#!/bin/bash
CURRENT=$(mktemp)
find /var/www/cms -type f -exec sha256sum {} \; > "$CURRENT"

if ! diff -q /var/backups/file-integrity.txt "$CURRENT" > /dev/null; then
    echo "WARNING: File integrity check failed"
    diff /var/backups/file-integrity.txt "$CURRENT" | mail -s "File Integrity Alert" [email protected]
fi

rm "$CURRENT"

Incident Response

Security Incident Procedures

Detection

  1. Monitor logs for suspicious activity
  2. Set up automated alerts
  3. Review user reports

Containment

# Immediately disable compromised accounts
php scripts/disable-user.php [email protected]

# Block attacking IP addresses
iptables -A INPUT -s 203.0.113.50 -j DROP

# Enable maintenance mode
# Edit config/neuron.yaml: maintenance.enabled: true

Recovery

  1. Identify scope of breach
  2. Patch vulnerabilities
  3. Reset compromised passwords
  4. Review and restore from backup if needed
  5. Update dependencies
  6. Strengthen security measures

Post-Incident

  1. Document incident timeline
  2. Analyze root cause
  3. Update security procedures
  4. Notify affected users (if required by law)
  5. Implement preventive measures

Backup Strategy

Regular Backups

# Daily automated backup
0 2 * * * /path/to/scripts/backup-db.sh
0 3 * * * rsync -av /var/www/cms /backup/cms-$(date +\%Y\%m\%d)

Offsite Storage

# Sync to remote server
rsync -avz -e ssh /backup/ [email protected]:/backups/cms/

# Or use cloud storage
aws s3 sync /backup/ s3://my-backups/cms/

Test Restores

# Quarterly restore tests
# scripts/test-restore.sh
#!/bin/bash
# Restore to test environment
# Verify data integrity
# Document process

Hardening Checklist

Pre-Production Security Checklist

Compliance Considerations

GDPR Compliance

Data Protection Principles

  1. Lawfulness, Fairness, Transparency: Inform users about data collection
  2. Purpose Limitation: Collect data only for specified purposes
  3. Data Minimization: Collect only necessary data
  4. Accuracy: Keep data accurate and up to date
  5. Storage Limitation: Delete data when no longer needed
  6. Integrity and Confidentiality: Protect data from unauthorized access
  7. Accountability: Demonstrate compliance

User Rights Implementation

Right to Access:

// Export user data
public function exportUserData( int $userId ): array
{
    $user = $this->_userRepo->findById( $userId );
    $posts = $this->_postRepo->findByAuthorId( $userId );

    return [
        'user' => $user->toArray(),
        'posts' => array_map( fn($p ) => $p->toArray(), $posts ),
        'exported_at' => date( 'Y-m-d H:i:s' )
    ];
}

Right to Erasure ("Right to be Forgotten"):

// Delete user and associated data
public function deleteUserData( int $userId ): void
{
    // Anonymize or delete posts
    $posts = $this->_postRepo->findByAuthorId( $userId );
    foreach( $posts as $post )
{
        $post->setAuthorId( null ); // Anonymize
        $post->setAuthor( 'Deleted User' );
        $this->_postRepo->update( $post );
    }

    // Delete user
    $this->_userDeleter->delete( $userId );
}

Right to Data Portability:

// Export in machine-readable format
public function exportUserDataJson( int $userId ): string
{
    $data = $this->exportUserData( $userId );
    return json_encode( $data, JSON_PRETTY_PRINT );
}

Consent Management

// Track consent
$user->setPrivacyPolicyAccepted( true );
$user->setPrivacyPolicyAcceptedAt( new DateTimeImmutable() );
$user->setMarketingEmailsConsent( $_POST['marketing_consent'] === '1' );
$userRepo->update( $user );

PCI DSS Compliance

Note: If handling payment card data, PCI DSS compliance is required.

Key Requirements:

  1. Never Store Sensitive Authentication Data: No CVV, PIN, or full track data
  2. Encrypt Transmission: All card data must be transmitted over secure channels
  3. Use Approved Payment Processors: Integrate with PCI-compliant payment gateways (Stripe, PayPal, etc.)
  4. Minimize Data Storage: Store only necessary data
  5. Regular Security Testing: Vulnerability scans and penetration tests

Recommended Approach:

Use tokenization through payment processor:

// Never handle card data directly
// Use payment processor's JavaScript SDK to tokenize

// Server-side: Process token only
$token = $_POST['payment_token'];
$amount = $_POST['amount'];

$charge = $paymentProcessor->createCharge( [
    'amount' => $amount,
    'token' => $token,
    'description' => 'Order #12345'
] );

Additional Resources