summary refs log tree commit diff
path: root/synapse
diff options
context:
space:
mode:
Diffstat (limited to 'synapse')
-rw-r--r--synapse/api/errors.py37
-rw-r--r--synapse/api/ratelimiting.py79
-rwxr-xr-xsynapse/app/homeserver.py1
-rw-r--r--synapse/config/_base.py2
-rw-r--r--synapse/config/homeserver.py4
-rw-r--r--synapse/config/ratelimiting.py35
-rw-r--r--synapse/crypto/context_factory.py16
-rw-r--r--synapse/handlers/_base.py15
-rw-r--r--synapse/handlers/message.py2
-rw-r--r--synapse/handlers/room.py3
-rw-r--r--synapse/http/server.py14
-rw-r--r--synapse/rest/room.py4
-rw-r--r--synapse/server.py5
-rw-r--r--synapse/storage/schema/room_aliases.sql15
-rw-r--r--synapse/util/logutils.py1
15 files changed, 212 insertions, 21 deletions
diff --git a/synapse/api/errors.py b/synapse/api/errors.py
index 21ededc5ae..ad79bc7ff9 100644
--- a/synapse/api/errors.py
+++ b/synapse/api/errors.py
@@ -28,6 +28,7 @@ class Codes(object):
     UNKNOWN = "M_UNKNOWN"
     NOT_FOUND = "M_NOT_FOUND"
     UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN"
+    LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED"
 
 
 class CodeMessageException(Exception):
@@ -38,11 +39,15 @@ class CodeMessageException(Exception):
         super(CodeMessageException, self).__init__("%d: %s" % (code, msg))
         self.code = code
         self.msg = msg
+        self.response_code_message = None
+
+    def error_dict(self):
+        return cs_error(self.msg)
 
 
 class SynapseError(CodeMessageException):
     """A base error which can be caught for all synapse events."""
