Skip to content

Security: WavestormSoftware/wavephp-starter

Security

docs/security.md

Security

WavePHP provides built-in protection against common web vulnerabilities through the WavePHP\Security\Security class and WavePHP\HTTP\Middleware\SecurityMiddleware.

Table of Contents


CSRF Protection

Cross-Site Request Forgery (CSRF) tokens prevent attackers from submitting requests on behalf of your users.

Generating and Verifying Tokens

use WavePHP\Security\Security;

// Generate or retrieve a token
$token = Wave::csrfToken();

// Output a hidden form field
echo Wave::csrfField();
// <input type="hidden" name="_token" value="...">

// Output a meta tag (useful for AJAX)
echo Security::csrfMeta();
// <meta name="csrf-token" content="...">

// Verify a submitted token (consumed after verification by default)
if (!Security::verifyCsrfToken($request->input('_token'))) {
    return $response->status(419)->json(['error' => 'CSRF token mismatch']);
}

CSRF Middleware

Apply CSRF checking globally. GET, HEAD, and OPTIONS requests are excluded by default since they should not change state.

Wave::middleware(Security::csrfMiddleware([
    'exclude_methods' => ['GET', 'HEAD', 'OPTIONS'],
    'exclude_routes'  => ['/api/webhooks/*', '/api/public/*'],
    'error_handler'   => function ($request, $response) {
        return $response->json(['error' => 'CSRF token mismatch'], 419);
    },
]));

AJAX Requests

Include the token in a request header:

fetch('/api/endpoint', {
    method: 'POST',
    headers: {
        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
        'Content-Type': 'application/json',
    },
    body: JSON.stringify(data),
});

XSS Prevention

Sanitizing Input

use WavePHP\Security\Security;

// Remove dangerous HTML and script tags
$clean = Security::sanitizeInput($userInput);

// Targeted XSS cleaning
$clean = Security::xssClean($userInput);

Detecting Attacks

if (Security::detectXss($userInput)) {
    throw new \WavePHP\Exceptions\SecurityException('Potential XSS detected');
}

Template Output

WavePHP templates escape output by default:

  • {{ $variable }} -- HTML-escaped (safe)
  • {!! $variable !!} -- raw output (use only with trusted content)

SecurityMiddleware XSS Protection

use WavePHP\HTTP\Middleware\SecurityMiddleware;

Wave::middleware(new SecurityMiddleware([
    'xss_protection'  => true,
    'excluded_routes' => ['/api/raw-content'],
]));

SQL Injection Prevention

Always use parameterized queries. WavePHP's Database class binds parameters automatically:

use WavePHP\Database\Database;

$db = Wave::resolve(Database::class);

// Parameterized query -- values are never interpolated into the SQL string
$users = $db->query(
    'SELECT * FROM users WHERE email = ? AND status = ?',
    [$email, 'active']
);

// Insert, update, and delete methods bind parameters internally
$db->insert('users', ['name' => $name, 'email' => $email]);
$db->update('users', ['name' => $newName], ['id' => $userId]);

The SecurityMiddleware can also detect common SQL injection patterns in request parameters and block them before they reach your application:

Wave::middleware(new SecurityMiddleware([
    'xss_protection' => true, // also enables SQL injection scanning
]));

Security Headers

Using SecurityMiddleware

Wave::middleware(new SecurityMiddleware([
    'xss_protection'         => true,
    'content_type_sniffing'  => true,
    'frame_protection'       => true,
    'frame_option'           => 'DENY',
    'content_security_policy' => [
        'default-src'     => "'self'",
        'script-src'      => "'self'",
        'style-src'       => "'self' 'unsafe-inline'",
        'img-src'         => "'self' data: https:",
        'frame-ancestors' => "'none'",
    ],
]));

Using Security::applySecurityHeaders

For more control, apply headers in an after-middleware:

Wave::after(function ($request, $response) {
    return Security::applySecurityHeaders($response, [
        'enable_default_headers'      => true,
        'content_security_policy'     => "default-src 'self'",
        'strict_transport_security'   => 'max-age=31536000; includeSubDomains; preload',
        'custom_headers' => [
            'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
        ],
    ]);
});

Default Headers

These headers are applied automatically when enable_default_headers is true:

Header Value
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection 1; mode=block
Referrer-Policy strict-origin-when-cross-origin
X-Permitted-Cross-Domain-Policies none

Rate Limiting

Rate limiting protects against brute-force attacks and API abuse.

Basic Usage

use WavePHP\Security\Security;

$ip  = $request->ip();
$key = $ip . ':login';

// Allow 5 attempts in a 300-second window
if (!Security::checkRateLimit($key, 5, 300)) {
    throw \WavePHP\Exceptions\SecurityException::rateLimitExceeded(300);
}

Rate Limit Middleware

$rateLimitMiddleware = function ($request, $response, $next) {
    $ip   = $request->ip();
    $path = $request->path();

    $limits = [
        '/api/login'    => ['max' => 5,  'window' => 300],
        '/api/register' => ['max' => 3,  'window' => 3600],
        '/api/upload'   => ['max' => 10, 'window' => 60],
    ];

    if (isset($limits[$path])) {
        $key   = $ip . ':' . $path;
        $limit = $limits[$path];

        if (!Security::checkRateLimit($key, $limit['max'], $limit['window'])) {
            throw \WavePHP\Exceptions\SecurityException::rateLimitExceeded($limit['window']);
        }
    }

    return $next($request, $response);
};

