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_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

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 #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 #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
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

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
cryptography<=3.0
sshtunnel>=0.1.5
netaddr==0.8.0
ldap3>=2.5.1

View File

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

View File

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

View File

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

View File

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

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(
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
)