Thank you Dave for the suggestion.

Please find the attached updated patch to make HSTS by default disabled and
conditional based on flag.

Regards,
Ganesh Jaybhay

On Mon, Oct 19, 2020 at 5:38 PM Dave Page <dp...@pgadmin.org> wrote:

> Hi
>
> On Mon, Oct 19, 2020 at 1:01 PM Ganesh Jaybhay <
> ganesh.jayb...@enterprisedb.com> wrote:
>
>> Hi Hackers,
>>
>> Please find the attached patch to fix the below security issues:
>>
>>    - Host Header Injection - Added ALLOWED_HOSTS list to limit host
>>    address
>>    - Lack of Content Security Policy (CSP) - Added security header
>>    - Lack of Protection Mechanisms - HSTS - Added security header
>>    - Lack of Cookie Attribute – Secure : Kept as False as secure limits
>>    cookies to HTTPS traffic only.
>>    - Information Disclosure – Web Server / Development Framework
>>    VersionDescription: Kept as hard coded 'Python' instead of exposing
>>    wsgi/python/gunicorn version info.
>>
>> Please review and let me know if I have missed anything.
>>
>
> I took a very quick look at this, and one thing that immediately stood out
> is that HSTS should definitely not be enabled by default. That can make
> dev/test/redeploy extremely difficult.
>
> --
> Dave Page
> Blog: http://pgsnake.blogspot.com
> Twitter: @pgsnake
>
> EDB: http://www.enterprisedb.com
>
>
diff --git a/Dockerfile b/Dockerfile
index 38c1310..3f5d504 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/pkg/docker/entrypoint.sh b/pkg/docker/entrypoint.sh
index 5a482c7..93d809f 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 0000000..513c889
--- /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 a5815a3..dbb0083 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 702e73f..b893e35 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 275f365..59cab62 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 5375e94..6ed138b 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 db11d1d..d05c1c8 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 0000000..d85b5eb
--- /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 b8af3d4..62e03ba 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
         )
 
 

Reply via email to