public inbox for [email protected]
help / color / mirror / Atom feedFrom: Ganesh Jaybhay <[email protected]>
To: pgadmin-hackers <[email protected]>
Subject: [pgAdmin][5919] Fix security related issues
Date: Mon, 19 Oct 2020 17:31:07 +0530
Message-ID: <CAK6syApbZRiHvJ9Z=mzAg6XPY79wWCPQsyBXo+3kut5UPUEsDA@mail.gmail.com> (raw)
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.
Regards,
Ganesh Jaybhay
Attachments:
[application/octet-stream] RM5919.patch (12.9K, 3-RM5919.patch)
download | inline diff:
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..5d80a28 100644
--- a/web/config.py
+++ b/web/config.py
@@ -143,12 +143,53 @@ 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';"
+
+# 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 +462,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..c3cf31c
--- /dev/null
+++ b/web/pgadmin/utils/security_headers.py
@@ -0,0 +1,36 @@
+##########################################################################
+#
+# 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',
+ 'STRICT_TRANSPORT_SECURITY': 'Strict-Transport-Security',
+ '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
+
+ # 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
)
view thread (4+ messages) latest in thread
reply
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Reply to all the recipients using the --to and --cc options:
reply via email
To: [email protected]
Cc: [email protected]
Subject: Re: [pgAdmin][5919] Fix security related issues
In-Reply-To: <CAK6syApbZRiHvJ9Z=mzAg6XPY79wWCPQsyBXo+3kut5UPUEsDA@mail.gmail.com>
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox