From 79966e36e3f74a50e923c74e83faf76e16c6ef13 Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Sun, 12 Sep 2021 19:51:16 -0400 Subject: [PATCH] Set a cookie for /admin/munin pages to grant access to Munin reports The /admin/munin routes used the same Authorization: header logic as the other API routes, but they are browsed directly in the browser because they are handled as static pages or as a proxy to a CGI script. This required users to enter their email username/password for HTTP basic authentication in the standard browser auth prompt, which wasn't ideal (and may leak the password in browser storage). It also stopped working when MFA was enabled for user accounts. A token is now set in a cookie when visiting /admin/munin which is then checked in the routes that proxy the Munin pages. The cookie's lifetime is kept limited to limit the opportunity for any unknown CSRF attacks via the Munin CGI script. --- CHANGELOG.md | 1 + management/auth.py | 16 +++++++++------ management/daemon.py | 36 ++++++++++++++++++++++++++++----- management/templates/index.html | 6 +++++- management/templates/munin.html | 20 ++++++++++++++++++ 5 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 management/templates/munin.html diff --git a/CHANGELOG.md b/CHANGELOG.md index e22592fe..7dd0b9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Control panel: * After logging in, the default page is now a fast-loading welcome page rather than the slow-loading system status checks page. * The backup retention period option now displays for B2 backup targets. * The DNSSEC DS record recommendations are cleaned up and now recommend changing records that use SHA1. +* The Munin monitoring pages no longer require a separate HTTP basic authentication login and can be used if two-factor authentication is turned on. * Control panel logins are now tied to a session backend that allows true logouts (rather than an encrypted cookie). * Failed logins no longer directly reveal whether the email address corresponds to a user account. * Browser dark mode now inverts the color scheme. diff --git a/management/auth.py b/management/auth.py index 38c15e91..0a88c457 100644 --- a/management/auth.py +++ b/management/auth.py @@ -72,14 +72,9 @@ class AuthService: return (None, ["admin"]) # If the password corresponds with a session token for the user, grant access for that user. - if password in self.sessions and self.sessions[password]["email"] == username and not login_only: + if self.get_session(username, password, "login", env) and not login_only: sessionid = password session = self.sessions[sessionid] - if session["password_token"] != self.create_user_password_state_token(username, env): - # This session is invalid because the user's password/MFA state changed - # after the session was created. - del self.sessions[sessionid] - raise ValueError("Session expired.") if logout: # Clear the session. del self.sessions[sessionid] @@ -158,5 +153,14 @@ class AuthService: self.sessions[token] = { "email": username, "password_token": self.create_user_password_state_token(username, env), + "type": type, } return token + + def get_session(self, user_email, session_key, session_type, env): + if session_key not in self.sessions: return None + session = self.sessions[session_key] + if session_type == "login" and session["email"] != user_email: return None + if session["type"] != session_type: return None + if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None + return session diff --git a/management/daemon.py b/management/daemon.py index ca891772..280efec0 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -654,16 +654,42 @@ def privacy_status_set(): # MUNIN @app.route('/munin/') -@app.route('/munin/') @authorized_personnel_only -def munin(filename=""): - # Checks administrative access (@authorized_personnel_only) and then just proxies - # the request to static files. +def munin_start(): + # Munin pages, static images, and dynamically generated images are served + # outside of the AJAX API. We'll start with a 'start' API that sets a cookie + # that subsequent requests will read for authorization. (We don't use cookies + # for the API to avoid CSRF vulnerabilities.) + response = make_response("OK") + response.set_cookie("session", auth_service.create_session_key(request.user_email, env, type='cookie'), + max_age=60*30, secure=True, httponly=True, samesite="Strict") # 30 minute duration + return response + +def check_request_cookie_for_admin_access(): + session = auth_service.get_session(None, request.cookies.get("session", ""), "cookie", env) + if not session: return False + privs = get_mail_user_privileges(session["email"], env) + if not isinstance(privs, list): return False + if "admin" not in privs: return False + return True + +def authorized_personnel_only_via_cookie(f): + @wraps(f) + def g(*args, **kwargs): + if not check_request_cookie_for_admin_access(): + return Response("Unauthorized", status=403, mimetype='text/plain', headers={}) + return f(*args, **kwargs) + return g + +@app.route('/munin/') +@authorized_personnel_only_via_cookie +def munin_static_file(filename=""): + # Proxy the request to static files. if filename == "": filename = "index.html" return send_from_directory("/var/cache/munin/www", filename) @app.route('/munin/cgi-graph/') -@authorized_personnel_only +@authorized_personnel_only_via_cookie def munin_cgi(filename): """ Relay munin cgi dynazoom requests /usr/lib/munin/cgi/munin-cgi-graph is a perl cgi script in the munin package diff --git a/management/templates/index.html b/management/templates/index.html index 115693aa..f9c87f2c 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -124,7 +124,7 @@
  • Custom DNS
  • External DNS
  • -
  • Munin Monitoring
  • +
  • Munin Monitoring
  • Mail
  • @@ -202,6 +202,10 @@ {% include "ssl.html" %} +
    + {% include "munin.html" %} +
    +