From 971e51b6f5ba8e8acc98c0f32a34b4ef56e79681 Mon Sep 17 00:00:00 2001 From: QA Tester Date: Thu, 6 Aug 2026 16:12:48 +0400 Subject: [PATCH] =?UTF-8?q?security:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D1=83=D1=8F=D0=B7=D0=B2=D0=B8=D0=BC=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0=20(PRJ-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gunicorn вместо dev-сервера Werkzeug; non-root user в Dockerfile - volume sqlite_data для БД (заявки не теряются при rebuild) - consultations.db и data/ в .dockerignore (ПДн не попадают в образ) - Secure-куки по умолчанию (COOKIE_SECURE=1), лимит сессии 12ч - защитные заголовки: CSP, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy - MAX_CONTENT_LENGTH 64КБ - анти-брутфорс админки: 5 попыток -> блокировка 15 мин + лимит 5/мин - обязательный чекбокс согласия на обработку ПДн + страница /privacy - .env.example: понятные плейсхолдеры, COOKIE_SECURE --- .dockerignore | 4 ++++ .env.example | 8 ++++--- .gitignore | 1 + Dockerfile | 9 ++++++- app.py | 53 ++++++++++++++++++++++++++++++++++++++---- docker-compose.yml | 6 +++++ requirements.txt | 1 + templates/index.html | 1 + templates/privacy.html | 51 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 templates/privacy.html diff --git a/.dockerignore b/.dockerignore index ec4e39e..a200884 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,7 @@ __pycache__ venv *.log logs +# БД с персональными данными не должна попадать в docker-образ +consultations.db +data/ +*.db diff --git a/.env.example b/.env.example index cb4f44e..1b4de44 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ # Обязательные переменные. Без них приложение не запустится. -SECRET_KEY=сгенерируйте-случайную-строку-50-символов -ADMIN_PASSWORD=надёжный-пароль-админки +SECRET_KEY=change-me-strong-random-secret +ADMIN_PASSWORD=change-me-strong-admin-password + +# Secure-куки: сайт работает только по HTTPS, держать включённым (=1) +COOKIE_SECURE=1 # Необязательные # FLASK_DEBUG=1 # только для локальной разработки -# COOKIE_SECURE=1 # включить при работе по HTTPS diff --git a/.gitignore b/.gitignore index a7a2373..17b21ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ *.pyc .env consultations.db +data/ diff --git a/Dockerfile b/Dockerfile index 35c1e54..830def8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,13 @@ COPY . . # Не dev-режим по умолчанию ENV FLASK_DEBUG=0 +# Не запускать от имени root +RUN useradd --create-home --uid 10001 appuser \ + && mkdir -p /app/data \ + && chown -R appuser:appuser /app +USER appuser + EXPOSE 5000 -CMD ["python", "app.py"] +# Production: gunicorn вместо dev-сервера Werkzeug +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "60", "app:app"] diff --git a/app.py b/app.py index 0f306d4..7463274 100644 --- a/app.py +++ b/app.py @@ -14,7 +14,7 @@ from flask_wtf import CSRFProtect BASE_DIR = Path(__file__).resolve().parent -DATABASE_PATH = BASE_DIR / "consultations.db" +DATABASE_PATH = BASE_DIR / "data" / "consultations.db" SUBJECTS = {"math": "Математика"} EXAMS = {"oge": "ОГЭ", "ege": "ЕГЭ", "other": "Пока не определились"} FORMATS = {"online": "Онлайн", "offline": "Очно", "other": "Пока не определились"} @@ -37,7 +37,10 @@ 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" +# Secure-куки по умолчанию включены (сайт работает только по HTTPS) +app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "1") == "1" +app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 12 # 12 часов +app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 # 64 КБ — защита от гигантских POST csrf = CSRFProtect(app) @@ -49,7 +52,23 @@ limiter = Limiter( ) +@app.after_request +def set_security_headers(response): + """Базовые защитные HTTP-заголовки.""" + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; " + "font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + ) + return response + + def get_connection(): + DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) connection = sqlite3.connect(DATABASE_PATH) connection.row_factory = sqlite3.Row return connection @@ -112,6 +131,11 @@ def submit_request(): format_key = request.form.get("format", "").strip() preferred_time_key = request.form.get("preferred_time", "").strip() message = request.form.get("message", "").strip()[:2000] + consent = request.form.get("consent", "").strip() + + if consent != "on": + flash("Необходимо согласие на обработку персональных данных.", "error") + return redirect(url_for("index") + "#contact") if ( not name @@ -155,6 +179,11 @@ def success(): return render_template("success.html", name=request.args.get("name", "")) +@app.get("/privacy") +def privacy_policy(): + return render_template("privacy.html") + + def admin_required(view): @wraps(view) def wrapped_view(*args, **kwargs): @@ -230,11 +259,20 @@ def fetch_stats(connection): @app.route("/admin/login", methods=["GET", "POST"]) -@limiter.limit("10 per minute") +@limiter.limit("5 per minute") def admin_login(): if session.get("is_admin"): return redirect(url_for("admin_dashboard")) + # анти-брутфорс: 5 неудачных попыток -> блокировка на 15 минут + failed = session.get("login_failed", 0) + locked_until = session.get("login_locked_until", 0) + now = datetime.now().timestamp() + if locked_until and now < locked_until: + remaining = int(locked_until - now) + flash(f"Слишком много попыток входа. Подождите {remaining // 60 + 1} мин.", "error") + return render_template("admin_login.html") + if request.method == "POST": password = request.form.get("password", "") admin_password = os.environ.get("ADMIN_PASSWORD") @@ -246,7 +284,14 @@ def admin_login(): session["is_admin"] = True return redirect(url_for("admin_dashboard")) - flash("Неверный пароль.", "error") + failed += 1 + if failed >= 5: + session["login_locked_until"] = now + 15 * 60 + session["login_failed"] = 0 + flash("Слишком много попыток входа. Вход заблокирован на 15 минут.", "error") + else: + session["login_failed"] = failed + flash(f"Неверный пароль. Осталось попыток: {5 - failed}.", "error") return render_template("admin_login.html") diff --git a/docker-compose.yml b/docker-compose.yml index 3bfa8cd..7e46148 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,10 @@ services: - "5000:5000" env_file: - .env + volumes: + # БД с заявками (ПДн) — на постоянном volume, не теряется при rebuild + - sqlite_data:/app/data restart: unless-stopped + +volumes: + sqlite_data: diff --git a/requirements.txt b/requirements.txt index ff04e54..5628833 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Flask>=3.0,<4.0 Flask-WTF>=1.2,<2.0 Flask-Limiter>=3.5,<4.0 +gunicorn>=22.0,<24.0 diff --git a/templates/index.html b/templates/index.html index 3335912..673a70e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -160,6 +160,7 @@
Формат занятий *
+

Нажимая кнопку, вы соглашаетесь на обработку персональных данных.

diff --git a/templates/privacy.html b/templates/privacy.html new file mode 100644 index 0000000..d5589e4 --- /dev/null +++ b/templates/privacy.html @@ -0,0 +1,51 @@ + + + + + + + Политика конфиденциальности — Анастасия Панкратенкова + + + +
+ + +
+

Юридическая информация

+

Политика
конфиденциальности

+

Этот сайт использует персональные данные только для обработки заявок на консультации.

+ +

Какие данные собираются

+

При заполнении формы могут быть указаны имя, контакт (телефон или Telegram), предмет, экзамен, формат занятий, желаемое время и комментарий к заявке.

+ +

Зачем они нужны

+

Данные используются исключительно для связи с заявителем, ответа на вопросы и подготовки к занятиям.

+ +

Хранение и защита

+

Заявки хранятся в защищённой базе данных. Доступ к ним имеет только преподаватель. Данные не передаются третьим лицам и не используются для рекламных рассылок.

+ +

Отзыв согласия

+

Чтобы уточнить, изменить или удалить сведения из заявки, свяжитесь с преподавателем тем же способом, который был указан при записи.

+ + Вернуться к записи +
+ + +
+ +