Repository: rucio/rucio

Issue:
Harden security on the Web UI
Motivation
----------
The login site uses scripts it doesn't need and the renderer uses unescaped variables in its output.


Modification
------------
Remove useless `<script>` tags from the login page and escape variables where appropriate.


Discussion:

Resolving patch:
diff --git a/lib/rucio/web/ui/common/utils.py b/lib/rucio/web/ui/common/utils.py
--- a/lib/rucio/web/ui/common/utils.py
+++ b/lib/rucio/web/ui/common/utils.py
@@ -9,11 +9,16 @@
 # - Thomas Beermann, <thomas.beermann@cern.ch>, 2014-2020
 # - Ruturaj Gujar, <ruturaj.gujar23@gmail.com>, 2019
 # - Jaroslav Guenther, <jaroslav.guenther@cern.ch>, 2019-2020
+# - Benedikt Ziemons <benedikt.ziemons@cern.ch>, 2020
+#
+# PY3K COMPATIBLE
 #
 # TO-DO !!! Remove passing data with account and other params to the functions
 # catch these from the webpy input() storage object
 # will allow to remove also lines around each use of select_account_name
 
+import re
+import sys
 from json import dumps, load
 from os.path import dirname, join
 from time import time
@@ -25,6 +30,20 @@
 from rucio.common.config import config_get
 from rucio.db.sqla.constants import AccountType
 
+
+if sys.version_info > (3, 0):
+    long = int
+    unicode = str
+
+escapefunc = None
+try:
+    import html
+    escapefunc = html.escape
+except ImportError:
+    import cgi
+    escapefunc = cgi.escape
+
+
 try:
     from onelogin.saml2.auth import OneLogin_Saml2_Auth
     SAML_SUPPORT = True
@@ -54,6 +73,14 @@
         AUTH_ISSUERS = []
 
 
+# excluded characters for injected JavaScript variables
+VARIABLE_VALUE_REGEX = re.compile(r"^[\w\- /=,.+*#()\[\]]*$", re.UNICODE)
+
+
+def html_escape(s, quote=True):
+    return escapefunc(s, quote)
+
+
 def prepare_webpy_request(request, data):
     """
     Prepare a webpy request for SAML
@@ -104,6 +131,10 @@ def __to_js(var, value):
     :param var: The name of the javascript var.
     :param value: The value to set.
     """
+    value = value.replace('\n', ' ')  # replace newlines for pattern matching on the whole string
+    if not VARIABLE_VALUE_REGEX.match(value):
+        # ensure nothing is injected
+        value = ''
     return '<script type="text/javascript">var %s = "%s";</script>' % (var, value)
 
 
@@ -255,7 +286,7 @@ def x509token_auth(data=None):
         ui_account = data.account
     else:
         ui_account = select_account_name(dn, 'x509')
-    msg = "<br><br>Your certificate (%s) is not mapped to (possibly any) rucio account: %s." % (dn, ui_account)
+    msg = "<br><br>Your certificate (%s) is not mapped to (possibly any) rucio account: %s." % (html_escape(dn), html_escape(ui_account))
     msg += "<br><br><font color=\"red\">First, please make sure it is correctly registered in <a href=\"https://voms2.cern.ch:8443/voms/atlas\">VOMS</a> and be patient until it has been fully propagated through the system.</font>"
     msg += "<br><br>Then, if it is still not working please contact <a href=\"mailto:atlas-adc-ddm-support@cern.ch\">DDM Support</a>."
     if not ui_account:
@@ -284,10 +315,10 @@ def userpass_auth(data, rendered_tpl):
         else:
             ui_account = select_account_name(data.username, 'userpass')
         if not ui_account:
-            return RENDERER.problem(('Cannot get find any account associated with %s identity.' % (data.username)))
+            return RENDERER.problem(('Cannot get find any account associated with %s identity.' % (html_escape(data.username))))
         token = get_token(auth.get_auth_token_user_pass, acc=ui_account, idt=data.username, pwd=data.password.encode("ascii"))
         if not token:
-            return RENDERER.problem(('Cannot get auth token. It is possible that the presented identity %s is not mapped to any Rucio account %s.') % (data.username, ui_account))
+            return RENDERER.problem(('Cannot get auth token. It is possible that the presented identity %s is not mapped to any Rucio account %s.') % (html_escape(data.username), html_escape(ui_account)))
     return finalize_auth(token, 'userpass')
 
 
@@ -315,10 +346,10 @@ def saml_auth(method, data=None):
         if not ui_account:
             ui_account = select_account_name(saml_nameid, 'saml')
         if not ui_account:
-            return RENDERER.problem('Cannot get find any account associated with %s identity.' % (saml_nameid))
+            return RENDERER.problem('Cannot get find any account associated with %s identity.' % (html_escape(saml_nameid)))
         token = get_token(auth.get_auth_token_saml, acc=ui_account, idt=saml_nameid)
         if not token:
-            return RENDERER.problem(('Cannot get auth token. It is possible that the presented identity %s is not mapped to any Rucio account %s.') % (saml_nameid, ui_account))
+            return RENDERER.problem(('Cannot get auth token. It is possible that the presented identity %s is not mapped to any Rucio account %s.') % (html_escape(saml_nameid), html_escape(ui_account)))
         return finalize_auth(token, 'saml')
 
     # If method is POST, check the received SAML response and redirect to home if valid
@@ -335,10 +366,10 @@ def saml_auth(method, data=None):
             if not ui_account:
                 ui_account = select_account_name(saml_nameid, 'saml')
             if not ui_account:
-                return RENDERER.problem('Cannot get find any account associated with %s identity.' % (saml_nameid))
+                return RENDERER.problem('Cannot get find any account associated with %s identity.' % (html_escape(saml_nameid)))
             token = get_token(auth.get_auth_token_saml, acc=ui_account, idt=saml_nameid)
             if not token:
-                return RENDERER.problem(('Cannot get auth token. It is possible that the presented identity %s is not mapped to any Rucio account %s.') % (saml_nameid, ui_account))
+                return RENDERER.problem(('Cannot get auth token. It is possible that the presented identity %s is not mapped to any Rucio account %s.') % (html_escape(saml_nameid), html_escape(ui_account)))
             return finalize_auth(token, 'saml', cookie_extra)
 
         return RENDERER.problem("Not authenticated")


Test patch: