Developed with love by KnpLabs Hire us for your project!
37

gentle-force-bundle

by mariusbalcytis

Symfony bundle for brute-force, error and request rate limiting

Gentle-force bundle: brute-force, error and request rate limiting

This is a symfony bundle for rate-limiting both brute-force attempts
(like invalid credentials) and ordinary requests.

It integrates standalone gentle-force library
into Symfony framework.

Features

  • can be used to limit brute-force attempts;
  • can be used for request rate limiting;
  • uses leaky / token bucket algorithm. This means that user does not have to wait for next hour or day - additional attempts are possible as time goes by. This also means that requests do not come in big batches when every hour starts;
  • handles race-conditions. This is important for brute-force limiting. For example, if 1000 requests are issued at the same time to check same user's password, only configured number of attempts will be possible;
  • can have several limits configured for single use-case (for example maximum of 100 requests per minute and 200 per hour);
  • does not make assumptions about where and what it's used for - it can be used with user identifiers, API tokens, IP addresses or any other data to group usages;
  • provides integration with Google reCAPTCHA.

Installation

composer require maba/gentle-force-bundle

Register bundle inside AppKernel.php:

new \Maba\Bundle\GentleForceBundle\MabaGentleForceBundle(),

If using recaptcha

composer require google/recaptcha

Import routing in app/routing.yml:

gentle_force:
    resource: '@MabaGentleForceBundle/Resources/config/routing.xml'

Usage

Usually it's enough to configure listeners in config.yml file.

You can also inject limiting service and incorporate your custom logic -
see advanced usage below.

Configuration

Examples

Example configuration for API request limiting by IP address and user:

maba_gentle_force:
    limits:
        api_request:
                # Allow 10 requests each minute.
                # User can "save up" hour of usage if not using API.
                # This means up to 610 requests at once, after that - 
                # 10 requests per minute, which could again save-up up to 610.
            - max_usages: 10
              period: 1m
              bucketed_period: 1h
    listeners:
        - path: ^/api/          # automatically limit matching requests
          limits_key: api_request
          identifiers: [ip]     # limit by IP address

        - path: ^/api
          limits_key: api_request
            # additionally limit by username, if available
          identifiers: [username]

Example configuration for limiting failures in login form:

maba_gentle_force:
    limits:
        credentials_error:
                # Allow 3 errors per hour,
                # 2 additional errors if no errors were made during last hour:
            - max_usages: 3
              period: 1h
              bucketed_usages: 2

                # Allow 10 errors per day:
            - max_usages: 10
              period: 1d
    listeners:
        - path: ^/login         # match only POST requests to /login*
          methods: [POST]
          limits_key: credentials_error
          identifiers: [ip]
          strategy: recaptcha_page
            # only status 302 is successful in our case
            # response code 200 usually displays error message,
            # while we redirect after success
          success_statuses: ['302']
    strategies:
        recaptcha_template:
            template: custom.html.twig  # optional - overwrite template
    recaptcha:
        site_key: my_recaptcha_site_key # get this at google.com/recaptcha
        secret: my_recaptcha_secret     # this also

Limits

Limits are defined by concrete use-case. It may be your API request,
credentials failure, password reset attempt, registering for email
subscription, checking if username is available etc.

Use any unique key for identifying limit configuration - use same
limit key later to calculate if concrete limit is reached.

Each limit configuration can have several limits defined. This is useful
if you want to have blocking with bigger intervals on more repeating
failures or requests. For example, you can have different limits for
minute, day and week for the same use-case. If any of defined limits
is reached, request is blocked.

You can configure following keys for limits:
- max_usages;
- period. Measured in seconds, you can use suffixes s (seconds),
m (minutes), h (hours), d (days) or w (weeks);
- bucketed_usages. Optional, additional usages available on top of
max_usages. Does not effect speed of additional tokens
(see token bucket);
- bucketed_period. Optional, mutually exclusive with bucketed_usages.
Period for additional usages to be added on top of max_usages if not
being used. Period suffixes available.

Listeners

Each configured listener can potentially block the request.

Filtering

To filter requests on which limit must be applied, use following keys:
- path. Regex to match request path;
- methods. List of request methods;
- hosts. List of hosts.

Limiting

You must always configure limits_key and identifiers for each
listener to use for limiting requests.

identifiers are used to specify items from request that will be used
for limiting. ip and username identifiers are available by default,
you can also register additional identifiers.

If several identifiers are specified, all of them must match for available
limit to be decreased.

Keep in mind, that if at least one identifier is unavailable, limit is not
applied at all. So, if limiting by [ip, username], unauthorised requests
will not be limited at all.

Handling successful requests

For brute-force attempts, bundle needs to check if request was successful or not.
By design, bundle checks and increases usage count in advance, even before
checking if everything is fine. Thus, if request was valid, this count must
be decreased.

