Added following security enhancements:

1) Added ALLOWED_HOSTS list to limit the host address.
  2) Added CSP and HSTS security header.
  3) Hide the webserver/ development framework version.

Fixes #5919
This commit is contained in:
Ganesh Jaybhay 2020-10-20 17:14:45 +05:30 committed by Akshay Joshi
parent 3413a42af4
commit 08c4deba5a
11 changed files with 148 additions and 11 deletions

View File

@ -81,7 +81,8 @@ RUN apk add --no-cache \
flask_gravatar \ flask_gravatar \
flask_migrate \ flask_migrate \
simplejson \ simplejson \
cryptography cryptography \
netaddr
# Copy the docs from the local tree. Explicitly remove any existing builds that # Copy the docs from the local tree. Explicitly remove any existing builds that
# may be present # 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 the runner script
COPY pkg/docker/run_pgadmin.py /pgadmin4 COPY pkg/docker/run_pgadmin.py /pgadmin4
COPY pkg/docker/gunicorn_config.py /pgadmin4
COPY pkg/docker/entrypoint.sh /entrypoint.sh COPY pkg/docker/entrypoint.sh /entrypoint.sh
# Precompile and optimize python code to save time and space on startup # Precompile and optimize python code to save time and space on startup

View File

@ -22,3 +22,4 @@ Bug fixes
| `Issue #5858 <https://redmine.postgresql.org/issues/5858>`_ - Ensure that search object functionality works with case insensitive string. | `Issue #5858 <https://redmine.postgresql.org/issues/5858>`_ - Ensure that search object functionality works with case insensitive string.
| `Issue #5895 <https://redmine.postgresql.org/issues/5895>`_ - Fixed an issue where the suffix for Toast table size is not visible in the Statistics tab. | `Issue #5895 <https://redmine.postgresql.org/issues/5895>`_ - Fixed an issue where the suffix for Toast table size is not visible in the Statistics tab.
| `Issue #5911 <https://redmine.postgresql.org/issues/5911>`_ - Ensure that macros should be run on the older version of Safari and Chrome. | `Issue #5911 <https://redmine.postgresql.org/issues/5911>`_ - Ensure that macros should be run on the older version of Safari and Chrome.
| `Issue #5919 <https://redmine.postgresql.org/issues/5919>`_ - Added security related enhancements.

View File

