diff --git a/.env.example b/.env.example index cba354f..cb4f44e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ -SECRET_KEY=replace-with-a-long-random-string -ADMIN_PASSWORD=replace-with-a-private-admin-password +# Обязательные переменные. Без них приложение не запустится. +SECRET_KEY=сгенерируйте-случайную-строку-50-символов +ADMIN_PASSWORD=надёжный-пароль-админки + +# Необязательные +# FLASK_DEBUG=1 # только для локальной разработки +# COOKIE_SECURE=1 # включить при работе по HTTPS diff --git a/app.py b/app.py index cc7f0ee..9a0a7ae 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,9 @@ from hmac import compare_digest from pathlib import Path from flask import Flask, flash, make_response, redirect, render_template, request, session, url_for +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_wtf import CSRFProtect BASE_DIR = Path(__file__).resolve().parent @@ -29,9 +32,21 @@ REQUEST_STATUSES = { } app = Flask(__name__) -app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-this-secret-key") +app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY") +if not app.config["SECRET_KEY"]: + raise RuntimeError("SECRET_KEY не задан. Установите переменную окружения SECRET_KEY.") app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "0") == "1" + +csrf = CSRFProtect(app) + +limiter = Limiter( + get_remote_address, + app=app, + default_limits=["200 per hour"], + storage_uri="memory://", +) def get_connection(): @@ -88,6 +103,7 @@ def index(): @app.post("/submit") +@limiter.limit("10 per minute") def submit_request(): name = request.form.get("name", "").strip() contact = request.form.get("contact", "").strip() @@ -172,8 +188,12 @@ def build_request_query(filters): conditions.append("subject = ?") parameters.append(filters["subject"]) if filters["q"]: - conditions.append("(name LIKE ? OR contact LIKE ? OR message LIKE ? OR admin_note LIKE ?)") - search_pattern = f"%{filters['q']}%" + escaped = filters["q"].replace("%", "\\%").replace("_", "\\_") + search_pattern = f"%{escaped}%" + conditions.append( + "(name LIKE ? ESCAPE '\\' OR contact LIKE ? ESCAPE '\\' " + "OR message LIKE ? ESCAPE '\\' OR admin_note LIKE ? ESCAPE '\\')" + ) parameters.extend([search_pattern] * 4) where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" @@ -210,13 +230,16 @@ def fetch_stats(connection): @app.route("/admin/login", methods=["GET", "POST"]) +@limiter.limit("10 per minute") def admin_login(): if session.get("is_admin"): return redirect(url_for("admin_dashboard")) if request.method == "POST": password = request.form.get("password", "") - admin_password = os.getenv("ADMIN_PASSWORD", "admin123") + admin_password = os.environ.get("ADMIN_PASSWORD") + if not admin_password: + raise RuntimeError("ADMIN_PASSWORD не задан. Установите переменную окружения.") if compare_digest(password, admin_password): session.clear() @@ -288,6 +311,13 @@ def admin_delete_request(request_id): return redirect(url_for("admin_dashboard")) +def csv_safe(value): + """Защита от CSV-инъекции: значения с формулами экранируются апострофом.""" + if value and value[0] in ("=", "+", "-", "@", "\t", "\r"): + return "'" + value + return value + + @app.get("/admin/export.csv") @admin_required def admin_export(): @@ -317,15 +347,15 @@ def admin_export(): writer.writerow( [ consultation["id"], - consultation["name"], - consultation["contact"], + csv_safe(consultation["name"]), + csv_safe(consultation["contact"]), consultation["subject"], consultation["exam"], consultation["format"], consultation["preferred_time"], REQUEST_STATUSES.get(consultation["status"], consultation["status"]), - consultation["message"] or "", - consultation["admin_note"] or "", + csv_safe(consultation["message"] or ""), + csv_safe(consultation["admin_note"] or ""), consultation["created_at"], consultation["updated_at"] or "", ] @@ -349,4 +379,4 @@ init_database() if __name__ == "__main__": - app.run(debug=True) + app.run(debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/requirements.txt b/requirements.txt index 840d434..ff04e54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ Flask>=3.0,<4.0 +Flask-WTF>=1.2,<2.0 +Flask-Limiter>=3.5,<4.0 diff --git a/templates/admin.html b/templates/admin.html index e9f005f..c6d572d 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -81,6 +81,7 @@ {% if consultation["message"] %}
{% endif %}