From e58398894e3c6325e46393b8545bfd6d02d10def Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Mon, 5 Jun 2017 13:16:40 -0400 Subject: [PATCH 1/9] Improve SSL security: * Disable SSLv2, SSLv3, and compression * Update default permitted ciphers * --hsts - adds HTTP Strict Transport Security header to responses * --sslkey - loads SSL private key from a separate file --- droopy | 60 +++++++++++++++++++++++++++++++++++++++++----------- man/droopy.1 | 8 +++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/droopy b/droopy index 5945cda..9091dfb 100755 --- a/droopy +++ b/droopy @@ -6,6 +6,10 @@ Copyright 2008-2013 (c) Pierre Duquesne Licensed under the New BSD License. Changelog + 20170605 * Disable SSLv2, SSLv3, and compression + * Update default permitted ciphers + * --hsts - adds HTTP Strict Transport Security header to responses + * --sslkey - loads SSL private key from a separate file 20151025 * Global variables removed * Code refactoring and re-layout * Python 2 and 3 compatibility @@ -211,6 +215,7 @@ class HTTPUploadHandler(httpserver.BaseHTTPRequestHandler): form_field = 'upfile' auth = '' certfile = None + hsts = None divpicture = '
' def get_case_insensitive_header(self, hdrname, default): @@ -360,6 +365,8 @@ class HTTPUploadHandler(httpserver.BaseHTTPRequestHandler): def send_resp_headers(self, response_code, headers_dict, end=False): "Just a shortcut for a common operation." self.send_response(response_code) + if self.hsts: + self.send_header('Strict-Transport-Security', 'max-age=' + str(self.hsts)) for k, v in headers_dict.items(): self.send_header(k, v) if end: @@ -424,16 +431,18 @@ def run(hostname='', auth='', certfile=None, permitted_ciphers=( - 'ECDH+AESGCM:ECDH+AES256:ECDH+AES128:ECDH+3DES' - ':RSA+AESGCM:RSA+AES:RSA+3DES' - ':!aNULL:!MD5:!DSS')): + 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+CHACHA20:ECDH+AES256:DH+AES256:' + 'ECDH+AES128:DH+AES:ECDH+HIGH:DH+HIGH:RSA+AESGCM:RSA+AES:RSA+HIGH:' + '!aNULL:!eNULL:!MD5:!DSS:!RC4:!3DES'), + keyfile=None, + hsts=None): """ certfile should be the path of a PEM TLS certificate. + keyfile should be a PEM private key, otherwise it's assumed to be in the certfile. permitted_ciphers, if a TLS cert is provided, is an OpenSSL cipher string. - The default here is taken from: - https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/ - ..with DH-only ciphers removed because of precomputation hazard. + The default here is taken from Python 3.6.1: + https://github.com/python/cpython/blob/v3.6.1/Lib/ssl.py#L208-L212 """ if templates is None or localisations is None: raise ValueError("Must provide templates *and* localisations.") @@ -442,24 +451,34 @@ def run(hostname='', HTTPUploadHandler.directory = directory HTTPUploadHandler.localisations = localisations HTTPUploadHandler.certfile = certfile + HTTPUploadHandler.hsts = hsts HTTPUploadHandler.publish_files = publish_files HTTPUploadHandler.picture = picture HTTPUploadHandler.message = message HTTPUploadHandler.file_mode = file_mode HTTPUploadHandler.auth = auth httpd = ThreadedHTTPServer((hostname, port), HTTPUploadHandler) - # TODO: Specify TLS1.2 only? if certfile: try: import ssl except: print("Error: Could not import module 'ssl', exiting.") sys.exit(2) - httpd.socket = ssl.wrap_socket( - httpd.socket, - certfile=certfile, - ciphers=permitted_ciphers, - server_side=True) + try: + ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ctx.load_cert_chain(certfile, keyfile) + ctx.set_ciphers(permitted_ciphers) + httpd.socket = ctx.wrap_socket( + httpd.socket, + server_side=True) + except AttributeError: + print("Warning: Use Python 2.7.9+ or 3.4+ for improved SSL security.") + httpd.socket = ssl.wrap_socket( + httpd.socket, + certfile=certfile, + keyfile=keyfile, + ciphers=permitted_ciphers, + server_side=True) httpd.serve_forever() # -- Dato @@ -1024,6 +1043,10 @@ def parse_args(cmd=None, ignore_defaults=False): help='set the authentication credentials, in form USER:PASS') parser.add_argument('--ssl', type=str, default='', help='set up https using the certificate file') + parser.add_argument('--sslkey', type=str, default='', + help='load ssl key from this separate file') + parser.add_argument('--hsts', type=int, nargs='?', metavar='SECONDS', const=15811200, # 6 months + help='add HTTP Strict Transport Security header to responses') parser.add_argument('--chmod', type=str, default=None, help='set the file permissions (octal value)') parser.add_argument('--save-config', action='store_true', default=False, @@ -1053,6 +1076,11 @@ def parse_args(cmd=None, ignore_defaults=False): print("PEM file not found: '{0}'".format(args.ssl)) sys.exit(1) args.ssl = fullpath(args.ssl) + if args.sslkey: + if not os.path.isfile(args.sslkey): + print("Private key PEM file not found: '{0}'".format(args.sslkey)) + sys.exit(1) + args.sslkey = fullpath(args.sslkey) if args.chmod is not None: try: args.chmod = int(args.chmod, 8) @@ -1092,6 +1120,12 @@ def main(): cfg = args.get('config_file', default_configfile()) save_options(cfg) print("Options saved in {0}".format(cfg)) + if not args['ssl']: + if args['sslkey']: + print("Ignoring --sslkey (use --ssl to enable SSL)") + if args['hsts']: + print("Ignoring --hsts (use --ssl to enable SSL)") + args['hsts'] = None print("Files will be uploaded to {0}\n".format(args['directory'])) proto = 'https' if args['ssl'] else 'http' print("HTTP server starting...", @@ -1099,6 +1133,8 @@ def main(): try: run(port=args['port'], certfile=args['ssl'], + keyfile=args['sslkey'], + hsts=args['hsts'], picture=args['picture'], message=args['message'], directory=args['directory'], diff --git a/man/droopy.1 b/man/droopy.1 index 25a2503..5535c57 100644 --- a/man/droopy.1 +++ b/man/droopy.1 @@ -39,6 +39,14 @@ Set the authentication credentials. .br Set up https using the certificate file. .TP 5 +\fB\-\-sslkey PEMFILE\fP +.br +Load SSL private key from a separate file. +.TP 5 +\fB\-\-hsts [SECONDS]\fP +.br +Add HTTP Strict Transport Security header to responses (default: 6 months). +.TP 5 \fB\-\-chmod MODE\fP .br set the file permissions (octal value). From 4577b4fa759d6b84a9348d22d49d8d09c80f5823 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Mon, 1 Feb 2021 13:29:46 -0500 Subject: [PATCH 2/9] Update default permitted ciphers --- droopy | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/droopy b/droopy index 9091dfb..24a39fa 100755 --- a/droopy +++ b/droopy @@ -6,6 +6,7 @@ Copyright 2008-2013 (c) Pierre Duquesne Licensed under the New BSD License. Changelog + 20200201 * Update default permitted ciphers 20170605 * Disable SSLv2, SSLv3, and compression * Update default permitted ciphers * --hsts - adds HTTP Strict Transport Security header to responses @@ -430,7 +431,9 @@ def run(hostname='', publish_files=False, auth='', certfile=None, - permitted_ciphers=( + permitted_ciphers=None if sys.version_info >= (3, 7) else ( + 'TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:' + 'TLS13-AES-128-GCM-SHA256:' 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+CHACHA20:ECDH+AES256:DH+AES256:' 'ECDH+AES128:DH+AES:ECDH+HIGH:DH+HIGH:RSA+AESGCM:RSA+AES:RSA+HIGH:' '!aNULL:!eNULL:!MD5:!DSS:!RC4:!3DES'), @@ -441,8 +444,8 @@ def run(hostname='', keyfile should be a PEM private key, otherwise it's assumed to be in the certfile. permitted_ciphers, if a TLS cert is provided, is an OpenSSL cipher string. - The default here is taken from Python 3.6.1: - https://github.com/python/cpython/blob/v3.6.1/Lib/ssl.py#L208-L212 + The default is Python's default when using v3.7+, otherwise it's Python 3.6.12's default: + https://github.com/python/cpython/blob/v3.6.12/Lib/ssl.py#L212-L218 """ if templates is None or localisations is None: raise ValueError("Must provide templates *and* localisations.") @@ -467,7 +470,8 @@ def run(hostname='', try: ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ctx.load_cert_chain(certfile, keyfile) - ctx.set_ciphers(permitted_ciphers) + if permitted_ciphers: + ctx.set_ciphers(permitted_ciphers) httpd.socket = ctx.wrap_socket( httpd.socket, server_side=True) From a5d5b7cab4b7d3beb5880c9f96d9b206aa21c1e6 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Thu, 1 Jun 2023 12:36:49 -0400 Subject: [PATCH 3/9] Change default interpreter to Python 3 --- droopy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/droopy b/droopy index 9747093..45b53e5 100755 --- a/droopy +++ b/droopy @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Droopy (http://stackp.online.fr/droopy) From 925ee1b7991d5392bad6d512d9c064dd638c433f Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Thu, 1 Jun 2023 12:39:58 -0400 Subject: [PATCH 4/9] Correctly handle str/bytes in check_auth() --- droopy | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/droopy b/droopy index 45b53e5..a408997 100755 --- a/droopy +++ b/droopy @@ -123,9 +123,8 @@ def check_auth(method): def decorated(self, *pargs): "Reject if auth fails." if self.auth: - # TODO: Between minor versions this handles str/bytes differently received = self.get_case_insensitive_header('Authorization', None) - expected = 'Basic ' + base64.b64encode(self.auth) + expected = 'Basic ' + base64.b64encode(self.auth.encode()).decode() # TODO: Timing attack? if received != expected: self.send_response(401) From 76b5205e85fd46b9f31149e9ce961c6f13627250 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Thu, 1 Jun 2023 12:41:32 -0400 Subject: [PATCH 5/9] Replace basename() with os.path.basename() --- droopy | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/droopy b/droopy index a408997..72bbad6 100755 --- a/droopy +++ b/droopy @@ -82,9 +82,6 @@ else: import cgi import os -import posixpath -import os.path -import ntpath import argparse import mimetypes import shutil @@ -112,12 +109,6 @@ def fullpath(path): "Shortcut for os.path abspath(expanduser())" return os.path.abspath(os.path.expanduser(path)) -def basename(path): - "Extract the file base name (some browsers send the full file path)." - for mod in posixpath, os.path, ntpath: - path = mod.basename(path) - return path - def check_auth(method): "Wraps methods on the request handler to require simple auth checks." def decorated(self, *pargs): @@ -327,7 +318,7 @@ class HTTPUploadHandler(httpserver.BaseHTTPRequestHandler): if not isinstance(file_items, list): file_items = [file_items] for item in file_items: - filename = _decode_str_if_py2(basename(item.filename), "utf-8") + filename = _decode_str_if_py2(os.path.basename(item.filename), "utf-8") if filename == "": continue localpath = _encode_str_if_py2(os.path.join(self.directory, filename), "utf-8") From 027cfe2f4747cc5023b2c39168fec8b8f002472a Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Thu, 1 Jun 2023 12:45:34 -0400 Subject: [PATCH 6/9] Add args to DroopyFieldStorage() for Python 3.8+ --- droopy | 3 +++ 1 file changed, 3 insertions(+) diff --git a/droopy b/droopy index 72bbad6..037a4c6 100755 --- a/droopy +++ b/droopy @@ -6,6 +6,7 @@ Copyright 2008-2013 (c) Pierre Duquesne Licensed under the New BSD License. Changelog + 20230601 * Updates for Python 3.8+ 20200201 * Update default permitted ciphers 20170605 * Disable SSLv2, SSLv3, and compression * Update default permitted ciphers @@ -150,6 +151,7 @@ class DroopyFieldStorage(cgi.FieldStorage): def __init__(self, fp=None, headers=None, outerboundary=b'', environ=os.environ, keep_blank_values=0, strict_parsing=0, limit=None, encoding='utf-8', errors='replace', + max_num_fields=None, separator='&', directory='.'): """ Adds 'directory' argument to FieldStorage.__init__. @@ -159,6 +161,7 @@ class DroopyFieldStorage(cgi.FieldStorage): # Not only is cgi.FieldStorage full of magic, it's DIFFERENT # magic in Py2/Py3. Here's a case of the core library making # life difficult, in a class that's *supposed to be subclassed*! + # TODO: fix passing of max_num_fields and separator parameters if sys.version_info > (3,): cgi.FieldStorage.__init__(self, fp, headers, outerboundary, environ, keep_blank_values, From 6ba7099317ec6e7423509943ae0b0b7a4d4ffb2f Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Thu, 1 Jun 2023 16:41:05 -0400 Subject: [PATCH 7/9] Default ciphers all use perfect forward secrecy --- droopy | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/droopy b/droopy index 037a4c6..d726652 100755 --- a/droopy +++ b/droopy @@ -6,6 +6,7 @@ Copyright 2008-2013 (c) Pierre Duquesne Licensed under the New BSD License. Changelog + 20230601 * All default ciphers now use perfect forward secrecy 20230601 * Updates for Python 3.8+ 20200201 * Update default permitted ciphers 20170605 * Disable SSLv2, SSLv3, and compression @@ -428,7 +429,7 @@ def run(hostname='', 'TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:' 'TLS13-AES-128-GCM-SHA256:' 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+CHACHA20:ECDH+AES256:DH+AES256:' - 'ECDH+AES128:DH+AES:ECDH+HIGH:DH+HIGH:RSA+AESGCM:RSA+AES:RSA+HIGH:' + 'ECDH+AES128:DH+AES:ECDH+HIGH:DH+HIGH:' '!aNULL:!eNULL:!MD5:!DSS:!RC4:!3DES'), keyfile=None, hsts=None): @@ -437,7 +438,8 @@ def run(hostname='', keyfile should be a PEM private key, otherwise it's assumed to be in the certfile. permitted_ciphers, if a TLS cert is provided, is an OpenSSL cipher string. - The default is Python's default when using v3.7+, otherwise it's Python 3.6.12's default: + The default is Python's default when using v3.7+, otherwise it's Python 3.6.12's + default (below); in either case ciphers missing perfect forward secrecy are excluded. https://github.com/python/cpython/blob/v3.6.12/Lib/ssl.py#L212-L218 """ if templates is None or localisations is None: @@ -465,6 +467,11 @@ def run(hostname='', ctx.load_cert_chain(certfile, keyfile) if permitted_ciphers: ctx.set_ciphers(permitted_ciphers) + else: + # Remove ciphers which don't support perfect forward secrecy (those whose only + # key exchange algorithm is RSA). Note that this requires an initially sane list, + # such as that created by Python 3.7+'s ssl.create_default_context() + ctx.set_ciphers(':'.join(c['name'] for c in ctx.get_ciphers() if c['kea'] != 'kx-rsa')) httpd.socket = ctx.wrap_socket( httpd.socket, server_side=True) From f38f7e7720f54763f130a23105edab6d941f2a52 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Thu, 1 Jun 2023 17:39:27 -0400 Subject: [PATCH 8/9] No default ciphers use SHA1 --- droopy | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/droopy b/droopy index d726652..b96b762 100755 --- a/droopy +++ b/droopy @@ -6,6 +6,7 @@ Copyright 2008-2013 (c) Pierre Duquesne Licensed under the New BSD License. Changelog + 20230601 * No default ciphers now use SHA1 20230601 * All default ciphers now use perfect forward secrecy 20230601 * Updates for Python 3.8+ 20200201 * Update default permitted ciphers @@ -430,7 +431,7 @@ def run(hostname='', 'TLS13-AES-128-GCM-SHA256:' 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+CHACHA20:ECDH+AES256:DH+AES256:' 'ECDH+AES128:DH+AES:ECDH+HIGH:DH+HIGH:' - '!aNULL:!eNULL:!MD5:!DSS:!RC4:!3DES'), + '!aNULL:!eNULL:!MD5:!SHA1:!DSS:!RC4:!3DES'), keyfile=None, hsts=None): """ @@ -438,8 +439,8 @@ def run(hostname='', keyfile should be a PEM private key, otherwise it's assumed to be in the certfile. permitted_ciphers, if a TLS cert is provided, is an OpenSSL cipher string. - The default is Python's default when using v3.7+, otherwise it's Python 3.6.12's - default (below); in either case ciphers missing perfect forward secrecy are excluded. + The default is Python's default when using v3.7+, otherwise it's Python 3.6.12's default + (below); in either case ciphers with SHA1 or missing perfect forward secrecy are excluded. https://github.com/python/cpython/blob/v3.6.12/Lib/ssl.py#L212-L218 """ if templates is None or localisations is None: @@ -470,8 +471,10 @@ def run(hostname='', else: # Remove ciphers which don't support perfect forward secrecy (those whose only # key exchange algorithm is RSA). Note that this requires an initially sane list, - # such as that created by Python 3.7+'s ssl.create_default_context() - ctx.set_ciphers(':'.join(c['name'] for c in ctx.get_ciphers() if c['kea'] != 'kx-rsa')) + # such as that created by Python 3.7+'s ssl.create_default_context(). + # Also remove ciphers which use SHA1. + ctx.set_ciphers(':'.join(c['name'] for c in ctx.get_ciphers() + if c['kea'] != 'kx-rsa' and c['digest'] != 'sha1')) httpd.socket = ctx.wrap_socket( httpd.socket, server_side=True) From 4aec4b2e8464c3466cc4d85a1ecf0fc248dc2cce Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Thu, 1 Jun 2023 18:04:25 -0400 Subject: [PATCH 9/9] Add support for Diffie-Hellman PFS --- droopy | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/droopy b/droopy index b96b762..bd9bc8e 100755 --- a/droopy +++ b/droopy @@ -6,6 +6,7 @@ Copyright 2008-2013 (c) Pierre Duquesne Licensed under the New BSD License. Changelog + 20230601 * Add support for Diffie-Hellman PFS (ECDH is already supported) 20230601 * No default ciphers now use SHA1 20230601 * All default ciphers now use perfect forward secrecy 20230601 * Updates for Python 3.8+ @@ -433,6 +434,7 @@ def run(hostname='', 'ECDH+AES128:DH+AES:ECDH+HIGH:DH+HIGH:' '!aNULL:!eNULL:!MD5:!SHA1:!DSS:!RC4:!3DES'), keyfile=None, + dhparamsfile=None, hsts=None): """ certfile should be the path of a PEM TLS certificate. @@ -442,6 +444,9 @@ def run(hostname='', The default is Python's default when using v3.7+, otherwise it's Python 3.6.12's default (below); in either case ciphers with SHA1 or missing perfect forward secrecy are excluded. https://github.com/python/cpython/blob/v3.6.12/Lib/ssl.py#L212-L218 + + dhparamsfile should contain Diffie-Hellman parameters in .pem format. + It is required for (non-EC) EDH support, see dhparam(1SSL). """ if templates is None or localisations is None: raise ValueError("Must provide templates *and* localisations.") @@ -475,11 +480,15 @@ def run(hostname='', # Also remove ciphers which use SHA1. ctx.set_ciphers(':'.join(c['name'] for c in ctx.get_ciphers() if c['kea'] != 'kx-rsa' and c['digest'] != 'sha1')) + if dhparamsfile: + ctx.load_dh_params(dhparamsfile) httpd.socket = ctx.wrap_socket( httpd.socket, server_side=True) except AttributeError: print("Warning: Use Python 2.7.9+ or 3.4+ for improved SSL security.") + if dhparamsfile: + print(" Non-EC Diffie-Hellman PFS is not supported.") httpd.socket = ssl.wrap_socket( httpd.socket, certfile=certfile, @@ -1052,6 +1061,8 @@ def parse_args(cmd=None, ignore_defaults=False): help='set up https using the certificate file') parser.add_argument('--sslkey', type=str, default='', help='load ssl key from this separate file') + parser.add_argument('--dhparams', type=str, default='', + help='load Diffie-Hellman parameters from this .pem file') parser.add_argument('--hsts', type=int, nargs='?', metavar='SECONDS', const=15811200, # 6 months help='add HTTP Strict Transport Security header to responses') parser.add_argument('--chmod', type=str, default=None, @@ -1141,6 +1152,7 @@ def main(): run(port=args['port'], certfile=args['ssl'], keyfile=args['sslkey'], + dhparamsfile=args['dhparams'], hsts=args['hsts'], picture=args['picture'], message=args['message'],