[IMP] bus, tools: add rate limiting to incoming websocket messages

This commit is part of the websocket integration in Odoo.
It focuses on implementing rate limiting to the incoming
websocket messages. When opening a connection, a burst of messages
is allowed. When requests are received too fast, an exception is
raised and the websocket is closed.

Two config parameters are added to customize the rate limtier
behavior:
   - websocket_rate_limit_delay: Integer specifying the seconds that
     should space out two requests.
   - websocket_rate_limit_burst: Integer specifying how many websocket
     frames can be accepted in excess of the specified rate.

Part-of: odoo/odoo#75510
This commit is contained in:
tsm-odoo
2022-08-23 17:55:09 +02:00
parent e06bb9a42d
commit f8e794a864
5 changed files with 98 additions and 1 deletions
+1
View File
@@ -2,3 +2,4 @@ from . import common
from . import test_assetsbundle
from . import test_health
from . import test_websocket_caryall
from . import test_websocket_rate_limiting
+1 -1
View File
@@ -12,7 +12,7 @@ except ImportError:
import odoo.tools
from odoo.tests import HOST, common
from odoo.addons.bus.websocket import CloseCode, WebsocketConnectionHandler
from ..websocket import CloseCode, WebsocketConnectionHandler
class WebsocketCase(common.HttpCase):
@@ -0,0 +1,62 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import time
from websocket._exceptions import WebSocketProtocolException
from odoo.tests import common
from .common import WebsocketCase
from ..websocket import CloseCode, Websocket
@common.tagged('post_install', '-at_install')
class TestWebsocketRateLimiting(WebsocketCase):
def test_rate_limiting_base_ok(self):
ws = self.websocket_connect()
for _ in range(Websocket.RL_BURST + 1):
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
time.sleep(Websocket.RL_DELAY)
def test_rate_limiting_base_ko(self):
ws = self.websocket_connect()
# Websocket client's close codes are not up to date. Indeed, the
# 1013 close code results in a protocol exception while it is a
# valid, registered close code ("TRY LATER") :
# https://www.iana.org/assignments/websocket/websocket.xhtml
with self.assertRaises(WebSocketProtocolException) as cm:
for _ in range(Websocket.RL_BURST + 1):
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
self.assert_close_with_code(ws, CloseCode.TRY_LATER)
self.assertEqual(str(cm.exception), 'Invalid close opcode.')
def test_rate_limiting_opening_burst(self):
ws = self.websocket_connect()
# first RL_BURST requests are accepted.
for _ in range(Websocket.RL_BURST):
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
# sending at a correct rate after burst should be accepted.
for _ in range(2):
time.sleep(Websocket.RL_DELAY)
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
def test_rate_limiting_start_ok_end_ko(self):
ws = self.websocket_connect()
# first requests are legit and should be accepted
for _ in range(Websocket.RL_BURST + 1):
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
time.sleep(Websocket.RL_DELAY)
# Websocket client's close codes are not up to date. Indeed, the
# 1013 close code results in a protocol exception while it is a
# valid, registered close code ("TRY LATER") :
# https://www.iana.org/assignments/websocket/websocket.xhtml
with self.assertRaises(WebSocketProtocolException) as cm:
# those requests are illicit and should not be accepted.
for _ in range(Websocket.RL_BURST * 2):
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
self.assert_close_with_code(ws, CloseCode.TRY_LATER)
self.assertEqual(str(cm.exception), 'Invalid close opcode.')
+32
View File
@@ -9,6 +9,7 @@ import struct
import selectors
import threading
import time
from collections import deque
from contextlib import suppress
from enum import IntEnum
from itertools import count
@@ -95,6 +96,13 @@ class ProtocolError(WebsocketException):
"""
class RateLimitExceededException(Exception):
"""
Raised when a client exceeds the number of request in a given
time.
"""
# ------------------------------------------------------
# WEBSOCKET
# ------------------------------------------------------
@@ -201,6 +209,10 @@ class Websocket:
# or received within CONNECTION_TIMEOUT - 15 seconds.
CONNECTION_TIMEOUT = 60
INACTIVITY_TIMEOUT = CONNECTION_TIMEOUT - 15
# How many requests can be made in excess of the given rate.
RL_BURST = int(config['websocket_rate_limit_burst'])
# How many seconds between each request.
RL_DELAY = float(config['websocket_rate_limit_delay'])
def __init__(self, socket):
self._socket = socket
@@ -211,6 +223,8 @@ class Websocket:
self._selector.register(self._socket, selectors.EVENT_READ)
self._selector.register(self._outgoing_frame_queue, selectors.EVENT_READ)
self._timeout_manager = TimeoutManager()
# Used for rate limiting.
self._incoming_frame_timestamps = deque(maxlen=type(self).RL_BURST)
self.state = ConnectionState.OPEN
type(self)._instances.add(self)
@@ -318,6 +332,7 @@ class Websocket:
payload[3::4] = payload[3::4].translate(d)
return payload
self._limit_rate()
first_byte, second_byte = recv_bytes(2)
fin, rsv1, rsv2, rsv3 = (is_bit_set(first_byte, n) for n in range(4))
try:
@@ -503,11 +518,28 @@ class Websocket:
code = CloseCode.INCONSISTENT_DATA
elif isinstance(exc, PayloadTooLargeException):
code = CloseCode.MESSAGE_TOO_BIG
elif isinstance(exc, RateLimitExceededException):
code = CloseCode.TRY_LATER
if code is CloseCode.SERVER_ERROR:
reason = None
_logger.error(exc, exc_info=True)
self.disconnect(code, reason)
def _limit_rate(self):
"""
This method is a simple rate limiter designed not to allow
more than one request by `RL_DELAY` seconds. `RL_BURST` specify
how many requests can be made in excess of the given rate at the
begining. When requests are received too fast, raises the
`RateLimitExceededException`.
"""
now = time.time()
if len(self._incoming_frame_timestamps) >= type(self).RL_BURST:
elapsed_time = now - self._incoming_frame_timestamps[0]
if elapsed_time < type(self).RL_DELAY * type(self).RL_BURST:
raise RateLimitExceededException()
self._incoming_frame_timestamps.append(now)
@classmethod
def _kick_all(cls):
""" Disconnect all the websocket instances. """
+2
View File
@@ -78,6 +78,8 @@ class configmanager(object):
'reportgz': False,
'root_path': None,
'websocket_keep_alive_timeout': 600,
'websocket_rate_limit_burst': 10,
'websocket_rate_limit_delay': 0.2,
}
# Not exposed in the configuration file.