@ -58,7 +58,7 @@ TIMEOUT=$(cd /pgadmin4 && python -c 'import config; print(config.SESSION_EXPIRAT
# Using --threads to have multi-threaded single-process worker # Using --threads to have multi-threaded single-process worker
if [ ! -z ${PGADMIN_ENABLE_TLS} ]; then 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 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 fi

View File

@ -0,0 +1,2 @@
import gunicorn
gunicorn.SERVER_SOFTWARE = 'Python'

View File

@ -41,4 +41,5 @@ Flask-Security-Too>=3.0.0
bcrypt<=3.1.7 bcrypt<=3.1.7
cryptography<=3.0 cryptography<=3.0
sshtunnel>=0.1.5 sshtunnel>=0.1.5
netaddr==0.8.0
ldap3>=2.5.1 ldap3>=2.5.1

View File

@ -143,12 +143,57 @@ DEFAULT_SERVER = '127.0.0.1'
# environment by the runtime # environment by the runtime
DEFAULT_SERVER_PORT = 5050 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. # Enable X-Frame-Option protection.
# Set to one of "SAMEORIGIN", "ALLOW-FROM origin" or "" to disable. # Set to one of "SAMEORIGIN", "ALLOW-FROM origin" or "" to disable.
# Note that "DENY" is NOT supported (and will be silently ignored). # Note that "DENY" is NOT supported (and will be silently ignored).
# See https://tools.ietf.org/html/rfc7034 for more info. # See https://tools.ietf.org/html/rfc7034 for more info.
X_FRAME_OPTIONS = "SAMEORIGIN" 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 # Hashing algorithm used for password storage
SECURITY_PASSWORD_HASH = 'pbkdf2_sha512' SECURITY_PASSWORD_HASH = 'pbkdf2_sha512'
@ -421,12 +466,14 @@ ON_DEMAND_RECORD_COUNT = 1000
SHOW_GRAVATAR_IMAGE = True SHOW_GRAVATAR_IMAGE = True
########################################################################## ##########################################################################
# Set cookie path # Set cookie path and options
########################################################################## ##########################################################################
COOKIE_DEFAULT_PATH = '/' COOKIE_DEFAULT_PATH = '/'
COOKIE_DEFAULT_DOMAIN = None COOKIE_DEFAULT_DOMAIN = None
SESSION_COOKIE_DOMAIN = None SESSION_COOKIE_DOMAIN = None
SESSION_COOKIE_SAMESITE = 'Lax' SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = False
SESSION_COOKIE_HTTPONLY = True
######################################################################### #########################################################################
# Skip storing session in files and cache for specific paths # Skip storing session in files and cache for specific paths

View File

@ -12,6 +12,7 @@ such as setup of logging, dynamic loading of modules etc."""
import logging import logging
import os import os
import sys import sys
import re
from types import MethodType from types import MethodType
from collections import defaultdict from collections import defaultdict
from importlib import import_module 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 flask import Flask, abort, request, current_app, session, url_for
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from flask_babelex import Babel, gettext from flask_babelex import Babel, gettext
from flask_babelex import gettext as _
from flask_login import user_logged_in, user_logged_out from flask_login import user_logged_in, user_logged_out
from flask_mail import Mail from flask_mail import Mail
from flask_paranoid import Paranoid from flask_paranoid import Paranoid
from flask_security import Security, SQLAlchemyUserDatastore, current_user from flask_security import Security, SQLAlchemyUserDatastore, current_user
from flask_security.utils import login_user, logout_user from flask_security.utils import login_user, logout_user
from netaddr import IPSet
from werkzeug.datastructures import ImmutableDict from werkzeug.datastructures import ImmutableDict
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from werkzeug.utils import find_modules 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 pgadmin.utils.versioned_template_loader import VersionedTemplateLoader
from datetime import timedelta from datetime import timedelta
from pgadmin.setup import get_version, set_version 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.utils.csrf import pgCSRFProtect
from pgadmin import authenticate from pgadmin import authenticate
from pgadmin.utils.security_headers import SecurityHeaders
winreg = None winreg = None
if os.name == 'nt': if os.name == 'nt':
@ -658,6 +662,36 @@ def create_app(app_name=None):
request.endpoint not in ('security.login', 'security.logout'): request.endpoint not in ('security.login', 'security.logout'):
logout_user() 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 @app.after_request
def after_request(response): def after_request(response):
if 'key' in request.args: if 'key' in request.args:
@ -667,13 +701,12 @@ def create_app(app_name=None):
domain['domain'] = config.COOKIE_DEFAULT_DOMAIN domain['domain'] = config.COOKIE_DEFAULT_DOMAIN
response.set_cookie('PGADMIN_INT_KEY', value=request.args['key'], response.set_cookie('PGADMIN_INT_KEY', value=request.args['key'],
path=config.COOKIE_DEFAULT_PATH, path=config.COOKIE_DEFAULT_PATH,
secure=config.SESSION_COOKIE_SECURE,
httponly=config.SESSION_COOKIE_HTTPONLY,
samesite=config.SESSION_COOKIE_SAMESITE,
**domain) **domain)
# X-Frame-Options for security SecurityHeaders.set_response_headers(response)
if config.X_FRAME_OPTIONS != "" and \
config.X_FRAME_OPTIONS.lower() != "deny":
response.headers["X-Frame-Options"] = config.X_FRAME_OPTIONS
return response return response
########################################################################## ##########################################################################

View File

@ -697,6 +697,9 @@ def index():
response.set_cookie("PGADMIN_LANGUAGE", value=language, response.set_cookie("PGADMIN_LANGUAGE", value=language,
path=config.COOKIE_DEFAULT_PATH, path=config.COOKIE_DEFAULT_PATH,
secure=config.SESSION_COOKIE_SECURE,
httponly=config.SESSION_COOKIE_HTTPONLY,
samesite=config.SESSION_COOKIE_SAMESITE,
**domain) **domain)
return response return response

View File

@ -232,6 +232,9 @@ def save(pid):
setattr(session, 'PGADMIN_LANGUAGE', language) setattr(session, 'PGADMIN_LANGUAGE', language)
response.set_cookie("PGADMIN_LANGUAGE", value=language, response.set_cookie("PGADMIN_LANGUAGE", value=language,
path=config.COOKIE_DEFAULT_PATH, path=config.COOKIE_DEFAULT_PATH,
secure=config.SESSION_COOKIE_SECURE,
httponly=config.SESSION_COOKIE_HTTPONLY,
samesite=config.SESSION_COOKIE_SAMESITE,
**domain) **domain)
return response return response

View File

@ -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]

View File

@ -311,7 +311,11 @@ class ManagedSessionInterface(SessionInterface):
response.set_cookie( response.set_cookie(
app.session_cookie_name, app.session_cookie_name,
'%s!%s' % (session.sid, session.hmac_digest), '%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
) )