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.
This commit is contained in:
Ben Busby 2022-08-29 13:36:40 -06:00
parent 77f617e984
commit 32ad39d0e1
No known key found for this signature in database
GPG Key ID: B9B7231E01D924A1
5 changed files with 44 additions and 77 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ __pycache__/
*.pyc *.pyc
*.pem *.pem
*.conf *.conf
*.key
config.json config.json
test/static test/static
flask_session/ flask_session/

View File

@ -3,9 +3,9 @@ from app.request import send_tor_signal
from app.utils.session import generate_user_key from app.utils.session import generate_user_key
from app.utils.bangs import gen_bangs_json from app.utils.bangs import gen_bangs_json
from app.utils.misc import gen_file_hash, read_config_bool from app.utils.misc import gen_file_hash, read_config_bool
from base64 import b64encode
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Flask from flask import Flask
from flask_session import Session
import json import json
import logging.config import logging.config
import os import os
@ -20,24 +20,15 @@ app = Flask(__name__, static_folder=os.path.dirname(
app.wsgi_app = ProxyFix(app.wsgi_app) 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 # Load .env file if enabled
if os.getenv('WHOOGLE_DOTENV', ''): if os.getenv('WHOOGLE_DOTENV', ''):
dotenv_path = '../whoogle.env' load_dotenv(dot_env_path)
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)),
dotenv_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.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'): if os.getenv('HTTPS_ONLY'):
app.config['SESSION_COOKIE_NAME'] = '__Secure-session' app.config['SESSION_COOKIE_NAME'] = '__Secure-session'
@ -86,6 +77,36 @@ app.config['BANG_FILE'] = os.path.join(
app.config['BANG_PATH'], app.config['BANG_PATH'],
'bangs.json') '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 # Config fields that are used to check for updates
app.config['RELEASES_URL'] = 'https://github.com/' \ app.config['RELEASES_URL'] = 'https://github.com/' \
'benbusby/whoogle-search/releases' 'benbusby/whoogle-search/releases'
@ -109,15 +130,7 @@ app.config['CSP'] = 'default-src \'none\';' \
'media-src \'self\';' \ 'media-src \'self\';' \
'connect-src \'self\';' 'connect-src \'self\';'
if not os.path.exists(app.config['CONFIG_PATH']): # Generate DDG bang filter
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'])
if not os.path.exists(app.config['BANG_FILE']): if not os.path.exists(app.config['BANG_FILE']):
json.dump({}, open(app.config['BANG_FILE'], 'w')) json.dump({}, open(app.config['BANG_FILE'], 'w'))
bangs_thread = threading.Thread( bangs_thread = threading.Thread(
@ -126,9 +139,6 @@ if not os.path.exists(app.config['BANG_FILE']):
bangs_thread.start() bangs_thread.start()
# Build new mapping of static files for cache busting # 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'] cache_busting_dirs = ['css', 'js']
for cb_dir in cache_busting_dirs: for cb_dir in cache_busting_dirs:
full_cb_dir = os.path.join(app.config['STATIC_FOLDER'], cb_dir) 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( app.jinja_env.globals.update(
cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f]) cb_url=lambda f: app.config['CACHE_BUSTING_MAP'][f])
Session(app)
# Attempt to acquire tor identity, to determine if Tor config is available # Attempt to acquire tor identity, to determine if Tor config is available
send_tor_signal(Signal.HEARTBEAT) send_tor_signal(Signal.HEARTBEAT)

View File

@ -5,7 +5,6 @@ class Endpoint(Enum):
autocomplete = 'autocomplete' autocomplete = 'autocomplete'
home = 'home' home = 'home'
healthz = 'healthz' healthz = 'healthz'
session = 'session'
config = 'config' config = 'config'
opensearch = 'opensearch.xml' opensearch = 'opensearch.xml'
search = 'search' search = 'search'

View File

@ -67,8 +67,7 @@ def auth_required(f):
def session_required(f): def session_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
if (valid_user_session(session) and if (valid_user_session(session)):
'cookies_disabled' not in request.args):
g.session_key = session['key'] g.session_key = session['key']
else: else:
session.pop('_permanent', None) session.pop('_permanent', None)
@ -113,6 +112,7 @@ def session_required(f):
@app.before_request @app.before_request
def before_request_func(): def before_request_func():
global bang_json global bang_json
session.permanent = True
# Check for latest version if needed # Check for latest version if needed
now = datetime.now() now = datetime.now()
@ -126,43 +126,17 @@ def before_request_func():
request.args if request.method == 'GET' else request.form 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'])) \ default_config = json.load(open(app.config['DEFAULT_CONFIG'])) \
if os.path.exists(app.config['DEFAULT_CONFIG']) else {} if os.path.exists(app.config['DEFAULT_CONFIG']) else {}
# Generate session values for user if unavailable # Generate session values for user if unavailable
if (not valid_user_session(session) and if (not valid_user_session(session)):
'cookies_disabled' not in request.args):
session['config'] = default_config session['config'] = default_config
session['uuid'] = str(uuid.uuid4()) session['uuid'] = str(uuid.uuid4())
session['key'] = generate_user_key() session['key'] = generate_user_key()
# Skip checking for session on any searches that don't # Establish config values per user session
# require a valid session g.user_config = Config(**session['config'])
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)
if not g.user_config.url: if not g.user_config.url:
g.user_config.url = get_request_url(request.url_root) g.user_config.url = get_request_url(request.url_root)
@ -209,19 +183,6 @@ def healthz():
return '' return ''
@app.route(f'/{Endpoint.session}/<session_id>', 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('/', methods=['GET'])
@app.route(f'/{Endpoint.home}', methods=['GET']) @app.route(f'/{Endpoint.home}', methods=['GET'])
@auth_required @auth_required
@ -246,8 +207,7 @@ def index():
dark=g.user_config.dark), dark=g.user_config.dark),
config_disabled=( config_disabled=(
app.config['CONFIG_DISABLE'] or app.config['CONFIG_DISABLE'] or
not valid_user_session(session) or not valid_user_session(session)),
'cookies_disabled' in request.args),
config=g.user_config, config=g.user_config,
tor_available=int(os.environ.get('TOR_AVAILABLE')), tor_available=int(os.environ.get('TOR_AVAILABLE')),
version_number=app.config['VERSION_NUMBER']) version_number=app.config['VERSION_NUMBER'])

View File

@ -9,7 +9,6 @@ cryptography==3.3.2
cssutils==2.4.0 cssutils==2.4.0
defusedxml==0.7.1 defusedxml==0.7.1
Flask==1.1.1 Flask==1.1.1
Flask-Session==0.4.0
idna==2.9 idna==2.9
itsdangerous==1.1.0 itsdangerous==1.1.0
Jinja2==2.11.3 Jinja2==2.11.3