Skip to content

Security

samuelgfeller edited this page Mar 26, 2024 · 11 revisions

Introduction

Web application security is one of the most important aspects of web development and is every developer's responsibility.

I strongly recommend learning the basics of web application security and Survive The Deep End: PHP Security by Padraic Brady which explains the most common vulnerabilities very clearly in detail.

The focus in this chapter is the implementation of input validation, the protection against SQL injection through parameterized queries, cross-site scripting through output escaping, and brute force attacks with request throttling.

Input validation

Validation is the outermost layer of defense.

A significant majority of web application vulnerabilities arise from a validation failure, so getting this part right is essential. It's one of the most fundamental defenses that a web application relies upon.

Creating an own validation system that is robust and secure is time-consuming and error-prone.
Therefore, the task of validating user input is done with the help of the cakephp/validation library.

The Validation chapter explains how the library is used.

Parameterized queries

Instead of directly including user input in the SQL statement, a placeholder (like a question mark or a named parameter) must be used and the input is supplied as a separate argument.
This ensures that the input is treated strictly as data and not as part of the SQL command, thereby preventing SQL injection.

Even though the native PDO library supports parameterized queries, a QueryBuilder makes it easier to build secure queries.

The Repository chapter explains how the database is accessed with the library cakephp/database which automatically uses parameterized queries and supports the use of named parameters when raw SQL is needed.

Output escaping

Cross-site scripting (XSS) occurs when an attacker is able to inject a script (often JavaScript) into an application in such a way that it is executed in the browser of other users.

Its potential for damage is often underestimated. Injected JavaScript can be used among other things to steal session cookies, redirect users to malicious websites, or take over the UI via the DOM and disguise components to trick users into entering sensitive info or clicking on malicious links.

As protection, all output has to be escaped so that it's treated as plain text by the browser and not interpreted as HTML or JavaScript.

The frontend must not trust any values that are coming from the backend.

Template renderer escaping

Every value rendered in the template renderer templates has to be escaped with the autoloaded global function html().

Function declaration

File: config/functions.php