For configuring what's considered successful response, use one of the following:
- success_statuses. Provide list of HTTP response statuses that indicates successful response;
- failure_statuses. Same as success_statuses, but in reverse - everything else is considered successful;
- success_matcher. Service ID to use for identifying whether response is
successful. Service must implement SuccessMatcherInterface.

If you skip all three, all requests are considered as a failure - that is,
functionality handles basic request limiting.

Defining strategy for reached limit

Use strategy key to identify strategy to use if limit for this
listener is reached. See strategies below for more information.

Strategies

Following strategies are available:
- headers. Returns pre-configured response with 429 Too Many Requests
status code. This is default one;
- log. Does not modify response, just logs failures. Usable in configuration
testing phase;
- recaptcha_headers. Same as headers but adds recaptcha site key. Can
be used by JavaScript code to initiate recaptcha widget;
- recaptcha_template. Returns HTML response with recaptcha widget.
After successfully submitting recaptcha, current page is refreshed.

You can configure and use your own strategy - just provide service ID
instead of pre-configured key. Strategy must implement StrategyInterface
and optionally ResponseModifyingStrategyInterface to modify successful responses.

Headers

Configuration options:
- content. Content to return in response;
- content_type. Content type for response;
- wait_for_header. Header name to use in rate exceeded responses.
This response header defines minimum time to wait in seconds before
repeating the request;
- requests_available_header. Header name to use in successful responses
to identify how many requests are available at this moment.

Log

You can configure level to use for logging (defaults to error).

Recaptcha

For recaptcha_headers you can configure site_key_header and
unlock_url_header to specify header names to use in rate exceeded response
to provide configured recaptcha site key and unlock absolute url.

For recaptcha_template you can configure template to use for generating
response. See templates inside the bundle for more information about
what data is passed. TwigBundle is needed for this strategy to work.

For both strategies, you must install recaptcha
(see installation) and configure recaptcha site data
(see configuration examples).

When routing is imported, maba_gentle_force_unlock_recaptcha route
is available (POST method). Pass recaptcha response
in g-recaptcha-response field using application/x-www-form-urlencoded
encoding. Empty 200 response means that rate limit was reset.
In case of error, 400 response is returned with JSON content, errors
key will hold array of errors from recaptcha service. See
RecaptchaUnlockController and JavaScript code in twig templates
for more information.

Redis

To configure redis client, either use host (defaults to localhost)
or parameters and options (allows to configure connection to redis sentinels)
or service_id to provide custom Predis\Redis service

You can configure prefix for additional prefix for all created keys.

If you prefer to avoid rate limiting at all if redis connection would
fail, but still serve requests as usual, configure failure_strategy
as ignore. In case of connection failure, you'd get error logged
instead of unhandled exception causing 500 responses.

Full configuration reference

maba_gentle_force:
    redis:
        host:                 ~
        parameters:           []
        options: 
            replication:      ~
            service:          ~
            parameters:
                password:     ~
        service_id:           ~
        prefix:               ~
        failure_strategy:     fail
    limits:
        my_limit_name:
            -
                max_usages: ~
                period: ~
                bucketed_usages: ~
                bucketed_period: ~
    strategies:
        default:              headers
        headers:
            wait_for_header:      null
            requests_available_header: null
            content:              'Too many requests'
            content_type:         'text/plain; charset=UTF-8'
        log:
            level:                error
        recaptcha_headers:
            site_key_header:      null
            unlock_url_header:    null
        recaptcha_template:
            template:             null
    listeners:
        -
            path:                 ^/
            limits_key:           ~
            identifiers:          []
            strategy:             ~
            success_matcher:      ~
            success_statuses:     []
            failure_statuses:     []
            methods:              []
            hosts:                []
    recaptcha:
        site_key:             ~
        secret:               ~
    listener_priorities:
        default: 1000
        post_authentication: 0

Additional identifiers

You can provide additional identifiers to configure in your listeners.

You need to create service which implements IdentifierProviderInterface
and tag it with maba_gentle_force.identifier_provider
(provide name in identifierType attribute).

For example:

<?php

namespace Acme;

use Symfony\Component\HttpFoundation\Request;
use Maba\Bundle\GentleForceBundle\Service\IdentifierProvider\IdentifierProviderInterface;

class UserAgentProvider implements IdentifierProviderInterface
{
    public function getIdentifier(Request $request)
    {
        return $request->headers->get('User-Agent');
    }
}
<service class="Acme\UserAgentProvider">
    <tag name="maba_gentle_force.identifier_provider"
         identifierType="user_agent"/>
</service>
# ...
    listeners:
        - limits_key: api_request
                # limit by IP and User-Agent combination
          identifiers: [ip, user_agent]

Advanced usage

Rate limiting:

/** @var Maba\GentleForce\Throttler $throttler */
$throttler = $container->get('maba_gentle_force.throttler');

