From 69d1383c50976ab338fa1f59dba5dbf0639bcac8 Mon Sep 17 00:00:00 2001 From: Michiel Scholten Date: Tue, 18 Mar 2025 13:52:10 +0100 Subject: [PATCH] Initial commit --- README.md | 23 +++++++++ pyproject.toml | 72 +++++++++++++++++++++++++++ requirements-dev.in | 2 + requirements.in | 2 + staticshield.py | 116 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.in create mode 100644 requirements.in create mode 100644 staticshield.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..310957a --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +staticshield session proxy +========================== + + +## Running it locally + +Create a file (e.g., called `run.sh`) with the following: + +```bash +#!/bin/bash +export FLASK_SERVE_DIR="/home/YOURUSER/workspace/somesite/build/html" +export FLASK_MOTHERSHIP="http://localhost:8888/api/staticshield" +export FLASK_SESSION_COOKIE_NAME="staticshield" + +flask --app staticshield run +``` + + +## ruff check and fix + +```bash +ruff check --fix --select I . +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c5a4b13 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[tool.pytest.ini_options] +markers = [ + "network: mark a test that needs internet access", +] + +[tool.coverage.run] +branch = true + +[tool.coverage.report] +precision = 2 + +[tool.ruff] +exclude = [ + ".git", + "__pycache__", + "flask_session", + "build", + "dist" +] +line-length = 120 + +[tool.ruff.format] +# Use single quotes for non-triple-quoted strings. +quote-style = "single" + +[tool.ruff.lint] +ignore = [ + "D203", "D213", "E501", + # Docstring section rules we do not want to use: + "D407", "D413", "D405", "D406", + # more structured docstring formatting comment out to see what we can do better: + "D400", "D401", "D403", "D415", "D200", "D202", "D205", "D209", "D210", "D212", + "D100", "D101", "D102", "D103", "D104", "D105", "D106", +] +select = [ + "C9", + "D", + "E", + "F", + "W", + "T10", # find breakpoints in the code + "ARG", # unused arguments + "TID", # relative imports, banned imports + "PT", # pytest style + "I", # isort + "G", # logging format +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pycodestyle] +max-doc-length = 180 +ignore-overlong-task-comments = true + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = true +mark-parentheses = true + +[tool.ruff.lint.flake8-unused-arguments] +ignore-variadic-names = true + +[tool.ruff.lint.isort] +section-order = ["future", "standard-library","third-party", "first-party", "testing", "local-folder"] +[tool.ruff.lint.isort.sections] +testing = ["tests"] + +[tool.uv.pip] +no-emit-package = ["some-package", "pip", "setuptools", "distribute"] # packages that should not be pinned diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..260ba6a --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,2 @@ +-r requirements.in +ruff diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..5c1bea9 --- /dev/null +++ b/requirements.in @@ -0,0 +1,2 @@ +flask +flask-session diff --git a/staticshield.py b/staticshield.py new file mode 100644 index 0000000..f5ac54a --- /dev/null +++ b/staticshield.py @@ -0,0 +1,116 @@ +import os +import urllib.request +from logging.config import dictConfig + +from flask import Flask, redirect, request, send_from_directory, session + +from flask_session import Session + +dictConfig({ + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + }}, + 'handlers': {'wsgi': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default' + }}, + 'root': { + #'level': 'INFO', + 'level': 'DEBUG', + 'handlers': ['wsgi'] + } +}) + +app = Flask(__name__) +app.config.from_prefixed_env() +app.config['SESSION_PERMANENT'] = False +app.config['SESSION_TYPE'] = 'filesystem' +Session(app) + +config_vars = ['SERVE_DIR', 'MOTHERSHIP'] +for config_var in config_vars: + if config_var not in app.config: + print(f'FLASK_{config_var} env var not set') + os.sys.exit(1) + else: + app.logger.info('Config env %s with value "%s"', config_var, app.config[config_var]) + +# Base dir of the files we want to serve; Flask will take care not to escape this dir +SERVE_DIR = app.config['SERVE_DIR'] + +# Mothership server and login-url, which will redirect here with a sessionstart/SEKRIT +MOTHERSHIP = app.config['MOTHERSHIP'] + + +@app.route('/', methods=['GET', 'POST']) +def all_routes(path): + """Intercept all routes and proxy it through a session. + + Loosely based on https://stackoverflow.com/a/20648053 + + :param str path: path of file inside the base SERVE_DIR to be served + """ + app.logger.info('Requested %s', path) + if path.startswith('sessionstart/'): + # We got redirected back from the mothership, lets see if the secret we got is really known + # The path we should have gotten back is of the format: + # /sessionstart/SEKRIT_TOKEN/ + secret_and_redirect = path.split('sessionstart/')[1] + secret_redirect_split = secret_and_redirect.split('/') + secret = secret_redirect_split[0] + redirect_path = '/' + if len(secret_redirect_split) > 1: + redirect_path = '/'.join(secret_redirect_split[1:]) + app.logger.info('starting new session with secret "%s"', secret) + print(f'afterwards, redirecting to "{redirect_path}"') + us = f'{request.host_url}{path}' + print(us) + # Ask the mothership if the secret is known to them, to prevent someone from just making up a URL + # Mothership will invalidate this secret token upon handling this request to prevent replay + try: + app.logger.info('verifying token "%s"', secret) + with urllib.request.urlopen(f'{MOTHERSHIP}/verify/{secret}') as response: + challenge_response = response.read() + print(challenge_response) + # Start session if challenge response was successful + session['id'] = secret + return redirect(redirect_path) + except urllib.error.URLError as e: + app.logger.error('lolwtf, server not found: %s', str(e.reason)) + return 'Unable to set up session', 403 + except urllib.error.HTTPError as e: + app.logger.error('lolnope, server says no: %s', str(e.reason)) + return 'Unable to set up session', 403 + + # Fallback to disallow + return 'Unable to set up session', 403 + + # check if the users exist or not + if not session.get('id'): + # Our current URL, to which mothership will redirect back including a sessionstart + original_url = f'{request.host_url}{path}' + callback_url = f'{request.host_url}sessionstart/' + app.logger.info('Redirecting to mothership with %s', original_url) + # No session yet, redirect to mothership + app.logger.debug('%s/%s', MOTHERSHIP, original_url) + return redirect(f'{MOTHERSHIP}/login?redirect={original_url}&callback={callback_url}') + + file_path = os.path.join(SERVE_DIR, path) + if os.path.isfile(file_path): + app.logger.info('Serving file %s', str(file_path)) + # This takes a base directory and a path, and ensures that the path is contained in the directory, which makes it safe to accept user-provided paths. + return send_from_directory(SERVE_DIR, path) + else: + app.logger.error('File not found: %s', str(file_path)) + return 'Sorry, 404' + # if text.startswith('favicon'): + # print('hoi') + # else: + # return redirect(url_for('404_error')) + + +@app.route('/') +def index(): + return redirect('/index.html')