From 32ad39d0e1529818289444d0352e1bf16beef400 Mon Sep 17 00:00:00 2001 From: Ben Busby Date: Mon, 29 Aug 2022 13:36:40 -0600 Subject: [PATCH] Refactor session behavior, remove `Flask-Session` dep Sessions are no longer validated using the "/session/..." route. This created a lot of problems due to buggy/unexpected behavior coming from the Flask-Session dependency, which is (more or less) no longer maintained. Sessions are also no longer strictly server-side-only. The majority of information that was being stored in user sessions was aesthetic only, aside from the session specific key used to encrypt URLs. This key is still unique per user, but is not (or shouldn't be) in anyone's threat model to keep absolutely 100% private from everyone. Especially paranoid users of Whoogle can easily modify the code to use a randomly generated encryption key that is reset on session invalidation (and set invalidation time to a short enough period for their liking). Ultimately, this should result in much more stable sessions per client. There shouldn't be decryption issues with element URLs or queries during result page navigation. --- .gitignore | 1 + app/__init__.py | 66 +++++++++++++++++++++++------------------- app/models/endpoint.py | 1 - app/routes.py | 52 ++++----------------------------- requirements.txt | 1 - 5 files changed, 44 insertions(+), 77 deletions(-) diff --git a/.gitignore b/.gitignore index 3738d16..02b4e03 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ *.pyc *.pem *.conf +*.key config.json test/static flask_session/ diff --git a/app/__init__.py b/app/__init__.py index fefa1d9..cdcf95e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,9 +3,9 @@ from app.request import send_tor_signal from app.utils.session import generate_user_key from app.utils.bangs import gen_bangs_json from app.utils.misc import gen_file_hash, read_config_bool +from base64 import b64encode from datetime import datetime, timedelta from flask import Flask -from flask_session import Session import json import logging.config import os @@ -20,24 +20,15 @@ app = Flask(__name__, static_folder=os.path.dirname( app.wsgi_app = ProxyFix(app.wsgi_app) +dot_env_path = ( + os.path.join(os.path.dirname(os.path.abspath(__file__)), + '../whoogle.env')) + # Load .env file if enabled if os.getenv('WHOOGLE_DOTENV', ''): - dotenv_path = '../whoogle.env' - load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), - dotenv_path)) + load_dotenv(dot_env_path) -# Session values -# NOTE: SESSION_COOKIE_SAMESITE must be set to 'lax' to allow the user's -# previous session to persist when accessing the instance from an external -# link. Setting this value to 'strict' causes Whoogle to revalidate a new -# session, and fail, resulting in cookies being disabled. -# -# This could be re-evaluated if Whoogle ever switches to client side -# configuration instead. app.default_key = generate_user_key() -app.config['SECRET_KEY'] = os.urandom(32) -app.config['SESSION_TYPE'] = 'filesystem' -app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' if os.getenv('HTTPS_ONLY'): app.config['SESSION_COOKIE_NAME'] = '__Secure-session' @@ -86,6 +77,36 @@ app.config['BANG_FILE'] = os.path.join( app.config['BANG_PATH'], 'bangs.json') +# Ensure all necessary directories exist +if not os.path.exists(app.config['CONFIG_PATH']): + os.makedirs(app.config['CONFIG_PATH']) + +if not os.path.exists(app.config['SESSION_FILE_DIR']): + os.makedirs(app.config['SESSION_FILE_DIR']) + +if not os.path.exists(app.config['BANG_PATH']): + os.makedirs(app.config['BANG_PATH']) + +if not os.path.exists(app.config['BUILD_FOLDER']): + os.makedirs(app.config['BUILD_FOLDER']) + +# Session values +app_key_path = os.path.join(app.config['CONFIG_PATH'], 'whoogle.key') +if os.path.exists(app_key_path): + app.config['SECRET_KEY'] = open(app_key_path, 'r').read() +else: + app.config['SECRET_KEY'] = str(b64encode(os.urandom(32))) + with open(app_key_path, 'w') as key_file: + key_file.write(app.config['SECRET_KEY']) + key_file.close() +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365) + +# NOTE: SESSION_COOKIE_SAMESITE must be set to 'lax' to allow the user's +# previous session to persist when accessing the instance from an external +# link. Setting this value to 'strict' causes Whoogle to revalidate a new +# session, and fail, resulting in cookies being disabled. +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' + # Config fields that are used to check for updates app.config['RELEASES_URL'] = 'https://github.com/' \ 'benbusby/whoogle-search/releases' @@ -109,15 +130,7 @@ app.config['CSP'] = 'default-src \'none\';' \ 'media-src \'self\';' \ 'connect-src \'self\';' -if not os.path.exists(app.config['CONFIG_PATH']): - os.makedirs(app.config['CONFIG_PATH']) - -if not os.path.exists(app.config['SESSION_FILE_DIR']): - os.makedirs(app.config['SESSION_FILE_DIR']) - -# Generate DDG bang filter, and create path if it doesn't exist yet -if not os.path.exists(app.config['BANG_PATH']): - os.makedirs(app.config['BANG_PATH']) +# Generate DDG bang filter if not os.path.exists(app.config['BANG_FILE']): json.dump({}, open(app.config['BANG_FILE'], 'w')) bangs_thread = threading.Thread( @@ -126,9 +139,6 @@ if not os.path.exists(app.config['BANG_FILE']): bangs_thread.start() # Build new mapping of static files for cache busting -if not os.path.exists(app.config['BUILD_FOLDER']): - os.makedirs(app.config['BUILD_FOLDER']) - cache_busting_dirs = ['css', 'js'] for cb_dir in cache_busting_dirs: full_cb_dir = os.path.join(app.config['STATIC_FOLDER'], cb_dir) @@ -155,8 +165,6 @@ app.jinja_env.globals.update(clean_query=clean_query) app.jinja_env.globals.update( cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f]) -Session(app) - # Attempt to acquire tor identity, to determine if Tor config is available send_tor_signal(Signal.HEARTBEAT) diff --git a/app/models/endpoint.py b/app/models/endpoint.py index eeddc64..83bea79 100644 --- a/app/models/endpoint.py +++ b/app/models/endpoint.py @@ -5,7 +5,6 @@ class Endpoint(Enum): autocomplete = 'autocomplete' home = 'home' healthz = 'healthz' - session = 'session' config = 'config' opensearch = 'opensearch.xml' search = 'search' diff --git a/app/routes.py b/app/routes.py index 5ce1101..f144553 100644 --- a/app/routes.py +++ b/app/routes.py @@ -67,8 +67,7 @@ def auth_required(f): def session_required(f): @wraps(f) def decorated(*args, **kwargs): - if (valid_user_session(session) and - 'cookies_disabled' not in request.args): + if (valid_user_session(session)): g.session_key = session['key'] else: session.pop('_permanent', None) @@ -113,6 +112,7 @@ def session_required(f): @app.before_request def before_request_func(): global bang_json + session.permanent = True # Check for latest version if needed now = datetime.now() @@ -126,43 +126,17 @@ def before_request_func(): request.args if request.method == 'GET' else request.form ) - # Skip pre-request actions if verifying session - if '/session' in request.path and not valid_user_session(session): - return - default_config = json.load(open(app.config['DEFAULT_CONFIG'])) \ if os.path.exists(app.config['DEFAULT_CONFIG']) else {} # Generate session values for user if unavailable - if (not valid_user_session(session) and - 'cookies_disabled' not in request.args): + if (not valid_user_session(session)): session['config'] = default_config session['uuid'] = str(uuid.uuid4()) session['key'] = generate_user_key() - # Skip checking for session on any searches that don't - # require a valid session - if (not Endpoint.autocomplete.in_path(request.path) and - not Endpoint.healthz.in_path(request.path) and - not Endpoint.opensearch.in_path(request.path)): - # reconstruct url if X-Forwarded-Host header present - request_url = get_proxy_host_url(request, - get_request_url(request.url)) - return redirect(url_for( - 'session_check', - session_id=session['uuid'], - follow=request_url), code=307) - else: - g.user_config = Config(**session['config']) - elif 'cookies_disabled' not in request.args: - # Set session as permanent - session.permanent = True - app.permanent_session_lifetime = timedelta(days=365) - g.user_config = Config(**session['config']) - else: - # User has cookies disabled, fall back to immutable default config - session.pop('_permanent', None) - g.user_config = Config(**default_config) + # Establish config values per user session + g.user_config = Config(**session['config']) if not g.user_config.url: g.user_config.url = get_request_url(request.url_root) @@ -209,19 +183,6 @@ def healthz(): return '' -@app.route(f'/{Endpoint.session}/', methods=['GET', 'PUT', 'POST']) -def session_check(session_id): - if 'uuid' in session and session['uuid'] == session_id: - session['valid'] = True - return redirect(request.args.get('follow'), code=307) - else: - follow_url = request.args.get('follow') - req = PreparedRequest() - req.prepare_url(follow_url, {'cookies_disabled': 1}) - session.pop('_permanent', None) - return redirect(req.url, code=307) - - @app.route('/', methods=['GET']) @app.route(f'/{Endpoint.home}', methods=['GET']) @auth_required @@ -246,8 +207,7 @@ def index(): dark=g.user_config.dark), config_disabled=( app.config['CONFIG_DISABLE'] or - not valid_user_session(session) or - 'cookies_disabled' in request.args), + not valid_user_session(session)), config=g.user_config, tor_available=int(os.environ.get('TOR_AVAILABLE')), version_number=app.config['VERSION_NUMBER']) diff --git a/requirements.txt b/requirements.txt index 03b1ebb..24e7115 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ cryptography==3.3.2 cssutils==2.4.0 defusedxml==0.7.1 Flask==1.1.1 -Flask-Session==0.4.0 idna==2.9 itsdangerous==1.1.0 Jinja2==2.11.3