From 868278fdba20c0ba31f211a9f6b4884ea430fc9d Mon Sep 17 00:00:00 2001 From: IK Date: Sat, 12 Sep 2026 16:01:00 +0700 Subject: [PATCH] security: restrict MinIO app to one bucket --- README.md | 2 ++ apps/api/app/readiness.py | 3 ++- apps/api/app/storage.py | 6 +----- apps/api/tests/test_readiness.py | 7 ++++--- compose.production.yaml | 10 +++++++++- deploy/README.md | 2 +- deploy/backup.sh | 2 +- deploy/restore.sh | 2 +- deploy/test-backup-restore.sh | 2 +- docs/ROADMAP.md | 2 +- 10 files changed, 23 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e8319df..9b2e790 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline. +В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые. + После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации. Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). diff --git a/apps/api/app/readiness.py b/apps/api/app/readiness.py index c2e878b..81ed59c 100644 --- a/apps/api/app/readiness.py +++ b/apps/api/app/readiness.py @@ -6,6 +6,7 @@ from typing import Any from sqlalchemy import func, select, text from sqlalchemy.orm import Session +from .config import settings from .models import CommunityImportRun, DataSource, ImportStatus, OfficialRecordImport @@ -32,7 +33,7 @@ def readiness_report( ready = False try: - s3.list_buckets() + s3.head_bucket(Bucket=settings.s3_bucket) components["minio"] = {"status": "ready"} except Exception: components["minio"] = {"status": "unavailable"} diff --git a/apps/api/app/storage.py b/apps/api/app/storage.py index 024ad94..0e22cb4 100644 --- a/apps/api/app/storage.py +++ b/apps/api/app/storage.py @@ -6,7 +6,6 @@ from functools import lru_cache import boto3 from botocore.client import BaseClient -from botocore.exceptions import ClientError from PIL import Image, UnidentifiedImageError from .config import settings @@ -63,10 +62,7 @@ def upload_screenshot(raw: bytes, *, filename: str | None = None, content_type: body, extension, mime = prepare_image(raw) key = f"reports/{uuid.uuid4()}.{extension}" s3 = client() - try: - s3.head_bucket(Bucket=settings.s3_bucket) - except ClientError: - s3.create_bucket(Bucket=settings.s3_bucket) + s3.head_bucket(Bucket=settings.s3_bucket) s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime) return key diff --git a/apps/api/tests/test_readiness.py b/apps/api/tests/test_readiness.py index 79f9184..4291936 100644 --- a/apps/api/tests/test_readiness.py +++ b/apps/api/tests/test_readiness.py @@ -11,12 +11,13 @@ from app.readiness import readiness_report class AvailableStorage: - def list_buckets(self) -> dict[str, list[object]]: - return {"Buckets": []} + def head_bucket(self, *, Bucket: str) -> dict[str, object]: + assert Bucket + return {} class UnavailableStorage: - def list_buckets(self) -> None: + def head_bucket(self, *, Bucket: str) -> None: raise ConnectionError("fixture unavailable") diff --git a/compose.production.yaml b/compose.production.yaml index 3f3d05d..abd3521 100644 --- a/compose.production.yaml +++ b/compose.production.yaml @@ -85,13 +85,21 @@ services: test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" && test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" && mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null && + mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null && + printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}' "$$S3_BUCKET" "$$S3_BUCKET" > /tmp/rf4-app-policy.json && + mc admin policy create local rf4-app-bucket /tmp/rf4-app-policy.json >/dev/null && (mc admin user info local "$$S3_ACCESS_KEY" >/dev/null 2>&1 || mc admin user add local "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY") && - mc admin policy attach local readwrite --user "$$S3_ACCESS_KEY" >/dev/null + (mc admin policy detach local readwrite --user "$$S3_ACCESS_KEY" >/dev/null 2>&1 || true) && + mc admin policy attach local rf4-app-bucket --user "$$S3_ACCESS_KEY" >/dev/null && + mc alias set app http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null && + mc stat "app/$$S3_BUCKET" >/dev/null && + ! mc ls app >/dev/null 2>&1 environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD} S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} + S3_BUCKET: ${S3_BUCKET:-catch-screenshots} depends_on: minio: {condition: service_healthy} networks: [backend] diff --git a/deploy/README.md b/deploy/README.md index 93a1818..fc08022 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -21,7 +21,7 @@ openssl rand -base64 36 # отдельные значения для БД, ADMI docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ' ``` -Заполните `.env.production`. Хеш Caddy содержит символы `$`, поэтому значение `ADMIN_BASIC_PASSWORD_HASH` в env-файле заключите в одинарные кавычки. `MINIO_ROOT_*` и `S3_*` обязаны быть разными: init-контейнер проверяет это, API получает только application credentials. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами. +Заполните `.env.production`. Хеш Caddy содержит символы `$`, поэтому значение `ADMIN_BASIC_PASSWORD_HASH` в env-файле заключите в одинарные кавычки. `MINIO_ROOT_*` и `S3_*` обязаны быть разными: init-контейнер root-доступом создаёт `S3_BUCKET`, назначает приложению только list/location этого bucket и get/put/delete его объектов, затем проверяет отсутствие глобального list. API получает только application credentials и не может создавать bucket. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами. ## 3. Проверка и первый запуск diff --git a/deploy/backup.sh b/deploy/backup.sh index 1ecabb3..27cb2ff 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -25,7 +25,7 @@ export BACKUP_DIRECTORY="$backup_root" env_file=${COMPOSE_ENV_FILE:-.env.production} compose="docker compose --env-file $env_file -f compose.production.yaml" $compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$target/postgres.dump" -$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "local/$S3_BUCKET" "/backup/'"$stamp"'/minio" && chmod -R a+rwX "/backup/'"$stamp"'/minio"' +$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc stat "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "local/$S3_BUCKET" "/backup/'"$stamp"'/minio" && chmod -R a+rwX "/backup/'"$stamp"'/minio"' tar -czf "$target/minio.tar.gz" -C "$target" minio rm -rf "$target/minio" (cd "$target" && sha256sum postgres.dump minio.tar.gz > SHA256SUMS) diff --git a/deploy/restore.sh b/deploy/restore.sh index b02d1ea..0a661b3 100755 --- a/deploy/restore.sh +++ b/deploy/restore.sh @@ -26,6 +26,6 @@ env_file=${COMPOSE_ENV_FILE:-.env.production} compose="docker compose --env-file $env_file -f compose.production.yaml" $compose stop api web $compose exec -T db sh -c 'pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$source_dir/postgres.dump" -$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc rm --recursive --force "local/$S3_BUCKET" >/dev/null 2>&1 || true; mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "/backup/'"$restore_name"'/minio" "local/$S3_BUCKET"' +$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc stat "local/$S3_BUCKET" >/dev/null && mc rm --recursive --force "local/$S3_BUCKET" >/dev/null 2>&1 && mc mirror --overwrite "/backup/'"$restore_name"'/minio" "local/$S3_BUCKET"' $compose up -d ${RESTORE_START_SERVICES:-minio api web proxy} echo "Restore completed from: $source_dir" diff --git a/deploy/test-backup-restore.sh b/deploy/test-backup-restore.sh index f04df12..4209598 100755 --- a/deploy/test-backup-restore.sh +++ b/deploy/test-backup-restore.sh @@ -19,7 +19,7 @@ trap cleanup EXIT INT TERM $compose up -d --wait db minio $compose run --rm minio-init $compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "create table restore_drill (value text primary key); insert into restore_drill values ('"'"'database-ok'"'"');"' >/dev/null -$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null; printf object-ok | mc pipe "local/$S3_BUCKET/restore-drill.txt"' >/dev/null +$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc stat "local/$S3_BUCKET" >/dev/null; printf object-ok | mc pipe "local/$S3_BUCKET/restore-drill.txt"' >/dev/null ./deploy/backup.sh "$drill_root" backup=$(find "$drill_root" -mindepth 1 -maxdepth 1 -type d ! -name '.rf4-restore.*' | head -n 1) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9b493e4..6407aa5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,7 +43,7 @@ - [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером. - [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска. - [x] **Q07 · Нагрузочная методика.** Добавлен read-only runner для activity, records, staging и moderation с warm-up, p50/p95/max, распределением HTTP-кодов и ограниченной concurrency. Методика фиксирует контекст запуска, ступени нагрузки и бюджеты, но не объявляет результатов до трёх прогонов на целевом сервере. -- [ ] **Q08 · Политика MinIO.** Ограничить app credentials одним bucket и добавить безопасную автоматическую проверку policy; root credentials оставить только bootstrap-задаче. +- [x] **Q08 · Политика MinIO.** Production bootstrap создаёт bucket и отдельную policy только с bucket location/list и get/put/delete его объектов. App credentials проверяются через bucket stat и отрицательную проверку глобального list; API больше не требует `ListAllMyBuckets` и не пытается создавать bucket. Root credentials остаются только у init-задачи. - [ ] **Q09 · Release-процедура.** Разделить миграционный/release-шаг и запуск приложения либо документировать выбранную стратегию отката; проверить upgrade с предыдущей ревизии на копии данных. - [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog.