diff --git a/ldapcherry/__init__.py b/ldapcherry/__init__.py index 88a7595..0a01d8c 100644 --- a/ldapcherry/__init__.py +++ b/ldapcherry/__init__.py @@ -20,6 +20,7 @@ from ldapcherry.exceptions import * from ldapcherry.lclogging import * from ldapcherry.roles import Roles from ldapcherry.attributes import Attributes +from ldapcherry.csrf import validate_csrf # Cherrypy http framework imports import cherrypy @@ -39,7 +40,6 @@ else: SESSION_KEY = '_cp_username' - class LdapCherry(object): def _handle_exception(self, e): @@ -388,7 +388,8 @@ class LdapCherry(object): for t in ('index.tmpl', 'error.tmpl', 'login.tmpl', '404.tmpl', 'searchadmin.tmpl', 'searchuser.tmpl', 'adduser.tmpl', 'roles.tmpl', 'groups.tmpl', 'form.tmpl', 'selfmodify.tmpl', - 'modify.tmpl', 'service_unavailable.tmpl' + 'modify.tmpl', 'service_unavailable.tmpl', 'csrf_error.tmpl', + 'csrf_field.tmpl' ): self.temp[t] = self.temp_lookup.get_template(t) @@ -882,15 +883,14 @@ class LdapCherry(object): @cherrypy.expose @exception_decorator def signin(self, url=None): - """simple signin page - """ + """simple signin page""" return self.temp['login.tmpl'].render(url=url) @cherrypy.expose @exception_decorator + @validate_csrf def login(self, login, password, url=None): - """login page - """ + """login page""" auth = self._auth(login, password) cherrypy.session['isadmin'] = auth['isadmin'] cherrypy.session['connected'] = auth['connected'] @@ -950,8 +950,7 @@ class LdapCherry(object): @cherrypy.expose @exception_decorator def index(self): - """main page rendering - """ + """ main page rendering """ self._check_auth(must_admin=False) is_admin = self._check_admin() sess = cherrypy.session @@ -1026,6 +1025,7 @@ class LdapCherry(object): @cherrypy.expose @exception_decorator + @validate_csrf def adduser(self, **params): """ add user page """ self._check_auth(must_admin=True) @@ -1074,6 +1074,7 @@ class LdapCherry(object): @cherrypy.expose @exception_decorator + @validate_csrf def delete(self, user): """ remove user page """ self._check_auth(must_admin=True) @@ -1088,6 +1089,7 @@ class LdapCherry(object): @cherrypy.expose @exception_decorator + @validate_csrf def modify(self, user=None, **params): """ modify user page """ self._check_auth(must_admin=True) @@ -1181,6 +1183,7 @@ class LdapCherry(object): @cherrypy.expose @exception_decorator + @validate_csrf def selfmodify(self, **params): """ self modify user page """ self._check_auth(must_admin=False) diff --git a/ldapcherry/csrf.py b/ldapcherry/csrf.py new file mode 100644 index 0000000..99e4e7c --- /dev/null +++ b/ldapcherry/csrf.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# vim:set expandtab tabstop=4 shiftwidth=4: +# +# The MIT License (MIT) +# LdapCherry +# Copyright (c) 2014 Carpentier Pierre-Francois + +""" +Utility functions to generate and verify CSRF tokens. + +For details about CSRF attack and protection, see: +* https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF) +* https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.md +""" + +import cherrypy +import secrets +from ldapcherry.exceptions import MissingCSRFParam, \ + MissingCSRFCookie, InvalidCSRFToken + + +# Names of the different parameters (cookie, post parameter, session variable) +CSRF_COOKIE_NAME = "csrf_reference" +CSRF_INPUT_NAME = "csrf_token" +CSRF_SESSION_NAME = "csrf_token" + + +def get_csrf_cookie(): + """ + Quick utility function to read CSRF cookie value. + + Return None if the cookie is not present. + """ + if CSRF_COOKIE_NAME in cherrypy.request.cookie: + return cherrypy.request.cookie[CSRF_COOKIE_NAME].value + else: + return None + + +def set_csrf_cookie(value): + """ + Quick utility function to set CSRF cookie value. + + Use cherrypy to set the correct header. + """ + cherrypy.response.cookie[CSRF_COOKIE_NAME] = value + + +def generate_token(nb_bytes=32): + """ + Generate and return a random CSRF token. + + Strong entropy generator from `secrets` library is used to ensure + the token is not guessable and return value is encoded in hex format. + @integer nb_bytes can be specified to set the number of bytes + (each byte resulting in two hex digits) + """ + return secrets.token_hex(nb_bytes) + + +def get_csrf_token(): + """ + Return the CSRF token associated with the user. + + Return the CSRF token from session if it exists. + Else, generate a new one and store it (in session + cookie). + """ + if CSRF_SESSION_NAME not in cherrypy.session: + token = generate_token() + cherrypy.session[CSRF_SESSION_NAME] = token + set_csrf_cookie(token) + return cherrypy.session[CSRF_SESSION_NAME] + + +def get_csrf_field(): + """ + Return an hidden form field containing the CSRF token. + + Return format is a plain string which can be inserted in a template. + The token is generated and saved if needed (via get_csrf_token() call). + """ + template = "" + return template.format(name=CSRF_INPUT_NAME, value=get_csrf_token()) + + +def ensure_valid_token(**params): + """ + Ensure request is legitimate by comparing cookie and post parameter. + + Raise an exception if CSRF cookie value is different from CSRF + post parameter value or if one of them is missing. + In this case, the request MUST NOT be processed (it is not genuine). + """ + if CSRF_INPUT_NAME not in params: + raise MissingCSRFParam() + if CSRF_COOKIE_NAME not in cherrypy.request.cookie: + raise MissingCSRFCookie() + if params.get(CSRF_INPUT_NAME) != get_csrf_cookie(): + raise InvalidCSRFToken() + + +def validate_csrf(func): + """ + Decorator ensuring CSRF token is validated before executing request. + + WARNING: only POST requests are checked, for GET requests, you need to call + ensure_valid_token() manually. + """ + def ret(self, *args, **kwargs): + if cherrypy.request.method.upper() == 'POST': + ensure_valid_token(**kwargs) + kwargs.pop(CSRF_INPUT_NAME) + return func(self, *args, **kwargs) + return ret diff --git a/ldapcherry/exceptions.py b/ldapcherry/exceptions.py index cb3ac54..7922b9c 100644 --- a/ldapcherry/exceptions.py +++ b/ldapcherry/exceptions.py @@ -223,6 +223,21 @@ class TemplateRenderError(Exception): self.log = "Template Render Error: " + error +class MissingCSRFParam(Exception): + def __init__(self): + self.log = "Missing CSRF post parameter" + + +class MissingCSRFCookie(Exception): + def __init__(self): + self.log = "Missing CSRF cookie" + + +class InvalidCSRFToken(Exception): + def __init__(self): + self.log = "CSRF validation failed" + + def exception_decorator(func): def ret(self, *args, **kwargs): try: @@ -231,6 +246,10 @@ def exception_decorator(func): raise e except cherrypy.HTTPError as e: raise e + except (InvalidCSRFToken, MissingCSRFCookie, MissingCSRFParam) as e: + cherrypy.response.status = 403 + self._handle_exception(e) + return self.temp['csrf_error.tmpl'].render() except Exception as e: cherrypy.response.status = 500 self._handle_exception(e) @@ -255,7 +274,6 @@ def exception_decorator(func): message="User '" + user + "' already exist" ) elif et is GroupDoesntExist: - group = e.group return self.temp['error.tmpl'].render( is_admin=is_admin, alert='danger', diff --git a/resources/templates/csrf_error.tmpl b/resources/templates/csrf_error.tmpl new file mode 100644 index 0000000..90f543b --- /dev/null +++ b/resources/templates/csrf_error.tmpl @@ -0,0 +1,16 @@ +## -*- coding: utf-8 -*- +<%inherit file="base.tmpl"/> +<%block name="core"> +
+
+
+
+