-    def __init__(self, code, msg, errcode=""):
+    def __init__(self, code, msg, errcode=Codes.UNKNOWN):
         """Constructs a synapse error.
 
         Args:
@@ -53,6 +58,11 @@ class SynapseError(CodeMessageException):
         super(SynapseError, self).__init__(code, msg)
         self.errcode = errcode
 
+    def error_dict(self):
+        return cs_error(
+            self.msg,
+            self.errcode,
+        )
 
 class RoomError(SynapseError):
     """An error raised when a room event fails."""
@@ -91,13 +101,26 @@ class StoreError(SynapseError):
     pass
 
 
-def cs_exception(exception):
-    if isinstance(exception, SynapseError):
+class LimitExceededError(SynapseError):
+    """A client has sent too many requests and is being throttled.
+    """
+    def __init__(self, code=429, msg="Too Many Requests", retry_after_ms=None,
+                 errcode=Codes.LIMIT_EXCEEDED):
+        super(LimitExceededError, self).__init__(code, msg, errcode)
+        self.retry_after_ms = retry_after_ms
+        self.response_code_message = "Too Many Requests"
+
+    def error_dict(self):
         return cs_error(
-            exception.msg,
-            Codes.UNKNOWN if not exception.errcode else exception.errcode)
-    elif isinstance(exception, CodeMessageException):
-        return cs_error(exception.msg)
+            self.msg,
+            self.errcode,
+            retry_after_ms=self.retry_after_ms,
+        )
+
+
+def cs_exception(exception):
+    if isinstance(exception, CodeMessageException):
+        return exception.error_dict()
     else:
         logging.error("Unknown exception type: %s", type(exception))
 
diff --git a/synapse/api/ratelimiting.py b/synapse/api/ratelimiting.py
new file mode 100644
index 0000000000..b9b6a9de1a
--- /dev/null
+++ b/synapse/api/ratelimiting.py
@@ -0,0 +1,79 @@
+# Copyright 2014 matrix.org
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import collections
+
+
+class Ratelimiter(object):
+    """
+    Ratelimit message sending by user.
+    """
+
+    def __init__(self):
+        self.message_counts = collections.OrderedDict()
+
+    def send_message(self, user_id, time_now_s, msg_rate_hz, burst_count):
+        """Can the user send a message?
+        Args:
+            user_id: The user sending a message.
+            time_now_s: The time now.
+            msg_rate_hz: The long term number of messages a user can send in a
+                second.
+            burst_count: How many messages the user can send before being
+                limited.
+        Returns:
+            A pair of a bool indicating if they can send a message now and a
+                time in seconds of when they can next send a message.
+        """
+        self.prune_message_counts(time_now_s)
+        message_count, time_start, _ignored = self.message_counts.pop(
+            user_id, (0., time_now_s, None),
+        )
+        time_delta = time_now_s - time_start
+        sent_count = message_count - time_delta * msg_rate_hz
+        if sent_count < 0:
+            allowed = True
+            time_start = time_now_s
+            message_count = 1.
+        elif sent_count > burst_count - 1.:
+            allowed = False
+        else:
+            allowed = True
+            message_count += 1
+
+        self.message_counts[user_id] = (
+            message_count, time_start, msg_rate_hz
+        )
+
+        if msg_rate_hz > 0:
+            time_allowed = (
+                time_start + (message_count - burst_count + 1) / msg_rate_hz
+            )
+            if time_allowed < time_now_s:
+                time_allowed = time_now_s
+        else:
+            time_allowed = -1
+
+        return allowed, time_allowed
+
+    def prune_message_counts(self, time_now_s):
+        for user_id in self.message_counts.keys():
+            message_count, time_start, msg_rate_hz = (
+                self.message_counts[user_id]
+            )
+            time_delta = time_now_s - time_start
+            if message_count - time_delta * msg_rate_hz > 0:
+                break
+            else:
+                del self.message_counts[user_id]
diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py
index 606c9c650d..8a7cd07fec 100755
--- a/synapse/app/homeserver.py
+++ b/synapse/app/homeserver.py
@@ -247,6 +247,7 @@ def setup():
         upload_dir=os.path.abspath("uploads"),
         db_name=config.database_path,
         tls_context_factory=tls_context_factory,
+        config=config,
     )
 
     hs.register_servlets()
diff --git a/synapse/config/_base.py b/synapse/config/_base.py
index 1913179c3a..4b445806da 100644
--- a/synapse/config/_base.py
+++ b/synapse/config/_base.py
@@ -13,8 +13,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-
-import ConfigParser as configparser
 import argparse
 import sys
 import os
diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py
index 18072e3196..a9aa4c735c 100644
--- a/synapse/config/homeserver.py
+++ b/synapse/config/homeserver.py
@@ -17,8 +17,10 @@ from .tls import TlsConfig
 from .server import ServerConfig
 from .logger import LoggingConfig
 from .database import DatabaseConfig
+from .ratelimiting import RatelimitConfig
 
-class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig):
+class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
+                       RatelimitConfig):
     pass
 
 if __name__=='__main__':
diff --git a/synapse/config/ratelimiting.py b/synapse/config/ratelimiting.py
new file mode 100644
index 0000000000..ee572e1fbf
--- /dev/null
+++ b/synapse/config/ratelimiting.py
@@ -0,0 +1,35 @@
+# Copyright 2014 matrix.org
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from ._base import Config
+
+class RatelimitConfig(Config):
+
+    def __init__(self, args):
+        super(RatelimitConfig, self).__init__(args)
+        self.rc_messages_per_second = args.rc_messages_per_second
+        self.rc_message_burst_count = args.rc_message_burst_count
+
+    @classmethod
+    def add_arguments(cls, parser):
+        super(RatelimitConfig, cls).add_arguments(parser)
+        rc_group = parser.add_argument_group("ratelimiting")
+        rc_group.add_argument(
+            "--rc-messages-per-second", type=float, default=0.2,
+            help="number of messages a client can send per second"
+        )
+        rc_group.add_argument(
+            "--rc-message-burst-count", type=float, default=10,
+            help="number of message a client can send before being throttled"
+        )
diff --git a/synapse/crypto/context_factory.py b/synapse/crypto/context_factory.py
index 45958abbf5..344f2dd218 100644
--- a/synapse/crypto/context_factory.py
+++ b/synapse/crypto/context_factory.py
@@ -1,4 +1,18 @@
-from twisted.internet import reactor, ssl
+# Copyright 2014 matrix.org
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from twisted.internet import ssl
 from OpenSSL import SSL
 from twisted.internet._sslverify import _OpenSSLECCurve, _defaultCurveName
 
diff --git a/synapse/handlers/_base.py b/synapse/handlers/_base.py
index b37c8be964..57429ad2ef 100644
--- a/synapse/handlers/_base.py
+++ b/synapse/handlers/_base.py
@@ -14,6 +14,7 @@
 # limitations under the License.
 
 from twisted.internet import defer
+from synapse.api.errors import LimitExceededError
 
 class BaseHandler(object):
 
@@ -25,8 +26,22 @@ class BaseHandler(object):
         self.room_lock = hs.get_room_lock_manager()
         self.state_handler = hs.get_state_handler()
         self.distributor = hs.get_distributor()
+        self.ratelimiter = hs.get_ratelimiter()
+        self.clock = hs.get_clock()
         self.hs = hs
 
+    def ratelimit(self, user_id):
+        time_now = self.clock.time()
+        allowed, time_allowed = self.ratelimiter.send_message(
+            user_id, time_now,
+            msg_rate_hz=self.hs.config.rc_messages_per_second,
+            burst_count=self.hs.config.rc_message_burst_count,
+        )
+        if not allowed:
+            raise LimitExceededError(
+                retry_after_ms=int(1000*(time_allowed - time_now)),
+            )
+
 
 class BaseRoomHandler(BaseHandler):
 
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index 4aeb2089f5..c9e3c4e451 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -76,6 +76,8 @@ class MessageHandler(BaseRoomHandler):
         Raises:
             SynapseError if something went wrong.
         """
+
+        self.ratelimit(event.user_id)
         # TODO(paul): Why does 'event' not have a 'user' object?
         user = self.hs.parse_userid(event.user_id)
         assert user.is_mine, "User must be our own: %s" % (user,)
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 53aa77405c..58afced8f5 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -49,6 +49,7 @@ class RoomCreationHandler(BaseRoomHandler):
             SynapseError if the room ID was taken, couldn't be stored, or
             something went horribly wrong.
         """
+        self.ratelimit(user_id)
 
         if "room_alias_name" in config:
             room_alias = RoomAlias.create_local(
@@ -110,8 +111,6 @@ class RoomCreationHandler(BaseRoomHandler):
                 servers=[self.hs.hostname],
             )
 
-        federation_handler = self.hs.get_handlers().federation_handler
-
         @defer.inlineCallbacks
         def handle_event(event):
             snapshot = yield self.store.snapshot_room(
diff --git a/synapse/http/server.py b/synapse/http/server.py
index 0b87718bfa..74c220e869 100644
--- a/synapse/http/server.py
+++ b/synapse/http/server.py
@@ -140,7 +140,8 @@ class JsonResource(HttpServer, resource.Resource):
             self._send_response(
                 request,
                 e.code,
-                cs_exception(e)
+                cs_exception(e),
+                response_code_message=e.response_code_message
             )
         except Exception as e:
             logger.exception(e)
@@ -150,7 +151,8 @@ class JsonResource(HttpServer, resource.Resource):
                 {"error": "Internal server error"}
             )
 
-    def _send_response(self, request, code, response_json_object):
+    def _send_response(self, request, code, response_json_object,
+                       response_code_message=None):
         # could alternatively use request.notifyFinish() and flip a flag when
         # the Deferred fires, but since the flag is RIGHT THERE it seems like
         # a waste.
@@ -166,7 +168,8 @@ class JsonResource(HttpServer, resource.Resource):
             json_bytes = encode_pretty_printed_json(response_json_object)
 
         # TODO: Only enable CORS for the requests that need it.
-        respond_with_json_bytes(request, code, json_bytes, send_cors=True)
+        respond_with_json_bytes(request, code, json_bytes, send_cors=True,
+                                response_code_message=response_code_message)
 
     @staticmethod
     def _request_user_agent_is_curl(request):
@@ -350,7 +353,8 @@ class ContentRepoResource(resource.Resource):
                 send_cors=True)
 
 
-def respond_with_json_bytes(request, code, json_bytes, send_cors=False):
+def respond_with_json_bytes(request, code, json_bytes, send_cors=False,
+                            response_code_message=None):
     """Sends encoded JSON in response to the given request.
 
     Args:
@@ -362,7 +366,7 @@ def respond_with_json_bytes(request, code, json_bytes, send_cors=False):
     Returns:
         twisted.web.server.NOT_DONE_YET"""
 
-    request.setResponseCode(code)
+    request.setResponseCode(code, message=response_code_message)
     request.setHeader(b"Content-Type", b"application/json")
 
     if send_cors:
diff --git a/synapse/rest/room.py b/synapse/rest/room.py
index a10b3b54f9..d76a2f5cd4 100644
--- a/synapse/rest/room.py
+++ b/synapse/rest/room.py
@@ -388,7 +388,7 @@ class RoomMembershipRestServlet(RestServlet):
     def register(self, http_server):
         # /rooms/$roomid/[invite|join|leave]
         PATTERN = ("/rooms/(?P<room_id>[^/]*)/" +
-            "(?P<membership_action>join|invite|leave)")
+            "(?P<membership_action>join|invite|leave|ban)")
         register_txn_path(self, PATTERN, http_server)
 
     @defer.inlineCallbacks
@@ -399,7 +399,7 @@ class RoomMembershipRestServlet(RestServlet):
 
         # target user is you unless it is an invite
         state_key = user.to_string()
-        if membership_action == "invite":
+        if membership_action in ["invite", "ban"]:
             if "user_id" not in content:
                 raise SynapseError(400, "Missing user_id key.")
             state_key = content["user_id"]
diff --git a/synapse/server.py b/synapse/server.py
index 3e72b2bcd5..35e311a47d 100644
--- a/synapse/server.py
+++ b/synapse/server.py
@@ -32,6 +32,7 @@ from synapse.util import Clock
 from synapse.util.distributor import Distributor
 from synapse.util.lockutils import LockManager
 from synapse.streams.events import EventSources
+from synapse.api.ratelimiting import Ratelimiter
 
 
 class BaseHomeServer(object):
@@ -73,6 +74,7 @@ class BaseHomeServer(object):
         'resource_for_web_client',
         'resource_for_content_repo',
         'event_sources',
+        'ratelimiter',
     ]
 
     def __init__(self, hostname, **kwargs):
@@ -190,6 +192,9 @@ class HomeServer(BaseHomeServer):
     def build_event_sources(self):
         return EventSources(self)
 
+    def build_ratelimiter(self):
+        return Ratelimiter()
+
     def register_servlets(self):
         """ Register all servlets associated with this HomeServer.
         """
diff --git a/synapse/storage/schema/room_aliases.sql b/synapse/storage/schema/room_aliases.sql
index 71a8b90e4d..d72b79e6ed 100644
--- a/synapse/storage/schema/room_aliases.sql
+++ b/synapse/storage/schema/room_aliases.sql
@@ -1,3 +1,18 @@
+/* Copyright 2014 matrix.org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
 CREATE TABLE IF NOT EXISTS room_aliases(
     room_alias TEXT NOT NULL,
     room_id TEXT NOT NULL
diff --git a/synapse/util/logutils.py b/synapse/util/logutils.py
index b94a749786..3d7c46ae44 100644
--- a/synapse/util/logutils.py
+++ b/synapse/util/logutils.py
@@ -19,7 +19,6 @@ from functools import wraps
 
 import logging
 import inspect
-import traceback
 
 
 def log_function(f):