try {
    $result = $throttler->checkAndIncrease('api_request', $request->getClientIp());
    $response->headers->set('Requests-Available', $result->getUsagesAvailable());

} catch (RateLimitReachedException $exception) {
    return new Response('', 429, ['Wait-For' => $exception->getWaitForInSeconds()]);
}

Brute force limiting:

try {
    // we must increase error count in-advance before even checking credentials
    // this avoids race-conditions with lots of requests
    $credentialsResult = $throttler->checkAndIncrease('credentials_error', $username);
} catch (RateLimitReachedException $exception) {
    $error = sprintf('Too much password tries for user. Please try after %s seconds', $exception->getWaitForInSeconds());

    return $this->showError($error);
}

$credentialsValid = $credentialsManager->checkCredentials($username, $password);

if ($credentialsValid) {
    // as we've increased error count in advance, we need to decrease it if everything went fine
    $credentialsResult->decrease();

    // log user into system
}

Resetting specific limits manually

There are two commands that can reset the limit manually, if needed.

maba:gentle-force:reset command interactively asks for wanted listener configuration and each identifier
(like username, IP etc.)

maba:gentle-force:reset-limit command takes 2 arguments - limit key and identifier to reset the limit. This
could be used when limits are set with advanced usage and concrete identifier to use is known.

Semantic versioning

This bundle follows semantic versioning.

Public API of this bundle (in other words, you should only use these features if you want to easily update
to new versions):
- only services that are not marked as public="false"
- only classes, interfaces and class methods that are marked with @api
- console commands
- supported DIC tags
- configuration reference
- routing keys and controllers with routes

For example, if only class method is marked with @api, you should not extend that class, as constructor
could change in any release.

See Symfony BC rules for basic information
about what can be changed and what not in the API. Keep in mind, that in this bundle everything is
@internal by default.

Running tests

Travis status

composer update
vendor/bin/phpunit

Contributing

Feel free to create issues and give pull requests.

You can fix any code style issues using this command:

vendor/bin/php-cs-fixer fix --config=.php_cs
The MIT License (MIT)

Copyright (c) 2017 Marius BalĨytis

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
  • allow maba/dependency-injection-extra version 1.0 (#11)
    By mariusbalcytis, 5 months ago
  • Remove memory limit for composer in travis builds
    By web-flow, 6 months ago
  • Allow 0.3 version of gentle-force library (#10)
    By web-flow, 6 months ago
  • Added unlock absolute url header in recaptcha header strategy (#7)
    By mariusbalcytis, 11 months ago
  • Adds ability to configure request listener priorities
    By mariusbalcytis, 11 months ago
  • Checks for logger defined as an alias in the logger compiler pass (#4)
    By mariusbalcytis, 1 year ago
  • Adds failed test case for logger defined via alias (#4)
    By mariusbalcytis, 1 year ago
  • Adds support for identifier priorities, runs as soon as possible
    By mariusbalcytis, 1 year ago
  • Adds overrides for new Symfony rules
    By mariusbalcytis, 1 year ago
  • Uses real security firewall in functional tests
    By mariusbalcytis, 1 year ago
  • Fixes compatibility with Symfony 4.0
    By mariusbalcytis, 1 year ago
  • Changes request matcher path matching (#2)
    By mariusbalcytis, 1 year ago
  • Fixes commands for older Symfony versions and adds lazy loading for newer ones
    By mariusbalcytis, 1 year ago
  • Code style fixes
    By mariusbalcytis, 1 year ago
  • Updates instructions in upgrade.md
    By mariusbalcytis, 1 year ago
  • Adds commands to reset limits manually
    By mariusbalcytis, 1 year ago
  • Define public API
    By mariusbalcytis, 1 year ago
  • Adds upgrade file
    By mariusbalcytis, 1 year ago
  • Code style fixes
    By mariusbalcytis, 1 year ago
  • Adds ability to configure Redis with multiple sentinel URLs (#1)
    By mariusbalcytis, 1 year ago
  • Improves and fixes readme
    By mariusbalcytis, 1 year ago
  • Provides configuration information in readme
    By mariusbalcytis, 1 year ago
  • New code style fixes
    By mariusbalcytis, 1 year ago
  • Ignores redis failures even when custom service is used
    By mariusbalcytis, 1 year ago
  • Fixes tests by providing invalid redis port
    By mariusbalcytis, 1 year ago
  • Refactors test config files to avoid overriding throttler service
    By mariusbalcytis, 1 year ago
  • Uses at least 2.7 version for symfony/debug for tests
    By mariusbalcytis, 1 year ago
  • Adds ability to ignore but log redis connection failures
    By mariusbalcytis, 1 year ago
  • Adds period suffixes in configuration
    By mariusbalcytis, 2 years ago
  • Adds ability to configure error response content and content-type
    By mariusbalcytis, 2 years ago