Wave::middleware($rateLimitMiddleware);

Rate Limit Response Headers

Wave::get('/api/resource', function ($request, $response) {
    $key = $request->ip() . ':api';
    $max = 100;

    $response->header('X-RateLimit-Limit', $max);
    $response->header('X-RateLimit-Remaining', Security::getRemainingAttempts($key, $max));

    if (!Security::checkRateLimit($key, $max, 60)) {
        return $response->status(429)->json(['error' => 'Rate limit exceeded']);
    }

    return $response->json(['data' => 'resource']);
});

File Upload Security

Validate Type and Size

use WavePHP\Security\Security;

Wave::post('/upload', function ($request, $response) {
    $file = $request->files('avatar');

    if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
        return $response->status(400)->json(['error' => 'No file uploaded']);
    }

    // Allow only images, max 5 MB
    if (!Security::validateFileUpload($file, ['jpg', 'jpeg', 'png', 'webp'], 5 * 1024 * 1024)) {
        return $response->status(400)->json(['error' => 'Invalid file type or size']);
    }

    // Scan for malicious content
    if (Security::scanForMalware($file['tmp_name'])) {
        return $response->status(400)->json(['error' => 'File failed security scan']);
    }

    // Generate a safe random filename and store outside the web root
    $ext      = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    $filename = Security::randomString(32) . '.' . $ext;
    move_uploaded_file($file['tmp_name'], '../storage/uploads/' . $filename);

    return $response->json(['success' => true, 'filename' => $filename]);
});

Sanitize Filenames

$safeFilename = Security::sanitizeFilename($file['name']);

Dangerous Extensions

WavePHP blocks executable extensions by default (php, phtml, phar, asp, etc.). You can extend the list:

Security::addDangerousExtension('jar');
$blocked = Security::getDangerousExtensions();

Password Hashing

WavePHP uses bcrypt by default.

Hash and Verify

use WavePHP\Security\Security;

// Hash a password
$hash = Security::hashPassword($password);

// Hash with a custom cost factor
$hash = Security::hashPassword($password, ['cost' => 12]);

// Verify a password against a stored hash
if (Security::verifyPassword($password, $hash)) {
    // Password is correct
}

In Practice

// Registration
$db->insert('users', [
    'email'    => $email,
    'password' => Security::hashPassword($password),
]);

// Login
$user = $db->findOne('users', ['email' => $email]);

if (!$user || !Security::verifyPassword($password, $user['password'])) {
    return $response->status(401)->json(['error' => 'Invalid credentials']);
}

Encryption

WavePHP encrypts data with AES-256-GCM, which provides both confidentiality and integrity.

Encrypt and Decrypt

use WavePHP\Security\Security;

$key = getenv('ENCRYPTION_KEY');

// Encrypt -- returns a base64 string containing IV + ciphertext + auth tag
$encrypted = Security::encrypt($sensitiveData, $key);

// Decrypt -- returns null if the key is wrong or data is corrupted
$decrypted = Security::decrypt($encrypted, $key);

if ($decrypted === null) {
    throw new \RuntimeException('Decryption failed');
}

Random String Generation

// Cryptographically secure hex string
$token  = Security::randomString(64);
$apiKey = 'sk_' . Security::randomString(32);

Constant-Time Comparison

Use this when comparing tokens to prevent timing attacks:

if (Security::constantTimeCompare($expected, $provided)) {
    // Tokens match
}

SecurityException Handling

All security violations throw WavePHP\Exceptions\SecurityException with a specific status code and error code.

Available Exception Types

Factory Method Description HTTP Status
csrfTokenMismatch() CSRF validation failed 403
rateLimitExceeded($retryAfter) Too many requests 429
suspiciousInput($field) Suspicious input detected 400
invalidToken($type) Invalid token 401
tokenExpired($type) Token expired 401
ipBlocked($ip, $reason) IP address blocked 403
xssDetected($field) XSS attack detected 400
sqlInjectionDetected($field) SQL injection detected 400
unauthorizedAccess($resource) Unauthorized access attempt 403

Global Error Handler

use WavePHP\Exceptions\SecurityException;

Wave::error(function (\Throwable $e, $request, $response) {
    if ($e instanceof SecurityException) {
        Wave::error('Security violation', [
            'code'    => $e->getErrorCode(),
            'message' => $e->getMessage(),
            'context' => $e->getContext(),
            'ip'      => $request->ip(),
            'path'    => $request->path(),
        ]);

        return $response->json([
            'error' => $e->getMessage(),
            'code'  => $e->getErrorCode(),
        ], $e->getStatusCode());
    }

    return null; // Let other handlers process the exception
});

Best Practices

  1. Enable CSRF protection on all POST, PUT, and DELETE routes.
  2. Sanitize user input with Security::sanitizeInput() before processing or storing it.
  3. Use parameterized queries for every database operation -- never concatenate user input into SQL.
  4. Hash passwords with bcrypt using Security::hashPassword() -- never store plaintext.
  5. Encrypt sensitive data at rest with Security::encrypt() and a strong key stored outside version control.
  6. Rate-limit authentication endpoints to slow down brute-force attacks.
  7. Validate file uploads for type, size, and content before saving them.
  8. Set security headers on every response via SecurityMiddleware.
  9. Log security events so you can detect and respond to incidents.
  10. Keep dependencies updated to patch known vulnerabilities.

See Also

There aren't any published security advisories