function html(?string $text = null): string
{
    return htmlspecialchars($text ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

Usage

<?= html($client->firstName) ?>

JavaScript escaping

When rendering values in JavaScript, the same escaping has to be applied.

The JavaScript function html() serves this purpose.

Function declaration

File: public/assets/general/general-js/functions.js

export function html(unsafeString) {
    return unsafeString?.toString()
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}

Usage

let html = `<p>${html(client.firstName)}</p>`;

Request throttling

Certain requests should be restricted to a specific number within a given time period to prevent potential harm to the application or server.

This is particularly crucial for tasks such as authentication or when dealing with an external API that imposes rate limits and may result in additional costs.

Authentication

A common method employed to gain access to a user account is a brute-force attack.
This occurs when an attacker attempts to guess the password by systematically trying numerous combinations, often with the help of a list of commonly used passwords.

This attack involves sending a large number of requests to the server within a brief timeframe.
Throttling can mitigate this risk by restricting the number of requests to a predefined limit per time period.

The throttling is implemented in the src/Domain/Security namespace.

Individual login requests

Login requests that involve one user or are coming from the same IP-address are throttled with the setting $settings['security']['login_throttle_rule'].

The key is the number of failed login requests, and the value is the delay in seconds. As value, a number can be used to define a fixed delay, or the string 'captcha' to require the user to fill out a captcha form.

The throttling only applies to login requests in the past $settings['security']['timespan'] seconds.

To prevent bots from increasing the total number of login requests to manipulate the global threshold, the same limit is enforced for failed and successful login requests.

Global login rules

Instead of attempting the 1000 most common passwords for a single user, an attacker may opt to try the single most common password across 1000 different users.
This type of attack is called password spraying.

As a protection for this, a global login failure threshold can be defined with the config value $settings['security']['login_failure_percentage'].

If the threshold value is set to 20, for example, a CAPTCHA is required for all users in the if the ratio of failed login requests exceeds 20% of the total number of login requests recorded in the past month.

If there are 200 logged login requests, the threshold is reached at 40 failed login requests.

This rule is only active when there is a significant number of total login requests (when the calculated failure threshold is more than 20).

To prevent an attacker from increasing the total login requests with a lot of successful requests on accounts they own and thus manipulating the global threshold, the individual successful login requests are throttled with the same limit as the failed requests.

Configuration

The default configuration values for the throttling are defined in config/defaults.php.

// Bool if login requests should be throttled
$settings['security']['throttle_login'] = true;
// Seconds in the past that should be considered for throttling
$settings['security']['timespan'] = 3600;
// key = failed request amount for throttling to apply; value = delay in seconds or 'captcha'; Lowest to highest
$settings['security']['login_throttle_rule'] = [4 => 10, 9 => 120, 12 => 'captcha'];
// Percentage of login requests that may be failures globally (threshold). Timespan is one month.
$settings['security']['login_failure_percentage'] = 20;

Usage

On a login action, before verifying if the password is correct, the function to perform the login security check is called.
If it fails, a SecurityException is thrown storing the remaining delay.

Failed and successful authentication requests are logged in the database to make the future security checks.

The main login service class (i.e. LoginVerifier) is responsible for calling the performLoginSecurityCheck function from the src/Domain/Security/Service/SecurityLoginChecker.php class and logging the request with the logLoginRequest method of the src/Domain/Authentication/Service/AuthenticationLogger.php.

File: src/Domain/Authentication/Service/LoginVerifier.php

public function verifyLoginAndGetUserId(array $userLoginValues, array $queryParams = []): int
{
    // Validate submitted values
    $this->userValidator->validateUserLogin($userLoginValues);
    $captcha = $userLoginValues['g-recaptcha-response'] ?? null;
   
    // Perform login security check before verifying credentials (throws SecurityException if failed)
    $this->loginSecurityChecker->performLoginSecurityCheck($userLoginValues['email'], $captcha);
    
    // Verify credentials
    // ...
    // If successful, log request with true as second param
    $this->authenticationLogger->logLoginRequest($userLoginValues['email'], true);
    // return the user id
    
    // If unsuccessful, log request with false as second param
    $this->loginSecurityChecker->logLoginRequest($userLoginValues['email'], false);
    // If credentials are invalid, the login security check is performed again to show the correct delay
    // after this new failure.
    $this->loginSecurityChecker->performLoginSecurityCheck($userLoginValues['email'], $captcha);
}

Email limit

To prevent spamming and other malicious activities, a limit should be placed on the number of emails that can be sent within a given time period.

Individual email requests

Throttling is applied to emails that are sent to the same recipient or sent from the same user.

With the values $settings['security']['user_email_throttle_rule'], the threshold and the delay for a next request can be defined for individual email requests.

This throttling only applies to emails being sent in the past $settings['security']['timespan'] seconds.

The key 'user_email_throttle_rule' can be omitted if no individual throttling is desired.

Global email rules

A global daily and monthly limit can be defined with the config values $settings['security']['global_daily_email_threshold'] and $settings['security']['global_monthly_email_threshold'].

When the threshold is reached, a captcha is required for anyone wanting to send emails.

These rules can also be omitted if no global throttling is desired.

Configuration

The default configuration values for the email throttling are defined in config/defaults.php.

// Bool if email requests should be throttled
$settings['security']['throttle_email'] = true;
// Seconds in the past that should be considered for throttling (same value as for authentication)
$settings['security']['timespan'] = 3600;
// Optional configurations
// key = sent email amount for throttling to apply; value = delay in seconds or 'captcha'; Lowest to highest
$settings['security']['user_email_throttle_rule'] = [5 => 2, 10 => 4, 20 => 'captcha'],
// Daily site-wide limit before throttling begins
$settings['security']['global_daily_email_threshold'] = 300,
// Monthly site-wide limit before throttling
$settings['security']['global_monthly_email_threshold'] = 900,

Usage

In each action that involves sending an email, the function to perform the email security check has to be called before the mail is sent.

File: src/Domain/Authentication/Service/PasswordRecoveryEmailSender.php

public function sendPasswordRecoveryEmail(array $userValues): void
{
    $this->userValidator->validatePasswordResetEmail($userValues);
    
    // Verify that user doesn't spam email sending
    $this->securityEmailChecker->performEmailAbuseCheck(
        $userValues['email'],
        $userValues['g-recaptcha-response'] ?? null
    );
    
    // ...
    
    // Send email
    $this->mailer->send($email);
    
    // ...
}

The email request is logged to the database in the mailer helper send function.

File: src/Infrastructure/Service/Mailer.php

public function send(Email $email): void
{
    $this->mailer->send($email);

    // Log email request
    $this->emailLoggerRepository->logEmailRequest(
        $email->getFrom()[0]->getAddress(),
        $email->getTo()[0]->getAddress(),
        $email->getSubject() ?? '',
        $this->loggedInUserId
    );
}

The reason the send function not also performs the email security check is that if a site-wide throttling is applied the captcha response is needed to be able to send the email, and it'd be cumbersome to pass it from the action class all the way to the send function each time.

Testing

To ensure that the login and email security checks work as expected and the correct throttling is applied, the SecurityLoginChecker and SecurityEmailChecker are unit and integration tested with each threshold for individual and global requests.


References

Clone this wiki locally