diff --git a/Dockerfile b/Dockerfile index 38c131027..3f5d5045a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,7 +81,8 @@ RUN apk add --no-cache \ flask_gravatar \ flask_migrate \ simplejson \ - cryptography + cryptography \ + netaddr # Copy the docs from the local tree. Explicitly remove any existing builds that # may be present @@ -177,6 +178,7 @@ RUN ln -sf /usr/lib/libpq.so.5.12 /usr/lib/libpq.so.5 # Copy the runner script COPY pkg/docker/run_pgadmin.py /pgadmin4 +COPY pkg/docker/gunicorn_config.py /pgadmin4 COPY pkg/docker/entrypoint.sh /entrypoint.sh # Precompile and optimize python code to save time and space on startup diff --git a/docs/en_US/release_notes_4_28.rst b/docs/en_US/release_notes_4_28.rst index e1dccbcbe..6281277df 100644 --- a/docs/en_US/release_notes_4_28.rst +++ b/docs/en_US/release_notes_4_28.rst @@ -22,3 +22,4 @@ Bug fixes | `Issue #5858 `_ - Ensure that search object functionality works with case insensitive string. | `Issue #5895 `_ - Fixed an issue where the suffix for Toast table size is not visible in the Statistics tab. | `Issue #5911 `_ - Ensure that macros should be run on the older version of Safari and Chrome. +| `Issue #5919 `_ - Added security related enhancements. diff --git a/pkg/docker/entrypoint.sh b/pkg/docker/entrypoint.sh index 5a482c7a3..93d809f36 100755 --- a/pkg/docker/entrypoint.sh +++ b/pkg/docker/entrypoint.sh @@ -58,7 +58,7 @@ TIMEOUT=$(cd /pgadmin4 && python -c 'import config; print(config.SESSION_EXPIRAT # Using --threads to have multi-threaded single-process worker if [ ! -z ${PGADMIN_ENABLE_TLS} ]; then - exec gunicorn --timeout ${TIMEOUT} --bind ${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-443} -w 1 --threads ${GUNICORN_THREADS:-25} --access-logfile ${GUNICORN_ACCESS_LOGFILE:--} --keyfile /certs/server.key --certfile /certs/server.cert run_pgadmin:app + exec gunicorn --timeout ${TIMEOUT} --bind ${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-443} -w 1 --threads ${GUNICORN_THREADS:-25} --access-logfile ${GUNICORN_ACCESS_LOGFILE:--} --keyfile /certs/server.key --certfile /certs/server.cert -c gunicorn_config.py run_pgadmin:app else - exec gunicorn --timeout ${TIMEOUT} --bind ${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-80} -w 1 --threads ${GUNICORN_THREADS:-25} --access-logfile ${GUNICORN_ACCESS_LOGFILE:--} run_pgadmin:app + exec gunicorn --timeout ${TIMEOUT} --bind ${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-80} -w 1 --threads ${GUNICORN_THREADS:-25} --access-logfile ${GUNICORN_ACCESS_LOGFILE:--} -c gunicorn_config.py run_pgadmin:app fi diff --git a/pkg/docker/gunicorn_config.py b/pkg/docker/gunicorn_config.py new file mode 100644 index 000000000..513c889fe --- /dev/null +++ b/pkg/docker/gunicorn_config.py @@ -0,0 +1,2 @@ +import gunicorn +gunicorn.SERVER_SOFTWARE = 'Python' diff --git a/requirements.txt b/requirements.txt index a5815a34c..dbb008323 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,5 @@ Flask-Security-Too>=3.0.0 bcrypt<=3.1.7 cryptography<=3.0 sshtunnel>=0.1.5 +netaddr==0.8.0 ldap3>=2.5.1 diff --git a/web/config.py b/web/config.py index 702e73f12..b893e352c 100644 --- a/web/config.py +++ b/web/config.py @@ -143,12 +143,57 @@ DEFAULT_SERVER = '127.0.0.1' # environment by the runtime DEFAULT_SERVER_PORT = 5050 +# This param is used to validate ALLOWED_HOSTS for the application +# This will be used to avoid Host Header Injection attack +# For how to set ALLOWED_HOSTS see netaddr library +# For more details https://netaddr.readthedocs.io/en/latest/tutorial_03.html +# e.g. ALLOWED_HOSTS = ['192.0.2.0/28', '::192.0.2.0/124'] +# ALLOWED_HOSTS = ['225.0.0.0/8', '226.0.0.0/7', '228.0.0.0/6'] +# ALLOWED_HOSTS = ['127.0.0.1', '192.168.0.1'] +# if ALLOWED_HOSTS= [] then it will accept all ips (and application will be +# vulnerable to Host Header Injection attack) +ALLOWED_HOSTS = [] + +# This param is used to override the default web server information about +# the web technology and the frameworks being used in the application +# An attacker could use this information to fingerprint underlying operating +# system and research known exploits for the specific version of +# software in use +WEB_SERVER = 'Python' + # Enable X-Frame-Option protection. # Set to one of "SAMEORIGIN", "ALLOW-FROM origin" or "" to disable. # Note that "DENY" is NOT supported (and will be silently ignored). # See https://tools.ietf.org/html/rfc7034 for more info. X_FRAME_OPTIONS = "SAMEORIGIN" +# The Content-Security-Policy header allows you to restrict how resources +# such as JavaScript, CSS, or pretty much anything that the browser loads. +# see https://content-security-policy.com/#source_list for more info +# e.g. "default-src https: data: 'unsafe-inline' 'unsafe-eval';" +CONTENT_SECURITY_POLICY = "default-src http: data: blob: 'unsafe-inline' " \ + "'unsafe-eval';" + +# STRICT_TRANSPORT_SECURITY_ENABLED when set to True will set the +# Strict-Transport-Security header +STRICT_TRANSPORT_SECURITY_ENABLED = False + +# The Strict-Transport-Security header tells the browser to convert all HTTP +# requests to HTTPS, preventing man-in-the-middle (MITM) attacks. +# e.g. 'max-age=31536000; includeSubDomains' +STRICT_TRANSPORT_SECURITY = "max-age=31536000; includeSubDomains" + +# The X-Content-Type-Options header forces the browser to honor the response +# content type instead of trying to detect it, which can be abused to +# generate a cross-site scripting (XSS) attack. +# e.g. nosniff +X_CONTENT_TYPE_OPTIONS = "nosniff" + +# The browser will try to prevent reflected XSS attacks by not loading the +# page if the request contains something that looks like JavaScript and the +# response contains the same data. e.g. '1; mode=block' +X_XSS_PROTECTION = "1; mode=block" + # Hashing algorithm used for password storage SECURITY_PASSWORD_HASH = 'pbkdf2_sha512' @@ -421,12 +466,14 @@ ON_DEMAND_RECORD_COUNT = 1000 SHOW_GRAVATAR_IMAGE = True ########################################################################## -# Set cookie path +# Set cookie path and options ########################################################################## COOKIE_DEFAULT_PATH = '/' COOKIE_DEFAULT_DOMAIN = None SESSION_COOKIE_DOMAIN = None SESSION_COOKIE_SAMESITE = 'Lax' +SESSION_COOKIE_SECURE = False +SESSION_COOKIE_HTTPONLY = True ######################################################################### # Skip storing session in files and cache for specific paths diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py index 275f3656c..59cab6250 100644 --- a/web/pgadmin/__init__.py +++ b/web/pgadmin/__init__.py @@ -12,6 +12,7 @@ such as setup of logging, dynamic loading of modules etc.""" import logging import os import sys +import re from types import MethodType from collections import defaultdict from importlib import import_module @@ -19,11 +20,13 @@ from importlib import import_module from flask import Flask, abort, request, current_app, session, url_for from werkzeug.exceptions import HTTPException from flask_babelex import Babel, gettext +from flask_babelex import gettext as _ from flask_login import user_logged_in, user_logged_out from flask_mail import Mail from flask_paranoid import Paranoid from flask_security import Security, SQLAlchemyUserDatastore, current_user from flask_security.utils import login_user, logout_user +from netaddr import IPSet from werkzeug.datastructures import ImmutableDict from werkzeug.local import LocalProxy from werkzeug.utils import find_modules @@ -36,9 +39,10 @@ from pgadmin.utils.session import create_session_interface, pga_unauthorised from pgadmin.utils.versioned_template_loader import VersionedTemplateLoader from datetime import timedelta from pgadmin.setup import get_version, set_version -from pgadmin.utils.ajax import internal_server_error +from pgadmin.utils.ajax import internal_server_error, make_json_response from pgadmin.utils.csrf import pgCSRFProtect from pgadmin import authenticate +from pgadmin.utils.security_headers import SecurityHeaders winreg = None if os.name == 'nt': @@ -658,6 +662,36 @@ def create_app(app_name=None): request.endpoint not in ('security.login', 'security.logout'): logout_user() + @app.before_request + def limit_host_addr(): + """ + This function validate the hosts from ALLOWED_HOSTS before allowing + HTTP request to avoid Host Header Injection attack + :return: None/JSON response with 403 HTTP status code + """ + client_host = str(request.host).split(':')[0] + valid = True + allowed_hosts = config.ALLOWED_HOSTS + + if len(allowed_hosts) != 0: + regex = re.compile( + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?:/\d{1,2}|)') + # Create separate list for ip addresses and host names + ip_set = list(filter(lambda ip: regex.match(ip), allowed_hosts)) + host_set = list(filter(lambda ip: not regex.match(ip), + allowed_hosts)) + is_ip = regex.match(client_host) + if is_ip: + valid = IPSet(ip_set).__contains__(client_host) + else: + valid = host_set.__contains__(client_host) + + if not valid: + return make_json_response( + status=403, success=0, + errormsg=_("403 FORBIDDEN") + ) + @app.after_request def after_request(response): if 'key' in request.args: @@ -667,13 +701,12 @@ def create_app(app_name=None): domain['domain'] = config.COOKIE_DEFAULT_DOMAIN response.set_cookie('PGADMIN_INT_KEY', value=request.args['key'], path=config.COOKIE_DEFAULT_PATH, + secure=config.SESSION_COOKIE_SECURE, + httponly=config.SESSION_COOKIE_HTTPONLY, + samesite=config.SESSION_COOKIE_SAMESITE, **domain) - # X-Frame-Options for security - if config.X_FRAME_OPTIONS != "" and \ - config.X_FRAME_OPTIONS.lower() != "deny": - response.headers["X-Frame-Options"] = config.X_FRAME_OPTIONS - + SecurityHeaders.set_response_headers(response) return response ########################################################################## diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py index 5375e944f..6ed138b20 100644 --- a/web/pgadmin/browser/__init__.py +++ b/web/pgadmin/browser/__init__.py @@ -697,6 +697,9 @@ def index(): response.set_cookie("PGADMIN_LANGUAGE", value=language, path=config.COOKIE_DEFAULT_PATH, + secure=config.SESSION_COOKIE_SECURE, + httponly=config.SESSION_COOKIE_HTTPONLY, + samesite=config.SESSION_COOKIE_SAMESITE, **domain) return response diff --git a/web/pgadmin/preferences/__init__.py b/web/pgadmin/preferences/__init__.py index db11d1d09..d05c1c89b 100644 --- a/web/pgadmin/preferences/__init__.py +++ b/web/pgadmin/preferences/__init__.py @@ -232,6 +232,9 @@ def save(pid): setattr(session, 'PGADMIN_LANGUAGE', language) response.set_cookie("PGADMIN_LANGUAGE", value=language, path=config.COOKIE_DEFAULT_PATH, + secure=config.SESSION_COOKIE_SECURE, + httponly=config.SESSION_COOKIE_HTTPONLY, + samesite=config.SESSION_COOKIE_SAMESITE, **domain) return response diff --git a/web/pgadmin/utils/security_headers.py b/web/pgadmin/utils/security_headers.py new file mode 100644 index 000000000..d85b5eb57 --- /dev/null +++ b/web/pgadmin/utils/security_headers.py @@ -0,0 +1,41 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2020, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +######################################################################### + +import config + + +class SecurityHeaders: + + @staticmethod + def set_response_headers(response): + """set response security headers""" + + params_dict = { + 'CONTENT_SECURITY_POLICY': 'Content-Security-Policy', + 'X_CONTENT_TYPE_OPTIONS': 'X-Content-Type-Options', + 'X_XSS_PROTECTION': 'X-XSS-Protection', + 'WEB_SERVER': 'Server', + } + + # X-Frame-Options for security + if config.X_FRAME_OPTIONS != "" and \ + config.X_FRAME_OPTIONS.lower() != "deny": + response.headers["X-Frame-Options"] = config.X_FRAME_OPTIONS + + # Strict-Transport-Security + if config.STRICT_TRANSPORT_SECURITY_ENABLED and \ + config.STRICT_TRANSPORT_SECURITY != "": + response.headers["Strict-Transport-Security"] = \ + config.STRICT_TRANSPORT_SECURITY + + # add other security options + for key in params_dict: + if key in config.__dict__ and config.__dict__[key] != "" \ + and config.__dict__[key] is not None: + response.headers[params_dict[key]] = config.__dict__[key] diff --git a/web/pgadmin/utils/session.py b/web/pgadmin/utils/session.py index b8af3d468..62e03babc 100644 --- a/web/pgadmin/utils/session.py +++ b/web/pgadmin/utils/session.py @@ -311,7 +311,11 @@ class ManagedSessionInterface(SessionInterface): response.set_cookie( app.session_cookie_name, '%s!%s' % (session.sid, session.hmac_digest), - expires=cookie_exp, httponly=True, domain=domain + expires=cookie_exp, + secure=config.SESSION_COOKIE_SECURE, + httponly=config.SESSION_COOKIE_HTTPONLY, + samesite=config.SESSION_COOKIE_SAMESITE, + domain=domain )