diff --git a/app/__init__.py b/app/__init__.py index ecd1edd..c6b8a42 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,28 +1,28 @@ from app.utils.misc import generate_user_keys -from cryptography.fernet import Fernet from flask import Flask from flask_session import Session import os app = Flask(__name__, static_folder=os.path.dirname(os.path.abspath(__file__)) + '/static') app.user_elements = {} -app.config['SECRET_KEY'] = os.urandom(16) +app.default_key_set = generate_user_keys() +app.no_cookie_ips = [] +app.config['SECRET_KEY'] = os.urandom(32) app.config['SESSION_TYPE'] = 'filesystem' app.config['VERSION_NUMBER'] = '0.2.0' app.config['APP_ROOT'] = os.getenv('APP_ROOT', os.path.dirname(os.path.abspath(__file__))) app.config['STATIC_FOLDER'] = os.getenv('STATIC_FOLDER', os.path.join(app.config['APP_ROOT'], 'static')) -app.config['CONFIG_PATH'] = os.getenv('CONFIG_VOLUME', app.config['STATIC_FOLDER'] + '/config') -app.config['USER_CONFIG'] = os.path.join(app.config['STATIC_FOLDER'], 'custom_config') -app.config['SESSION_FILE_DIR'] = app.config['CONFIG_PATH'] -app.config['SESSION_COOKIE_SECURE'] = True +app.config['CONFIG_PATH'] = os.getenv('CONFIG_VOLUME', os.path.join(app.config['STATIC_FOLDER'], 'config')) +app.config['DEFAULT_CONFIG'] = os.path.join(app.config['CONFIG_PATH'], 'config.json') +app.config['SESSION_FILE_DIR'] = os.path.join(app.config['CONFIG_PATH'], 'session') +app.config['SESSION_COOKIE_SAMESITE'] = 'Strict' if not os.path.exists(app.config['CONFIG_PATH']): os.makedirs(app.config['CONFIG_PATH']) -if not os.path.exists(app.config['USER_CONFIG']): - os.makedirs(app.config['USER_CONFIG']) +if not os.path.exists(app.config['SESSION_FILE_DIR']): + os.makedirs(app.config['SESSION_FILE_DIR']) -sess = Session() -sess.init_app(app) +Session(app) from app import routes diff --git a/app/routes.py b/app/routes.py index b473073..99e05a6 100644 --- a/app/routes.py +++ b/app/routes.py @@ -37,11 +37,19 @@ def auth_required(f): @app.before_request def before_request_func(): - # Generate secret key for user if unavailable + g.request_params = request.args if request.method == 'GET' else request.form + g.cookies_disabled = False + + # Generate session values for user if unavailable if not valid_user_session(session): - session['config'] = {'url': request.url_root} - session['keys'] = generate_user_keys() + session['config'] = json.load(open(app.config['DEFAULT_CONFIG'])) \ + if os.path.exists(app.config['DEFAULT_CONFIG']) else {'url': request.url_root} session['uuid'] = str(uuid.uuid4()) + session['fernet_keys'] = generate_user_keys(True) + + # Flag cookies as possibly disabled in order to prevent against + # unnecessary session directory expansion + g.cookies_disabled = True if session['uuid'] not in app.user_elements: app.user_elements.update({session['uuid']: 0}) @@ -63,11 +71,22 @@ def before_request_func(): @app.after_request def after_request_func(response): - # Regenerate element key if all elements have been served to user if app.user_elements[session['uuid']] <= 0 and '/element' in request.url: - session['keys']['element_key'] = Fernet.generate_key() + # Regenerate element key if all elements have been served to user + session['fernet_keys']['element_key'] = '' if not g.cookies_disabled else app.default_key_set['element_key'] app.user_elements[session['uuid']] = 0 + # Check if address consistently has cookies blocked, in which case start removing session + # files after creation. + # Note: This is primarily done to prevent overpopulation of session directories, since browsers that + # block cookies will still trigger Flask's session creation routine with every request. + if g.cookies_disabled and request.remote_addr not in app.no_cookie_ips: + app.no_cookie_ips.append(request.remote_addr) + elif g.cookies_disabled and request.remote_addr in app.no_cookie_ips: + session_list = list(session.keys()) + for key in session_list: + session.pop(key) + return response @@ -79,6 +98,9 @@ def unknown_page(e): @app.route('/', methods=['GET']) @auth_required def index(): + # Reset keys + session['fernet_keys'] = generate_user_keys(g.cookies_disabled) + return render_template('index.html', languages=Config.LANGUAGES, countries=Config.COUNTRIES, @@ -103,8 +125,7 @@ def opensearch(): @app.route('/autocomplete', methods=['GET', 'POST']) def autocomplete(): - request_params = request.args if request.method == 'GET' else request.form - q = request_params.get('q') + q = g.request_params.get('q') if not q and not request.data: return jsonify({'?': []}) @@ -117,11 +138,10 @@ def autocomplete(): @app.route('/search', methods=['GET', 'POST']) @auth_required def search(): - # Clear previous elements and generate a new key each time a new search is performed + # Reset element counter app.user_elements[session['uuid']] = 0 - session['keys']['element_key'] = Fernet.generate_key() - search_util = RoutingUtils(request, g.user_config, session) + search_util = RoutingUtils(request, g.user_config, session, cookies_disabled=g.cookies_disabled) query = search_util.new_search_query() # Redirect to home if invalid/blank search @@ -157,7 +177,7 @@ def config(): return json.dumps(g.user_config.__dict__) elif request.method == 'PUT': if 'name' in request.args: - config_pkl = os.path.join(app.config['USER_CONFIG'], request.args.get('name')) + config_pkl = os.path.join(app.config['CONFIG_PATH'], request.args.get('name')) session['config'] = pickle.load(open(config_pkl, 'rb')) if os.path.exists(config_pkl) else session['config'] return json.dumps(session['config']) else: @@ -167,8 +187,13 @@ def config(): if 'url' not in config_data or not config_data['url']: config_data['url'] = g.user_config.url + # Save config by name to allow a user to easily load later if 'name' in request.args: - pickle.dump(config_data, open(os.path.join(app.config['USER_CONFIG'], request.args.get('name')), 'wb')) + pickle.dump(config_data, open(os.path.join(app.config['CONFIG_PATH'], request.args.get('name')), 'wb')) + + # Overwrite default config if user has cookies disabled + if g.cookies_disabled: + open(app.config['DEFAULT_CONFIG'], 'w').write(json.dumps(config_data, indent=4)) session['config'] = config_data return redirect(config_data['url']) @@ -196,7 +221,7 @@ def imgres(): @app.route('/element') @auth_required def element(): - cipher_suite = Fernet(session['keys']['element_key']) + cipher_suite = Fernet(session['fernet_keys']['element_key']) src_url = cipher_suite.decrypt(request.args.get('url').encode()).decode() src_type = request.args.get('type') diff --git a/app/static/js/autocomplete.js b/app/static/js/autocomplete.js index 316f8c4..84e9b23 100644 --- a/app/static/js/autocomplete.js +++ b/app/static/js/autocomplete.js @@ -4,7 +4,7 @@ const handleUserInput = searchBar => { xhrRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); xhrRequest.onload = function() { if (xhrRequest.readyState === 4 && xhrRequest.status !== 200) { - alert("Error fetching autocomplete results"); + // Do nothing if failed to fetch autocomplete results return; } diff --git a/app/utils/misc.py b/app/utils/misc.py index a70a82a..9bd580d 100644 --- a/app/utils/misc.py +++ b/app/utils/misc.py @@ -1,9 +1,13 @@ from cryptography.fernet import Fernet +from flask import current_app as app -SESSION_VALS = ['uuid', 'config', 'keys'] +SESSION_VALS = ['uuid', 'config', 'fernet_keys'] -def generate_user_keys(): +def generate_user_keys(cookies_disabled=False) -> dict: + if cookies_disabled: + return app.default_key_set + # Generate/regenerate unique key per user return { 'element_key': Fernet.generate_key(), diff --git a/app/utils/routing_utils.py b/app/utils/routing_utils.py index cc3ed1f..cfe0b64 100644 --- a/app/utils/routing_utils.py +++ b/app/utils/routing_utils.py @@ -1,5 +1,5 @@ -from app import app from app.filter import Filter, get_first_link +from app.utils.misc import generate_user_keys from app.request import gen_query from bs4 import BeautifulSoup from cryptography.fernet import Fernet, InvalidToken @@ -8,13 +8,14 @@ from typing import Any, Tuple class RoutingUtils: - def __init__(self, request, config, session): + def __init__(self, request, config, session, cookies_disabled=False): self.request_params = request.args if request.method == 'GET' else request.form self.user_agent = request.headers.get('User-Agent') self.feeling_lucky = False self.config = config self.session = session self.query = '' + self.cookies_disabled = cookies_disabled self.search_type = self.request_params.get('tbm') if 'tbm' in self.request_params else '' def __getitem__(self, name): @@ -30,8 +31,8 @@ class RoutingUtils: return hasattr(self, name) def new_search_query(self) -> str: - app.user_elements[self.session['uuid']] = 0 - self.session['keys']['element_key'] = Fernet.generate_key() + # Generate a new element key each time a new search is performed + self.session['fernet_keys']['element_key'] = generate_user_keys(cookies_disabled=self.cookies_disabled)['element_key'] q = self.request_params.get('q') @@ -40,12 +41,12 @@ class RoutingUtils: else: # Attempt to decrypt if this is an internal link try: - q = Fernet(self.session['keys']['text_key']).decrypt(q.encode()).decode() + q = Fernet(self.session['fernet_keys']['text_key']).decrypt(q.encode()).decode() except InvalidToken: pass # Reset text key - self.session['keys']['text_key'] = Fernet.generate_key() + self.session['fernet_keys']['text_key'] = generate_user_keys(cookies_disabled=self.cookies_disabled)['text_key'] # Format depending on whether or not the query is a "feeling lucky" query self.feeling_lucky = q.startswith('! ') @@ -55,7 +56,7 @@ class RoutingUtils: def generate_response(self) -> Tuple[Any, int]: mobile = 'Android' in self.user_agent or 'iPhone' in self.user_agent - content_filter = Filter(self.session['keys'], mobile=mobile, config=self.config) + content_filter = Filter(self.session['fernet_keys'], mobile=mobile, config=self.config) full_query = gen_query(self.query, self.request_params, self.config, content_filter.near) get_body = g.user_request.send(query=full_query).text diff --git a/test/test_misc.py b/test/test_misc.py index 296d03a..96ef373 100644 --- a/test/test_misc.py +++ b/test/test_misc.py @@ -13,7 +13,7 @@ def test_valid_session(client): assert not valid_user_session(session) session['uuid'] = 'test' - session['keys'] = generate_user_keys() + session['fernet_keys'] = generate_user_keys() session['config'] = {} assert valid_user_session(session) @@ -26,11 +26,11 @@ def test_request_key_generation(client): with client.session_transaction() as session: assert valid_user_session(session) - text_key = session['keys']['text_key'] + text_key = session['fernet_keys']['text_key'] rv = client.get('/search?q=test+2') assert rv._status_code == 200 with client.session_transaction() as session: assert valid_user_session(session) - assert text_key not in session['keys']['text_key'] + assert text_key not in session['fernet_keys']['text_key']