Skip to content

Commit cd6a3c9

Browse files
authored
Security improvements (#1227)
* Security improvements * Update pattern escape Co-authored-by: Mher Movsisyan <mher@users.noreply.github.com>
1 parent d91fbbf commit cd6a3c9

File tree

6 files changed

+80
-11
lines changed

6 files changed

+80
-11
lines changed

docs/config.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ auth
6565
~~~~
6666

6767
Enables authentication. `auth` is a regexp of emails to grant access.
68+
For security reasons `auth` only supports a basic regex syntax: single email (`user@example.com`),
69+
wildcard (`.*@example.com`) or list of emails separated by pipes (`one@example.com|two@example.com`).
6870
For more info see :ref:`authentication`.
6971

7072
.. _auto_refresh:

flower/command.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .urls import settings
1919
from .utils import abs_path, prepend_url
2020
from .options import DEFAULT_CONFIG_FILE, default_options
21+
from .views.auth import validate_auth_option
2122

2223
logger = logging.getLogger(__name__)
2324
ENV_VAR_PREFIX = 'FLOWER_'
@@ -135,6 +136,10 @@ def extract_settings():
135136
if options.ca_certs:
136137
settings['ssl_options']['ca_certs'] = abs_path(options.ca_certs)
137138

139+
if options.auth and not validate_auth_option(options.auth):
140+
logger.error("Invalid '--auth' option: %s", options.auth)
141+
sys.exit(1)
142+
138143

139144
def is_flower_option(arg):
140145
name, _, _ = arg.lstrip('-').partition("=")
@@ -168,3 +173,5 @@ def print_banner(app, ssl):
168173
pformat(sorted(app.tasks.keys()))
169174
)
170175
logger.debug('Settings: %s', pformat(settings))
176+
if not (options.basic_auth or options.auth):
177+
logger.warning('Running without authentication')

flower/options.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import types
2+
from secrets import token_urlsafe
23

34
from prometheus_client import Histogram
45
from tornado.options import define
@@ -52,7 +53,7 @@
5253
help="refresh dashboards", type=bool)
5354
define("purge_offline_workers", default=None, type=int,
5455
help="time (in seconds) after which offline workers are purged from dashboard")
55-
define("cookie_secret", type=str, default=None,
56+
define("cookie_secret", type=str, default=token_urlsafe(64),
5657
help="secure cookie secret")
5758
define("conf", default=DEFAULT_CONFIG_FILE,
5859
help="configuration file")

flower/views/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616

1717
class BaseHandler(tornado.web.RequestHandler):
1818
def set_default_headers(self):
19-
self.set_header("Access-Control-Allow-Origin", "*")
20-
self.set_header("Access-Control-Allow-Headers",
21-
"x-requested-with,access-control-allow-origin,authorization,content-type")
22-
self.set_header('Access-Control-Allow-Methods',
23-
' PUT, DELETE, OPTIONS, POST, GET, PATCH')
19+
if not (self.application.options.basic_auth or self.application.options.auth):
20+
self.set_header("Access-Control-Allow-Origin", "*")
21+
self.set_header("Access-Control-Allow-Headers",
22+
"x-requested-with,access-control-allow-origin,authorization,content-type")
23+
self.set_header('Access-Control-Allow-Methods',
24+
' PUT, DELETE, OPTIONS, POST, GET, PATCH')
2425

2526
def options(self, *args, **kwargs):
2627
self.set_status(204)

flower/views/auth.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,26 @@
1414
from ..views import BaseHandler
1515

1616

17+
def authenticate(pattern, email):
18+
if '|' in pattern:
19+
return email in pattern.split('|')
20+
elif '*' in pattern:
21+
pattern = re.escape(pattern).replace('\.\*', "[A-Za-z0-9!#$%&'*+/=?^_`{|}~.\-]*")
22+
return re.fullmatch(pattern, email)
23+
else:
24+
return pattern == email
25+
26+
27+
def validate_auth_option(pattern):
28+
if pattern.count('*') > 1:
29+
return False
30+
if '*' in pattern and '|' in pattern:
31+
return False
32+
if '*' in pattern.rsplit('@', 1)[-1]:
33+
return False
34+
return True
35+
36+
1737
class GoogleAuth2LoginHandler(BaseHandler, tornado.auth.GoogleOAuth2Mixin):
1838
_OAUTH_SETTINGS_KEY = 'oauth'
1939

@@ -49,7 +69,7 @@ def _on_auth(self, user):
4969
raise tornado.web.HTTPError(403, 'Google auth failed: %s' % e)
5070

5171
email = json.loads(response.body.decode('utf-8'))['email']
52-
if not re.match(self.application.options.auth, email):
72+
if not authenticate(self.application.options.auth, email):
5373
message = (
5474
"Access denied to '{email}'. Please use another account or "
5575
"ask your admin to add your email to flower --auth."
@@ -129,7 +149,7 @@ def _on_auth(self, user):
129149
'User-agent': 'Tornado auth'})
130150

