CVE-2013-0270: Improper Restriction of Operations within the Bounds of a Memory Buffer (CWE-119)
value: OpenStack Keystone Grizzly before 2013.1, Folsom, and possibly earlier allows remote attackers to cause a denial of service (CPU and memory consumption) via a large HTTP request, as demonstrated by a long tenant_name when requesting a token.
CWE description: The product performs operations on a memory buffer, but it reads from or writes to a memory location outside the buffer's intended boundary. This may result in read or write operations on unexpected memory locations that could be linked to other variables, data structures, or internal program data.
Published: 2013-04-12T22:55Z
Repository: https://github.com/openstack/keystone
Language: Python
Files changed: etc/keystone.conf.sample, keystone/common/utils.py, keystone/config.py, keystone/exception.py, keystone/middleware/core.py, tests/test_sizelimit.py
Commit date: 2013-01-21 19:54:29 -0500
Commit message: Limit the size of HTTP requests.

Adds a new RequestBodySizeLimiter middleware to guard against
really large HTTP requests. The default max request size is 112k
although this limit is configurable via the 'max_request_body_size'
config parameter.

Fixes LP Bug #1099025.

Change-Id: Id51be3d9a0d829d63d55a92dca61a39a17629785

Diff: diff --git a/etc/keystone.conf.sample b/etc/keystone.conf.sample
index 13a78475d..4017a04d4 100644
--- a/etc/keystone.conf.sample
+++ b/etc/keystone.conf.sample
@@ -184,10 +184,13 @@ paste.filter_factory = keystone.contrib.ec2:Ec2Extension.factory
 paste.filter_factory = keystone.contrib.s3:S3Extension.factory
 
 [filter:url_normalize]
 paste.filter_factory = keystone.middleware:NormalizingFilter.factory
 
+[filter:sizelimit]
+paste.filter_factory = keystone.middleware:RequestBodySizeLimiter.factory
+
 [filter:stats_monitoring]
 paste.filter_factory = keystone.contrib.stats:StatsMiddleware.factory
 
 [filter:stats_reporting]
 paste.filter_factory = keystone.contrib.stats:StatsExtension.factory
@@ -200,29 +203,29 @@ paste.app_factory = keystone.service:v3_app_factory
 
 [app:admin_service]
 paste.app_factory = keystone.service:admin_app_factory
 
 [pipeline:public_api]
-pipeline = stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug ec2_extension user_crud_extension public_service
+pipeline = sizelimit stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug ec2_extension user_crud_extension public_service
 
 [pipeline:admin_api]
-pipeline = stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug stats_reporting ec2_extension s3_extension crud_extension admin_service
+pipeline = sizelimit stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug stats_reporting ec2_extension s3_extension crud_extension admin_service
 
 [pipeline:api_v3]
-pipeline = stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug stats_reporting ec2_extension s3_extension service_v3
+pipeline = sizelimit stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug stats_reporting ec2_extension s3_extension service_v3
 
 [app:public_version_service]
 paste.app_factory = keystone.service:public_version_app_factory
 
 [app:admin_version_service]
 paste.app_factory = keystone.service:admin_version_app_factory
 
 [pipeline:public_version_api]
-pipeline = stats_monitoring url_normalize xml_body public_version_service
+pipeline = sizelimit stats_monitoring url_normalize xml_body public_version_service
 
 [pipeline:admin_version_api]
-pipeline = stats_monitoring url_normalize xml_body admin_version_service
+pipeline = sizelimit stats_monitoring url_normalize xml_body admin_version_service
 
 [composite:main]
 use = egg:Paste#urlmap
 /v2.0 = public_api
 /v3 = api_v3
diff --git a/keystone/common/utils.py b/keystone/common/utils.py
index d74da5b54..2c194db51 100644
--- a/keystone/common/utils.py
+++ b/keystone/common/utils.py
@@ -309,5 +309,39 @@ def setup_remote_pydev_debug():
                             stderrToServer=True)
             return True
         except:
             LOG.exception(_(error_msg))
             raise
