diff --git a/server.py b/server.py index 814b7b4..ad59a12 100644 --- a/server.py +++ b/server.py @@ -6,6 +6,8 @@ from flask import Flask, render_template, request, jsonify from flask_recaptcha import ReCaptcha +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from sendgrid import SendGridAPIClient from sendgrid.helpers.mail import (Mail, Attachment, FileContent, FileName, FileType, Disposition) @@ -14,93 +16,140 @@ load_dotenv() -if not set(['RECAPTCHASITEKEY', 'RECAPTCHASECRETKEY', 'SENDGRIDAPIKEY', 'SENDGRIDFROMEMAIL']).issubset(os.environ): - print("Failed to start. Please set the environment variables RECAPTCHASITEKEY, RECAPTCHASECRETKEY, SENDGRIDAPIKEY, and SENDGRIDFROMEMAIL") - exit(1) - -RECAPTCHASITEKEY = os.environ['RECAPTCHASITEKEY'] -RECAPTCHASECRETKEY = os.environ['RECAPTCHASECRETKEY'] -SENDGRIDAPIKEY = os.environ['SENDGRIDAPIKEY'] -FROMEMAIL = os.environ['SENDGRIDFROMEMAIL'] - -# this needs to be reflected in the `templates/index.html` file -NUMBER_OF_ATTACHMENTS = int(os.environ.get('NUMBEROFATTACHMENTS', '10')) -DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true' - -app = Flask(__name__) -app.config['RECAPTCHA_SITE_KEY'] = RECAPTCHASITEKEY -app.config['RECAPTCHA_SECRET_KEY'] = RECAPTCHASECRETKEY -app.config['MAX_CONTENT_LENGTH'] = 15 * 1024 * 1024 # 15 Mb limit -recaptcha = ReCaptcha(app) - -log_file = os.environ.get('LOG_FILE', '') -if log_file: - logging.basicConfig(filename=log_file, level=logging.INFO) -else: - logging.basicConfig(level=logging.INFO) +class Config: + MAX_CONTENT_LENGTH = 15 * 1024 * 1024 # 15 MB + EMAIL_DOMAIN = "@ethereum.org" + DEFAULT_RECIPIENT_EMAIL = "kyc@ethereum.org" + NUMBER_OF_ATTACHMENTS = int(os.getenv('NUMBEROFATTACHMENTS', 10)) + DEBUG_MODE = os.getenv('DEBUG', 'False').lower() == 'true' + SECRET_KEY = os.getenv('SECRET_KEY', 'you-should-set-a-secret-key') + +def validate_env_vars(required_vars): + """ + Validates that all required environment variables are set. + """ + missing_vars = [var for var in required_vars if var not in os.environ] + if missing_vars: + raise EnvironmentError(f"Missing required environment variables: {', '.join(missing_vars)}") + +def sanitize_filename(filename): + """ + Sanitizes the filename to prevent directory traversal and other issues. + """ + return filename.replace("..", "").replace("/", "").replace("\\", "") def parse_form(form): + """ + Parses the form data to extract the message, recipient, and attachments. + """ text = form['message'] recipient = form['recipient'] all_attachments = [] - for i in range(NUMBER_OF_ATTACHMENTS): - attachment = form['attachment-%s' % i] - filename = form['filename-%s' % i].encode('ascii', 'ignore').decode() # remove non-ascii characters + for i in range(Config.NUMBER_OF_ATTACHMENTS): + attachment = form.get(f'attachment-{i}') + filename = form.get(f'filename-{i}', '').encode('ascii', 'ignore').decode() # remove non-ascii characters if not attachment: continue - all_attachments.append((filename, attachment)) + sanitized_filename = sanitize_filename(filename) + all_attachments.append((sanitized_filename, attachment)) return text, recipient, all_attachments def valid_recipient(recipient): - if recipient in ['legal', 'devcon', 'esp', 'security', 'oleh']: - return True - return False + """ + Checks if the recipient is valid. + """ + valid_recipients = ['legal', 'devcon', 'esp', 'security', 'oleh'] + return recipient in valid_recipients def get_identifier(recipient, now=None, randint=None): + """ + Generates a unique identifier based on the recipient, current timestamp, and a random number. + """ if now is None: now = datetime.now() if randint is None: randint = Random().randint(1000, 9999) - return '%s:%s:%s' % (recipient, now.strftime('%Y:%m:%d:%H:%M:%S'), randint) + return f'{recipient}:{now.strftime("%Y:%m:%d:%H:%M:%S")}:{randint}' -def create_email(toEmail, identifier, text, all_attachments): +def create_email(to_email, identifier, text, all_attachments): + """ + Creates an email message with attachments. + """ plain_text = text.replace('
', '\n') message = Mail( - from_email=FROMEMAIL, - to_emails=toEmail, - subject='Secure Form Submission %s' % identifier, - plain_text_content=plain_text) - - for item in all_attachments: - filename = item['filename'] - attachment = item['attachment'] + from_email=FROMEMAIL, + to_emails=to_email, + subject=f'Secure Form Submission {identifier}', + plain_text_content=plain_text + ) + for filename, attachment in all_attachments: encoded_file = base64.b64encode(attachment.encode("utf-8")).decode() - attachedFile = Attachment( + attached_file = Attachment( FileContent(encoded_file), FileName(filename + '.pgp'), FileType('application/pgp-encrypted'), Disposition('attachment') ) - message.add_attachment(attachedFile) + message.add_attachment(attached_file) return message +def validate_recaptcha(recaptcha_response): + """ + Validates the ReCaptcha response. + """ + if not recaptcha.verify(response=recaptcha_response): + raise ValueError('Error: ReCaptcha verification failed!') + +def send_email(message): + """ + Sends the email using SendGrid. + """ + sg = SendGridAPIClient(SENDGRIDAPIKEY) + response = sg.send(message) + if response.status_code not in [200, 201, 202]: + raise ValueError(f"Error: Failed to send email. Status code: {response.status_code}") + +# Validate required environment variables +required_env_vars = ['RECAPTCHASITEKEY', 'RECAPTCHASECRETKEY', 'SENDGRIDAPIKEY', 'SENDGRIDFROMEMAIL'] +validate_env_vars(required_env_vars) + +RECAPTCHASITEKEY = os.environ['RECAPTCHASITEKEY'] +RECAPTCHASECRETKEY = os.environ['RECAPTCHASECRETKEY'] +SENDGRIDAPIKEY = os.environ['SENDGRIDAPIKEY'] +FROMEMAIL = os.environ['SENDGRIDFROMEMAIL'] + +app = Flask(__name__) +app.config.from_object(Config) +recaptcha = ReCaptcha(app) + +# Initialize rate limiting +limiter = Limiter(get_remote_address, app=app, default_limits=["200 per day", "50 per hour"]) + +# Configure logging +log_file = os.environ.get('LOG_FILE', '') + +if log_file: + logging.basicConfig(filename=log_file, level=logging.INFO) +else: + logging.basicConfig(level=logging.INFO) + @app.route('/', methods=['GET']) def index(): - return render_template('index.html', notice='', hascaptcha=not DEBUG, attachments_number=NUMBER_OF_ATTACHMENTS, recaptcha_sitekey=RECAPTCHASITEKEY) + return render_template('index.html', notice='', hascaptcha=not Config.DEBUG_MODE, attachments_number=Config.NUMBER_OF_ATTACHMENTS, recaptcha_sitekey=RECAPTCHASITEKEY) @app.route('/submit-encrypted-data', methods=['POST']) +@limiter.limit("5 per minute") def submit(): try: # Parse JSON data from request data = request.get_json() - # Won't even look on Captcha for debug mode - if not DEBUG: - if not recaptcha.verify(response=data['g-recaptcha-response']): - raise ValueError('Error: ReCaptcha verification failed! You would need to re-submit the request.') - + # Validate ReCaptcha unless in debug mode + if not Config.DEBUG_MODE: + validate_recaptcha(data['g-recaptcha-response']) + # Extract fields from JSON data message = data['message'] recipient = data['recipient'] @@ -111,44 +160,37 @@ def submit(): if not valid_recipient(recipient): raise ValueError('Error: Invalid recipient!') - + # Get submission statistics date = datetime.now().strftime('%Y-%m-%d %H:%M:%S') message_length = len(message) file_count = len(files) - - toEmail = "kyc@ethereum.org" if recipient == 'legal' else recipient + "@ethereum.org" + + to_email = Config.DEFAULT_RECIPIENT_EMAIL if recipient == 'legal' else recipient + Config.EMAIL_DOMAIN identifier = get_identifier(recipient) - # toEmail = "oleh@ethereum.org" log_data = f"{date} - message to: {recipient}, identifier: {identifier}, length: {message_length}, file count: {file_count}" logging.info(log_data) - message = create_email(toEmail, identifier, message, files) + message = create_email(to_email, identifier, message, files) - if DEBUG: - print("Attempt to send email to %s" % toEmail) + if Config.DEBUG_MODE: + print(f"Attempt to send email to {to_email}") print(message.get()) else: - sg = SendGridAPIClient(SENDGRIDAPIKEY) - response = sg.send(message) - if not response.status_code in [200, 201, 202]: - logging.error("Failed to send email: %s" % response.body) - logging.error("Headers: %s" % response.headers) - raise ValueError('Error: Failed to send email. Please try again later. Code: %s' % response.status_code) - - notice = 'Thank you! The relevant team was notified of your submission. You could use a following identifier to refer to it in correspondence: ' + identifier + '' - + send_email(message) + + notice = f'Thank you! The relevant team was notified of your submission. You could use the following identifier to refer to it in correspondence: {identifier}' + # Return success response return jsonify({'status': 'success', 'message': notice}) - + except Exception as e: # Log error message and return failure response - error_message = str(e) - print(error_message) + error_message = "An unexpected error occurred. Please try again later." + logging.error(f"Internal error: {str(e)}") return jsonify({'status': 'failure', 'message': error_message}) - @app.errorhandler(413) def error413(e): return render_template('413.html'), 413