From d022428a695ba5c499fcd0c8a9681962c006b8e8 Mon Sep 17 00:00:00 2001 From: drebs Date: Fri, 24 May 2013 12:59:21 -0300 Subject: Add action validation in server. * Use routes for validating user actions when interacting with server. * Also add tests for action validation. * Add changes file. * Closes #2356. --- src/leap/soledad/__init__.py | 6 +- src/leap/soledad/server.py | 134 +++++++++++++++ src/leap/soledad/tests/test_leap_backend.py | 8 +- src/leap/soledad/tests/test_server.py | 242 ++++++++++++++++++++++++++++ 4 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 src/leap/soledad/tests/test_server.py (limited to 'src/leap') diff --git a/src/leap/soledad/__init__.py b/src/leap/soledad/__init__.py index 94a21447..c9bf3f76 100644 --- a/src/leap/soledad/__init__.py +++ b/src/leap/soledad/__init__.py @@ -71,6 +71,7 @@ Path to the certificate file used to certify the SSL connection between Soledad client and server. """ +SECRETS_DOC_ID_HASH_PREFIX = 'uuid-' # # Exceptions @@ -522,7 +523,10 @@ class Soledad(object): @return: the hash @rtype: str """ - return sha256('uuid-%s' % self._uuid).hexdigest() + return sha256( + '%s%s' % ( + SECRETS_DOC_ID_HASH_PREFIX, + self._uuid)).hexdigest() def _shared_db(self): """ diff --git a/src/leap/soledad/server.py b/src/leap/soledad/server.py index e2944057..5a2bb81f 100644 --- a/src/leap/soledad/server.py +++ b/src/leap/soledad/server.py @@ -30,8 +30,13 @@ try: except ImportError: import json # noqa + +from hashlib import sha256 +from routes.mapper import Mapper +from u1db import DBNAME_CONSTRAINTS from u1db.remote import http_app + # Keep OpenSSL's tsafe before importing Twisted submodules so we can put # it back if Twisted==12.0.0 messes with it. from OpenSSL import tsafe @@ -50,6 +55,7 @@ if version.base() == "12.0.0": from couchdb.client import Server +from leap.soledad import SECRETS_DOC_ID_HASH_PREFIX from leap.soledad.backends.couch import CouchServerState @@ -63,6 +69,115 @@ class Unauthorized(Exception): """ +class URLToAuth(object): + """ + Verify if actions can be performed by a user. + """ + + HTTP_METHOD_GET = 'GET' + HTTP_METHOD_PUT = 'PUT' + HTTP_METHOD_DELETE = 'DELETE' + HTTP_METHOD_POST = 'POST' + + def __init__(self, uuid): + """ + Initialize the mapper. + + The C{uuid} is used to create the rules that will either allow or + disallow the user to perform specific actions. + + @param uuid: The user uuid. + @type uuid: str + """ + self._map = Mapper(controller_scan=None) + self._register_auth_info(self._uuid_dbname(uuid)) + + def is_authorized(self, environ): + """ + Return whether an HTTP request that produced the CGI C{environ} + corresponds to an authorized action. + + @param environ: Dictionary containing CGI variables. + @type environ: dict + + @return: Whether the action is authorized or not. + @rtype: bool + """ + return self._map.match(environ=environ) is not None + + def _register(self, pattern, http_methods): + """ + Register a C{pattern} in the mapper as valid for C{http_methods}. + + @param pattern: The URL pattern that corresponds to the user action. + @type pattern: str + @param http_methods: A list of authorized HTTP methods. + @type http_methods: list of str + """ + self._map.connect( + None, pattern, http_methods=http_methods, + conditions=dict(method=http_methods), + requirements={'dbname': DBNAME_CONSTRAINTS}) + + def _uuid_dbname(self, uuid): + """ + Return the database name corresponding to C{uuid}. + + @param uuid: The user uid. + @type uuid: str + + @return: The database name corresponding to C{uuid}. + @rtype: str + """ + return sha256('%s%s' % (SECRETS_DOC_ID_HASH_PREFIX, uuid)).hexdigest() + + def _register_auth_info(self, dbname): + """ + Register the authorization info in the mapper using C{dbname} as the + user's database name. + + This method sets up the following authorization rules: + + URL path | Authorized actions + -------------------------------------------------- + / | GET + /shared-db | GET + /shared-db/docs | - + /shared-db/doc/{id} | GET, PUT, DELETE + /shared-db/sync-from/{source} | - + /user-db | GET, PUT, DELETE + /user-db/docs | - + /user-db/doc/{id} | - + /user-db/sync-from/{source} | GET, PUT, POST + + @param dbname: The name of the user's database. + @type dbname: str + """ + # auth info for global resource + self._register('/', [self.HTTP_METHOD_GET]) + # auth info for shared-db database resource + self._register( + '/%s' % SoledadApp.SHARED_DB_NAME, + [self.HTTP_METHOD_GET]) + # auth info for shared-db doc resource + self._register( + '/%s/doc/{id:.*}' % SoledadApp.SHARED_DB_NAME, + [self.HTTP_METHOD_GET, self.HTTP_METHOD_PUT, + self.HTTP_METHOD_DELETE]) + # auth info for user-db database resource + self._register( + '/%s' % dbname, + [self.HTTP_METHOD_GET, self.HTTP_METHOD_PUT, + self.HTTP_METHOD_DELETE]) + # auth info for user-db sync resource + self._register( + '/%s/sync-from/{source_replica_uid}' % dbname, + [self.HTTP_METHOD_GET, self.HTTP_METHOD_PUT, + self.HTTP_METHOD_POST]) + # generate the regular expressions + self._map.create_regs() + + class SoledadAuthMiddleware(object): """ Soledad Authentication WSGI middleware. @@ -150,6 +265,9 @@ class SoledadAuthMiddleware(object): if not self.verify_token(environ, uuid, token): return unauth_err("Incorrect address or token.") + if not self.verify_action(uuid, environ): + return unauth_err("Unauthorized action.") + del environ[self.HTTP_AUTH_KEY] return self._app(environ, start_response) @@ -183,6 +301,22 @@ class SoledadAuthMiddleware(object): return False return True + def verify_action(self, uuid, environ): + """ + Verify if the user is authorized to perform the requested action over + the requested database. + + @param uuid: The user's uuid. + @type uuid: str + @param environ: Dictionary containing CGI variables. + @type environ: dict + + @return: Whether the user is authorize to perform the requested action + over the requested db. + @rtype: bool + """ + return URLToAuth(uuid).is_authorized(environ) + #----------------------------------------------------------------------------- # Soledad WSGI application diff --git a/src/leap/soledad/tests/test_leap_backend.py b/src/leap/soledad/tests/test_leap_backend.py index 2e4b3b01..458fc5d5 100644 --- a/src/leap/soledad/tests/test_leap_backend.py +++ b/src/leap/soledad/tests/test_leap_backend.py @@ -35,12 +35,13 @@ from u1db.remote import ( http_database, http_target, ) +from routes.mapper import Mapper from leap import soledad from leap.soledad.backends import leap_backend from leap.soledad.server import ( SoledadApp, - SoledadAuthMiddleware + SoledadAuthMiddleware, ) from leap.soledad import auth @@ -78,8 +79,13 @@ def make_token_soledad_app(state): return True return False + # we test for action authorization in leap.soledad.tests.test_server + def verify_action(environ, uuid): + return True + application = SoledadAuthMiddleware(app) application.verify_token = verify_token + application.verify_action = verify_action return application diff --git a/src/leap/soledad/tests/test_server.py b/src/leap/soledad/tests/test_server.py new file mode 100644 index 00000000..d574b50e --- /dev/null +++ b/src/leap/soledad/tests/test_server.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# test_server.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +""" +Tests for server-related functionality. +""" + +import os +import shutil +import tempfile +try: + import simplejson as json +except ImportError: + import json # noqa +import hashlib + + +from leap.soledad.server import URLToAuth +from leap.common.testing.basetest import BaseLeapTest + + +class SoledadServerTestCase(BaseLeapTest): + """ + Tests that guarantee that data will always be encrypted when syncing. + """ + + def setUp(self): + pass + + def tearDown(self): + pass + + def _make_environ(self, path_info, request_method): + return { + 'PATH_INFO': path_info, + 'REQUEST_METHOD': request_method, + } + + def test_verify_action_with_correct_dbnames(self): + """ + Test encrypting and decrypting documents. + + The following table lists the authorized actions among all possible + u1db remote actions: + + URL path | Authorized actions + -------------------------------------------------- + / | GET + /shared-db | GET + /shared-db/docs | - + /shared-db/doc/{id} | GET, PUT, DELETE + /shared-db/sync-from/{source} | - + /user-db | GET, PUT, DELETE + /user-db/docs | - + /user-db/doc/{id} | - + /user-db/sync-from/{source} | GET, PUT, POST + """ + uuid = 'myuuid' + authmap = URLToAuth(uuid) + dbname = authmap._uuid_dbname(uuid) + # test global auth + self.assertTrue( + authmap.is_authorized(self._make_environ('/', 'GET'))) + # test shared-db database resource auth + self.assertTrue( + authmap.is_authorized( + self._make_environ('/shared', 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared', 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared', 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared', 'POST'))) + # test shared-db docs resource auth + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/docs', 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/docs', 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/docs', 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/docs', 'POST'))) + # test shared-db doc resource auth + self.assertTrue( + authmap.is_authorized( + self._make_environ('/shared/doc/x', 'GET'))) + self.assertTrue( + authmap.is_authorized( + self._make_environ('/shared/doc/x', 'PUT'))) + self.assertTrue( + authmap.is_authorized( + self._make_environ('/shared/doc/x', 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/doc/x', 'POST'))) + # test shared-db sync resource auth + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/sync-from/x', 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/sync-from/x', 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/sync-from/x', 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/shared/sync-from/x', 'POST'))) + # test user-db database resource auth + self.assertTrue( + authmap.is_authorized( + self._make_environ('/%s' % dbname, 'GET'))) + self.assertTrue( + authmap.is_authorized( + self._make_environ('/%s' % dbname, 'PUT'))) + self.assertTrue( + authmap.is_authorized( + self._make_environ('/%s' % dbname, 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s' % dbname, 'POST'))) + # test user-db docs resource auth + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/docs' % dbname, 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/docs' % dbname, 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/docs' % dbname, 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/docs' % dbname, 'POST'))) + # test user-db doc resource auth + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/doc/x' % dbname, 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/doc/x' % dbname, 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/doc/x' % dbname, 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/doc/x' % dbname, 'POST'))) + # test user-db sync resource auth + self.assertTrue( + authmap.is_authorized( + self._make_environ('/%s/sync-from/x' % dbname, 'GET'))) + self.assertTrue( + authmap.is_authorized( + self._make_environ('/%s/sync-from/x' % dbname, 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/sync-from/x' % dbname, 'DELETE'))) + self.assertTrue( + authmap.is_authorized( + self._make_environ('/%s/sync-from/x' % dbname, 'POST'))) + + def test_verify_action_with_wrong_dbnames(self): + """ + Test if authorization fails for a wrong dbname. + """ + uuid = 'myuuid' + authmap = URLToAuth(uuid) + dbname = 'somedb' + # test wrong-db database resource auth + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s' % dbname, 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s' % dbname, 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s' % dbname, 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s' % dbname, 'POST'))) + # test wrong-db docs resource auth + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/docs' % dbname, 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/docs' % dbname, 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/docs' % dbname, 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/docs' % dbname, 'POST'))) + # test wrong-db doc resource auth + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/doc/x' % dbname, 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/doc/x' % dbname, 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/doc/x' % dbname, 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/doc/x' % dbname, 'POST'))) + # test wrong-db sync resource auth + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/sync-from/x' % dbname, 'GET'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/sync-from/x' % dbname, 'PUT'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/sync-from/x' % dbname, 'DELETE'))) + self.assertFalse( + authmap.is_authorized( + self._make_environ('/%s/sync-from/x' % dbname, 'POST'))) -- cgit v1.2.3