From cf23fda43083a7374f48fc37433c0a8117779d2d Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 31 May 2026 00:32:02 -0400 Subject: refactor: hardening of the application --- src/app.py | 240 +++++++++++++++--------- src/index.html | 419 ++++++++++++++++++++++++++++++++++++++---- src/nyan.png | Bin 0 -> 901 bytes src/requirements.txt | 3 +- src/uploads/.htaccess | 2 - src/uploads/nyan_819cac51.png | Bin 901 -> 0 bytes 6 files changed, 539 insertions(+), 125 deletions(-) create mode 100644 src/nyan.png delete mode 100644 src/uploads/.htaccess delete mode 100644 src/uploads/nyan_819cac51.png (limited to 'src') diff --git a/src/app.py b/src/app.py index d74d11d..da8f5df 100644 --- a/src/app.py +++ b/src/app.py @@ -1,38 +1,51 @@ #!/usr/bin/env python3 import base64 +import hashlib import os -import re -import secrets import subprocess +import time from pathlib import Path +from threading import Lock -import magic from flask import Flask, Response, jsonify, request, send_file, send_from_directory app = Flask(__name__, static_folder=None) -app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB upload cap -UPLOAD_DIR = Path(__file__).parent / "uploads" -UPLOAD_DIR.mkdir(mode=0o755, exist_ok=True) - -ALLOWED_MIME = { - "image/png": "png", - "image/jpeg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "image/svg+xml": "svg", - "image/bmp": "bmp", -} +_CSP = ( + "default-src 'self'; " + "base-uri 'self'; " + "frame-ancestors 'none'; " + "object-src 'none'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "img-src 'self' data: http: https:; " + "font-src 'self' data:; " + "connect-src 'self'; " + "form-action 'self'" +) + + +@app.after_request +def add_security_headers(resp: Response) -> Response: + resp.headers.setdefault("Content-Security-Policy", _CSP) + resp.headers.setdefault("X-Content-Type-Options", "nosniff") + resp.headers.setdefault("X-Frame-Options", "DENY") + resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + resp.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + return resp # build list of allowed font directories _FONT_DIRS: list[Path] = [ Path("/usr/share/fonts"), Path("/usr/local/share/fonts"), ] -for _home in Path("/home").glob("*"): - _FONT_DIRS.append(_home / ".local" / "share" / "fonts") - _FONT_DIRS.append(_home / ".fonts") + +_FONTS_CACHE_TTL_SECONDS = 300 +_fonts_cache_lock = Lock() +_fonts_cache_until = 0.0 +_fonts_cache_payload: list[dict[str, str]] = [] +_fonts_cache_paths_by_token: dict[str, Path] = {} def _allowed_font_dirs() -> list[str]: @@ -46,49 +59,13 @@ def _allowed_font_dirs() -> list[str]: return out -@app.route("/") -def index(): - return send_from_directory(app.root_path, "index.html") - - -@app.route("/favicon.svg") -def favicon_svg(): - return send_from_directory(app.root_path, "favicon.svg") - - -@app.route("/uploads/") -def uploads(filename: str): - return send_from_directory(UPLOAD_DIR, filename) - - -@app.route("/upload", methods=["POST"]) -def upload(): - if "image" not in request.files: - return jsonify({"error": "no file provided"}), 400 - - f = request.files["image"] - if not f.filename: - return jsonify({"error": "empty filename"}), 400 - - data = f.read() - if not data: - return jsonify({"error": "empty file"}), 400 - - mime = magic.from_buffer(data, mime=True) - if mime not in ALLOWED_MIME: - return jsonify({"error": f"invalid file type: {mime}"}), 400 +def _font_token_for_path(path: Path) -> str: + digest = hashlib.sha256(str(path).encode("utf-8")).digest() + token = base64.urlsafe_b64encode(digest[:18]).decode("ascii") + return token.rstrip("=") - ext = ALLOWED_MIME[mime] - basename = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(f.filename).stem)[:64] - filename = f"{basename}_{secrets.token_hex(4)}.{ext}" - (UPLOAD_DIR / filename).write_bytes(data) - - return jsonify({"filename": filename, "url": f"uploads/{filename}"}) - - -@app.route("/fonts") -def fonts(): +def _build_fonts_payload() -> tuple[list[dict[str, str]], dict[str, Path]] | None: try: result = subprocess.run( ["fc-list", "--format=%{family}|%{style}|%{file}\n"], @@ -99,13 +76,15 @@ def fonts(): check=False, ) except (FileNotFoundError, subprocess.TimeoutExpired): - return jsonify([]) + return None if result.returncode != 0: - return jsonify([]) + return None if not result.stdout.strip(): - return jsonify([]) + return [], {} + + allowed_dirs = _allowed_font_dirs() def style_score(style: str) -> int: s = style.strip().lower() @@ -117,59 +96,144 @@ def fonts(): return 2 return 3 - best: dict[str, dict] = {} + best: dict[str, dict[str, str | int]] = {} for line in result.stdout.splitlines(): parts = line.split("|", 2) if len(parts) < 3: continue + family = parts[0].split(",")[0].strip() if not family: continue - style = parts[1].split(",")[0].strip() + + style = parts[1].split(",")[0].strip() file_path = parts[2].strip() - if not Path(file_path).exists(): + + try: + real = Path(file_path).resolve(strict=True) + except (OSError, RuntimeError): + continue + + real_str = str(real) + os.sep + if not any(real_str.startswith(d) for d in allowed_dirs): continue + score = style_score(style) - if family not in best or score < best[family]["score"]: - best[family] = {"file": file_path, "score": score} + if family not in best or score < int(best[family]["score"]): + best[family] = {"file": str(real), "score": score} fmt_map = {"ttf": "truetype", "otf": "opentype", "woff": "woff", "woff2": "woff2"} - fonts_list = [ + token_map: dict[str, Path] = {} + fonts_list = [] + for family, entry in best.items(): + file_str = str(entry["file"]) + real = Path(file_str) + token = _font_token_for_path(real) + token_map[token] = real + fonts_list.append( + { + "family": family, + "file": token, + "format": fmt_map.get(real.suffix.lstrip(".").lower(), "truetype"), + } + ) + + fonts_list.sort(key=lambda x: x["family"].casefold()) + return fonts_list, token_map + + +def _get_fonts_cache() -> list[dict[str, str]]: + global _fonts_cache_until, _fonts_cache_payload, _fonts_cache_paths_by_token + + now = time.monotonic() + with _fonts_cache_lock: + if _fonts_cache_payload and now < _fonts_cache_until: + return _fonts_cache_payload + + built = _build_fonts_payload() + if built is not None: + payload, token_map = built + _fonts_cache_payload = payload + _fonts_cache_paths_by_token = token_map + _fonts_cache_until = now + _FONTS_CACHE_TTL_SECONDS + return _fonts_cache_payload + + if not _fonts_cache_payload: + _fonts_cache_payload = [] + _fonts_cache_paths_by_token = {} + + _fonts_cache_until = now + 15 + return _fonts_cache_payload + + +def _font_path_from_token(token: str) -> Path | None: + _get_fonts_cache() + with _fonts_cache_lock: + return _fonts_cache_paths_by_token.get(token) + + +def _font_path_from_legacy_param(encoded: str) -> Path | None: + try: + file_str = base64.b64decode(encoded, validate=True).decode("utf-8") + except Exception: + return None + + if "\x00" in file_str: + return None + + try: + return Path(file_str).resolve(strict=True) + except (OSError, RuntimeError): + return None + + +@app.route("/") +def index(): + return send_from_directory(app.root_path, "index.html") + + +@app.route("/favicon.svg") +def favicon_svg(): + return send_from_directory(app.root_path, "favicon.svg") + + +@app.route("/nyan.png") +def nyan_png(): + return send_from_directory(app.root_path, "nyan.png") + + +@app.route("/upload", methods=["POST"]) +def upload(): + return jsonify( { - "family": family, - "file": base64.b64encode(entry["file"].encode()).decode(), - "format": fmt_map.get(Path(entry["file"]).suffix.lstrip(".").lower(), "truetype"), + "error": "server-side uploads are disabled; images stay in browser local storage" } - for family, entry in best.items() - ] - fonts_list.sort(key=lambda x: x["family"].casefold()) + ), 410 + +@app.route("/fonts") +def fonts(): + fonts_list = _get_fonts_cache() resp = jsonify(fonts_list) - resp.headers["Cache-Control"] = "public, max-age=3600" + resp.headers["Cache-Control"] = f"public, max-age={_FONTS_CACHE_TTL_SECONDS}" return resp @app.route("/font") def font(): - encoded = request.args.get("f", "") - if not encoded: + token = request.args.get("f", "") + if not token: return Response("missing parameter", status=400) - try: - file_str = base64.b64decode(encoded).decode("utf-8") - except Exception: - return Response("invalid parameter", status=400) - - # null byte guard - if "\x00" in file_str: - return Response("invalid parameter", status=400) + path = _font_path_from_token(token) + if path is None: + path = _font_path_from_legacy_param(token) - p = Path(file_str) - if not p.exists(): + if path is None: return Response("font not found", status=404) try: - real = p.resolve(strict=True) + real = path.resolve(strict=True) except (OSError, RuntimeError): return Response("font not found", status=404) diff --git a/src/index.html b/src/index.html index a6f3ae4..cbcc7cd 100644 --- a/src/index.html +++ b/src/index.html @@ -7,7 +7,7 @@ sent-web - +