+
+
+class LimitingReader(object):
+    """Reader to limit the size of an incoming request."""
+    def __init__(self, data, limit):
+        """
+        :param data: Underlying data object
+        :param limit: maximum number of bytes the reader should allow
+        """
+        self.data = data
+        self.limit = limit
+        self.bytes_read = 0
+
+    def __iter__(self):
+        for chunk in self.data:
+            self.bytes_read += len(chunk)
+            if self.bytes_read > self.limit:
+                raise exception.RequestTooLarge()
+            else:
+                yield chunk
+
+    def read(self, i):
+        result = self.data.read(i)
+        self.bytes_read += len(result)
+        if self.bytes_read > self.limit:
+            raise exception.RequestTooLarge()
+        return result
+
+    def read(self):
+        result = self.data.read()
+        self.bytes_read += len(result)
+        if self.bytes_read > self.limit:
+            raise exception.RequestTooLarge()
+        return result
diff --git a/keystone/config.py b/keystone/config.py
index c26a518c2..72fd0dcb5 100644
--- a/keystone/config.py
+++ b/keystone/config.py
@@ -135,10 +135,12 @@ register_str('admin_port', default=35357)
 register_str('public_port', default=5000)
 register_str('onready')
 register_str('auth_admin_prefix', default='')
 register_str('policy_file', default='policy.json')
 register_str('policy_default_rule', default=None)
+#default max request size is 112k
+register_int('max_request_body_size', default=114688)
 
 #ssl options
 register_bool('enable', group='ssl', default=False)
 register_str('certfile', group='ssl', default=None)
 register_str('keyfile', group='ssl', default=None)
diff --git a/keystone/exception.py b/keystone/exception.py
index 26697e0db..2787e064a 100644
--- a/keystone/exception.py
+++ b/keystone/exception.py
@@ -171,10 +171,16 @@ class Conflict(Error):
     """
     code = 409
     title = 'Conflict'
 
 
+class RequestTooLarge(Error):
+    """Request is too large."""
+    code = 413
+    title = 'Request is too large.'
+
+
 class UnexpectedError(Error):
     """An unexpected error prevented the server from fulfilling your request.
 
     %(exception)s
 
diff --git a/keystone/middleware/core.py b/keystone/middleware/core.py
index a49f743be..24495c987 100644
--- a/keystone/middleware/core.py
+++ b/keystone/middleware/core.py
@@ -12,11 +12,14 @@
 # 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 webob.dec
+
 from keystone.common import serializer
+from keystone.common import utils
 from keystone.common import wsgi
 from keystone import config
 from keystone import exception
 from keystone.openstack.common import jsonutils
 
@@ -162,5 +165,23 @@ class NormalizingFilter(wsgi.Middleware):
                 request.environ['PATH_INFO'][-1] == '/'):
             request.environ['PATH_INFO'] = request.environ['PATH_INFO'][:-1]
         # Rewrites path to root if no path is given.
         elif not request.environ['PATH_INFO']:
             request.environ['PATH_INFO'] = '/'
+
+
+class RequestBodySizeLimiter(wsgi.Middleware):
+    """Limit the size of an incoming request."""
+
+    def __init__(self, *args, **kwargs):
+        super(RequestBodySizeLimiter, self).__init__(*args, **kwargs)
+
+    @webob.dec.wsgify(RequestClass=wsgi.Request)
+    def __call__(self, req):
+
+        if req.content_length > CONF.max_request_body_size:
+            raise exception.RequestTooLarge()
+        if req.content_length is None and req.is_body_readable:
+            limiter = utils.LimitingReader(req.body_file,
+                                           CONF.max_request_body_size)
+            req.body_file = limiter
+        return self.application
diff --git a/tests/test_sizelimit.py b/tests/test_sizelimit.py
new file mode 100644
index 000000000..aec57ecfe
--- /dev/null
+++ b/tests/test_sizelimit.py
@@ -0,0 +1,56 @@
+# Copyright (c) 2013 OpenStack, LLC
+#
+#    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 webob
+
+from keystone import config
+from keystone import exception
+from keystone import middleware
+from keystone import test
+
+CONF = config.CONF
+MAX_REQUEST_BODY_SIZE = CONF.max_request_body_size
+
+
+class TestRequestBodySizeLimiter(test.TestCase):
+
+    def setUp(self):
+        super(TestRequestBodySizeLimiter, self).setUp()
+
+        @webob.dec.wsgify()
+        def fake_app(req):
+            return webob.Response(req.body)
+
+        self.middleware = middleware.RequestBodySizeLimiter(fake_app)
+        self.request = webob.Request.blank('/', method='POST')
+
+    def test_content_length_acceptable(self):
+        self.request.headers['Content-Length'] = MAX_REQUEST_BODY_SIZE
+        self.request.body = "0" * MAX_REQUEST_BODY_SIZE
+        response = self.request.get_response(self.middleware)
+        self.assertEqual(response.status_int, 200)
+
+    def test_content_length_too_large(self):
+        self.request.headers['Content-Length'] = MAX_REQUEST_BODY_SIZE + 1
+        self.request.body = "0" * (MAX_REQUEST_BODY_SIZE + 1)
+        self.assertRaises(exception.RequestTooLarge,
+                          self.request.get_response,
+                          self.middleware)
+
+    def test_request_too_large_no_content_length(self):
+        self.request.body = "0" * (MAX_REQUEST_BODY_SIZE + 1)
+        self.request.headers['Content-Length'] = None
+        self.assertRaises(exception.RequestTooLarge,
+                          self.request.get_response,
+                          self.middleware)

