aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.py
diff options
context:
space:
mode:
authorkj_sh6042026-05-31 00:32:02 -0400
committerkj_sh6042026-05-31 00:32:02 -0400
commitcf23fda43083a7374f48fc37433c0a8117779d2d (patch)
tree664c87fa2007fd7958d8b780d2b59f0886d0d06a /src/app.py
parent8f2ef3227a87bb6389a40ef85c2d6ab9db405dc5 (diff)
refactor: hardening of the application
Diffstat (limited to 'src/app.py')
-rw-r--r--src/app.py240
1 files changed, 152 insertions, 88 deletions
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/<filename>")
-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)