Your request was denied for security reasons.

+

This may happen from time to time if you don't actively use the tab or if you clear cookies.

+

Try to refresh the page and contact an administrator if the problem persists.

+
+ Return +
+
+
+ diff --git a/resources/templates/csrf_field.tmpl b/resources/templates/csrf_field.tmpl new file mode 100644 index 0000000..c072367 --- /dev/null +++ b/resources/templates/csrf_field.tmpl @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%! from ldapcherry.csrf import get_csrf_token, CSRF_INPUT_NAME %> + diff --git a/resources/templates/form.tmpl b/resources/templates/form.tmpl index 068eb61..3e73908 100644 --- a/resources/templates/form.tmpl +++ b/resources/templates/form.tmpl @@ -1,5 +1,6 @@ ## -*- coding: utf-8 -*- -<% +<% +from ldapcherry.csrf import get_csrf_field from markupsafe import Markup len_attr = len(attributes) switch = len_attr / 2 @@ -16,11 +17,11 @@ for a in sorted(attributes.keys(), key=lambda attr: attributes[attr]['weight']): counter = counter + 1 %> <%def name="form_col(l)"> -% for a in l: +% for a in l: <% attr = attributes[a] %>
- <% + <% if modify: required = '' else: @@ -84,6 +85,8 @@ ${form_col(lc1)} ${form_col(lc2)}
+<%include file="csrf_field.tmpl"/> + % if autofill: <% attr_set = [] diff --git a/resources/templates/login.tmpl b/resources/templates/login.tmpl index 3bc6ca0..dbe0518 100644 --- a/resources/templates/login.tmpl +++ b/resources/templates/login.tmpl @@ -11,24 +11,25 @@ action='login' % endif > -
-

Please sign in

-
- - +
+ +
+ + +
-
-
-
- - +
+
+ + +
-
-
-
- +
+
+ <%include file="csrf_field.tmpl"/> + +
-
diff --git a/resources/templates/searchadmin.tmpl b/resources/templates/searchadmin.tmpl index e682166..f4ebc99 100644 --- a/resources/templates/searchadmin.tmpl +++ b/resources/templates/searchadmin.tmpl @@ -1,6 +1,7 @@ ## -*- coding: utf-8 -*- <%inherit file="navbar.tmpl"/> <%block name="core"> + <% from ldapcherry.csrf import get_csrf_token %>
@@ -42,8 +43,8 @@ %for attr in sorted(attrs_list.keys(), key=lambda attr: attrs_list[attr]['weight']): % if attr in searchresult[user]: - <% - value = searchresult[user][attr] + <% + value = searchresult[user][attr] if type(value) is list: value = ', '.join(value) %> @@ -55,7 +56,12 @@ Modify - Delete + + <%include file="csrf_field.tmpl"/> + +
% endfor