mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2024-11-22 08:46:39 -06:00
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:
parent
3413a42af4
commit
08c4deba5a
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
2
pkg/docker/gunicorn_config.py
Normal file
2
pkg/docker/gunicorn_config.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import gunicorn
|
||||||
|
gunicorn.SERVER_SOFTWARE = 'Python'
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
41
web/pgadmin/utils/security_headers.py
Normal file
41
web/pgadmin/utils/security_headers.py
Normal 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]
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user