131151
emails = [email['email'].lower() for email in json.loads(response.body.decode('utf-8'))
132-
if email['verified'] and re.match(self.application.options.auth, email['email'])]
152+
if email['verified'] and authenticate(self.application.options.auth, email['email'])]
133153

134154
if not emails:
135155
message = (
@@ -209,7 +229,7 @@ def _on_auth(self, user):
209229
raise tornado.web.HTTPError(403, 'GitLab auth failed: %s' % e)
210230

211231
user_email = json.loads(response.body.decode('utf-8'))['email']
212-
email_allowed = re.match(self.application.options.auth, user_email)
232+
email_allowed = authenticate(self.application.options.auth, user_email)
213233

214234
# Check user's groups against list of allowed groups
215235
matching_groups = []
@@ -323,7 +343,7 @@ def _on_auth(self, access_token_response):
323343
email = (decoded_body.get('email') or '').strip()
324344
email_verified = (
325345
decoded_body.get('email_verified') and
326-
re.match(self.application.options.auth, email)
346+
authenticate(self.application.options.auth, email)
327347
)
328348

329349
if not email_verified:

tests/unit/views/test_auth.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from tests.unit import AsyncHTTPTestCase
2-
2+
from flower.views.auth import authenticate, validate_auth_option
33

44
class BasicAuthTests(AsyncHTTPTestCase):
55
def test_with_single_creds(self):
@@ -21,3 +21,41 @@ def test_with_multiple_creds(self):
2121
self.assertEqual(200, r.code)
2222
r = self.fetch('/', auth_username='user1', auth_password='pswd2')
2323
self.assertEqual(401, r.code)
24+
25+
26+
class AuthTests(AsyncHTTPTestCase):
27+
def test_validate_auth_option(self):
28+
self.assertTrue(validate_auth_option("mail@example.com"))
29+
self.assertTrue(validate_auth_option(".*@example.com"))
30+
self.assertTrue(validate_auth_option("one.*@example.com"))
31+
self.assertTrue(validate_auth_option("one.*two@example.com"))
32+
self.assertFalse(validate_auth_option(".*@.*example.com"))
33+
self.assertFalse(validate_auth_option("one@domain1.com|.*@domain2.com"))
34+
self.assertTrue(validate_auth_option("one@example.com|two@example.com"))
35+
self.assertFalse(validate_auth_option("mail@.*example.com"))
36+
self.assertFalse(validate_auth_option(".*example.com"))
37+
38+
def test_authenticate_single_email(self):
39+
self.assertTrue(authenticate("mail@example.com", "mail@example.com"))
40+
self.assertFalse(authenticate("mail@example.com", "foo@example.com"))
41+
self.assertFalse(authenticate("mail@example.com", "long.mail@example.com"))
42+
self.assertFalse(authenticate("mail@example.com", ""))
43+
self.assertFalse(authenticate("me@gmail.com", "me@gmail.com.attacker.com"))
44+
self.assertFalse(authenticate("me@gmail.com", "*"))
45+
46+
def test_authenticate_email_list(self):
47+
self.assertTrue(authenticate("one@example.com|two@example.net", "one@example.com"))
48+
self.assertTrue(authenticate("one@example.com|two@example.net", "two@example.net"))
49+
self.assertFalse(authenticate("one@example.com|two@example.net", "two@example.com"))
50+
self.assertFalse(authenticate("one@example.com|two@example.net", "one@example.net"))
51+
self.assertFalse(authenticate("one@example.com|two@example.net", "mail@gmail.com"))
52+
self.assertFalse(authenticate("one@example.com|two@example.net", ""))
53+
self.assertFalse(authenticate("one@example.com|two@example.net", "*"))
54+
55+
def test_authenticate_wildcard_email(self):
56+
self.assertTrue(authenticate(".*@example.com", "one@example.com"))
57+
self.assertTrue(authenticate("one.*@example.com", "one@example.com"))
58+
self.assertTrue(authenticate("one.*@example.com", "one.two@example.com"))
59+
self.assertFalse(authenticate(".*@example.com", "attacker@example.com.attacker.com"))
60+
self.assertFalse(authenticate(".*@corp.example.com", "attacker@corpZexample.com"))
61+
self.assertFalse(authenticate(".*@corp\.example\.com", "attacker@corpZexample.com"))

0 commit comments

Comments
 (0)