Vulnerable code: pipeline = stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug ec2_extension user_crud_extension public_service
pipeline = stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug stats_reporting ec2_extension s3_extension crud_extension admin_service
pipeline = stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug stats_reporting ec2_extension s3_extension service_v3
pipeline = stats_monitoring url_normalize xml_body public_version_service
pipeline = stats_monitoring url_normalize xml_body admin_version_service
Fixed code: [filter:sizelimit]
paste.filter_factory = keystone.middleware:RequestBodySizeLimiter.factory

pipeline = sizelimit stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug ec2_extension user_crud_extension public_service
pipeline = sizelimit stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug stats_reporting ec2_extension s3_extension crud_extension admin_service
pipeline = sizelimit stats_monitoring url_normalize token_auth admin_token_auth xml_body json_body debug stats_reporting ec2_extension s3_extension service_v3
pipeline = sizelimit stats_monitoring url_normalize xml_body public_version_service
pipeline = sizelimit stats_monitoring url_normalize xml_body admin_version_service


class LimitingReader(object):
    """Reader to limit the size of an incoming request."""
    def __init__(self, data, limit):
        """
        :param data: Underlying data object
        :param limit: maximum number of bytes the reader should allow
        """
        self.data = data
        self.limit = limit
        self.bytes_read = 0

    def __iter__(self):
        for chunk in self.data:
            self.bytes_read += len(chunk)
            if self.bytes_read > self.limit:
                raise exception.RequestTooLarge()
            else:
                yield chunk

    def read(self, i):
        result = self.data.read(i)
        self.bytes_read += len(result)
        if self.bytes_read > self.limit:
            raise exception.RequestTooLarge()
        return result

    def read(self):
        result = self.data.read()
        self.bytes_read += len(result)
        if self.bytes_read > self.limit:
            raise exception.RequestTooLarge()
        return result
#default max request size is 112k
register_int('max_request_body_size', default=114688)
class RequestTooLarge(Error):
    """Request is too large."""
    code = 413
    title = 'Request is too large.'


import webob.dec

from keystone.common import utils


class RequestBodySizeLimiter(wsgi.Middleware):
    """Limit the size of an incoming request."""

    def __init__(self, *args, **kwargs):
        super(RequestBodySizeLimiter, self).__init__(*args, **kwargs)

    @webob.dec.wsgify(RequestClass=wsgi.Request)
    def __call__(self, req):

        if req.content_length > CONF.max_request_body_size:
            raise exception.RequestTooLarge()
        if req.content_length is None and req.is_body_readable:
            limiter = utils.LimitingReader(req.body_file,
                                           CONF.max_request_body_size)
            req.body_file = limiter
        return self.application
# Copyright (c) 2013 OpenStack, LLC
#
#    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 webob

from keystone import config
from keystone import exception
from keystone import middleware
from keystone import test

CONF = config.CONF
MAX_REQUEST_BODY_SIZE = CONF.max_request_body_size


class TestRequestBodySizeLimiter(test.TestCase):

    def setUp(self):
        super(TestRequestBodySizeLimiter, self).setUp()

        @webob.dec.wsgify()
        def fake_app(req):
            return webob.Response(req.body)

        self.middleware = middleware.RequestBodySizeLimiter(fake_app)
        self.request = webob.Request.blank('/', method='POST')

    def test_content_length_acceptable(self):
        self.request.headers['Content-Length'] = MAX_REQUEST_BODY_SIZE
        self.request.body = "0" * MAX_REQUEST_BODY_SIZE
        response = self.request.get_response(self.middleware)
        self.assertEqual(response.status_int, 200)

    def test_content_length_too_large(self):
        self.request.headers['Content-Length'] = MAX_REQUEST_BODY_SIZE + 1
        self.request.body = "0" * (MAX_REQUEST_BODY_SIZE + 1)
        self.assertRaises(exception.RequestTooLarge,
                          self.request.get_response,
                          self.middleware)

    def test_request_too_large_no_content_length(self):
        self.request.body = "0" * (MAX_REQUEST_BODY_SIZE + 1)
        self.request.headers['Content-Length'] = None
        self.assertRaises(exception.RequestTooLarge,
                          self.request.get_response,
                          self.middleware)