Skip to content

Commit

Permalink
wsgi: Add middleware for delaying and rejecting requests
Browse files Browse the repository at this point in the history
- Fix #7
  • Loading branch information
paveldedik committed Jun 13, 2019
1 parent cf4754c commit 1e8f428
Show file tree
Hide file tree
Showing 9 changed files with 226 additions and 13 deletions.
2 changes: 2 additions & 0 deletions .bandit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[bandit]
exclude: /test
9 changes: 7 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@ repos:
- id: flake8
language_version: python3.7

- repo: https://github.com/asottile/seed-isort-config
rev: v1.9.1
hooks:
- id: seed-isort-config

- repo: https://github.com/pre-commit/mirrors-isort
rev: v4.3.16
rev: v4.3.20
hooks:
- id: isort
language_version: python3.7

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.1.0
rev: v2.2.3
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
Expand Down
9 changes: 9 additions & 0 deletions kw/platform/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import os


REQUESTS_SLOWDOWN_DATETIME = os.getenv(
"KIWI_REQUESTS_SLOWDOWN_DATETIME", "2019-06-24T13:00:00"
)
REQUESTS_RESTRICT_DATETIME = os.getenv(
"KIWI_REQUESTS_RESTRICT_DATETIME", "2019-08-01T13:00:00"
)
88 changes: 88 additions & 0 deletions kw/platform/wsgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import re
import time
from datetime import datetime

import webob
from dateutil.parser import parse
from webob.dec import wsgify

from . import settings


user_agent_re = re.compile(
r"^(?P<name>\S.+?)\/(?P<version>\S.+?) "
r"\(Kiwi\.com (?P<environment>\S.+?)\)(?: ?(?P<system_info>.*))$"
)
req_slowdown_datetime = parse(settings.REQUESTS_SLOWDOWN_DATETIME)
req_restrict_datetime = parse(settings.REQUESTS_RESTRICT_DATETIME)


def _refuse_request(req, app):
msg = "Invalid User-Agent: does not comply with KW-RFC-22"
return webob.exc.HTTPBadRequest(msg)


def _slowdown_request(req, app):
before_time = time.time()
resp = req.get_response(app)
seconds = time.time() - before_time
time.sleep(seconds)
return resp


@wsgify.middleware
def user_agent_middleware(req, app):
"""Validate client's User-Agent header and modify response based on that.
If the User-Agent header is invalid, there are three things that can happen:
1. The current time is less then :obj:`settings.REQUESTS_SLOWDOWN_DATETIME`,
do nothing in this case.
2. The current time is less then :obj:`settings.REQUESTS_RESTRICT_DATETIME`,
slow down the response twice the normal responce time.
3. The current time is more then :obj:`settings.REQUESTS_RESTRICT_DATETIME`,
refuse the request, return HTTP 400 to the client.
Usage::
from your_app import wsgi_app
wsgi_app = user_agent_middleware(wsgi_app)
For example, in Flask, the middleware can be applied like this::
from flask import Flask
app = Flask(__name__)
app.wsgi_app = user_agent_middleware(app)
app.run()
In Django, you can apply the middleware like this::
from django.core.wsgi import get_wsgi_application
application = user_agent_middleware(get_wsgi_application())
For more information see
`Django docs <https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/>`_.
.. warning::
The middleware slows down requests by calling :meth:`time.sleep()`
(in the time frame when requests with invalid user-agent are being delayed).
This can increase worker busyness which can overload a service.
"""
now = datetime.utcnow()

if now < req_slowdown_datetime:
return app

is_valid = bool(user_agent_re.match(req.user_agent))

if is_valid:
return app
elif now < req_restrict_datetime:
return _slowdown_request(req, app)
else:
return _refuse_request(req, app)
70 changes: 68 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ include = ["*.md", "*.toml", "*.txt", "*.yaml", ".coveragerc", "tox.ini"]

[tool.poetry.dependencies]
python = "~2.7 || ^3.5"
webob = "^1.8"
python-dateutil = "^2.8"

[tool.poetry.dev-dependencies]
pytest = "^4.6"
pytest-mock = "^1.10"
coverage = "^4.5"
7 changes: 0 additions & 7 deletions test/test_nothing.py

This file was deleted.

48 changes: 48 additions & 0 deletions test/test_wsgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import time
from datetime import datetime

import pytest
from webob.request import BaseRequest

from kw.platform import wsgi as uut


def create_app(sleep_seconds=0):
def simple_app(environ, start_response):
if sleep_seconds:
time.sleep(sleep_seconds)
start_response("200 OK", [("Content-Type", "text/html; charset=UTF-8")])
return ["OK"]

return simple_app


@pytest.mark.parametrize(
"user_agent,sleep_seconds,expected_time,expected_status,current_time",
[
("invalid", 0, 0, 200, datetime(2019, 5, 21, 0, 0, 0)),
("invalid", 0, 0, 400, datetime(2020, 1, 10, 0, 0, 0)),
("mambo/1a (Kiwi.com dev)", 0, 0, 200, datetime(2020, 1, 10, 0, 0, 0)),
("invalid", 0.1, 0.2, 200, datetime(2019, 7, 1, 0, 0, 0)),
("mambo/1a (Kiwi.com dev)", 0.1, 0.1, 200, datetime(2019, 7, 1, 0, 0, 0)),
],
)
def test_user_agent_middleware(
mocker, user_agent, sleep_seconds, expected_time, expected_status, current_time
):
req = BaseRequest.blank("/")
req.user_agent = user_agent

mocker.patch(
"kw.platform.wsgi.datetime",
mocker.Mock(utcnow=mocker.Mock(return_value=current_time)),
)
app = create_app(sleep_seconds)
app = uut.user_agent_middleware(app) # pylint: disable=no-value-for-parameter

before_time = time.time()
res = req.get_response(app)
request_time = time.time() - before_time

assert res.status_code == expected_status
assert expected_time >= request_time
3 changes: 1 addition & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,10 @@ atomic = true
force_grid_wrap = 0
include_trailing_comma = true
lines_after_imports = 2
lines_between_types = 1
multi_line_output = 3
not_skip = __init__.py
use_parentheses = true
known_first_party = kw.platform
known_third_party = dateutil,pytest,webob

[flake8]
max-line-length = 88
Expand Down

0 comments on commit 1e8f428

Please sign in to comment.