diff --git a/.env.production.example b/.env.production.example index fb2c99a..d2ee2a1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -13,10 +13,12 @@ ADMIN_TOKEN=replace-with-at-least-32-random-characters RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters ADMIN_BASIC_USER=rf4admin # Generate with: docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'YOUR PASSWORD' -ADMIN_BASIC_PASSWORD_HASH=replace-with-caddy-password-hash +ADMIN_BASIC_PASSWORD_HASH='replace-with-caddy-password-hash' -S3_ACCESS_KEY=replace-with-random-access-key -S3_SECRET_KEY=replace-with-at-least-32-random-characters +MINIO_ROOT_USER=replace-with-private-root-access-key +MINIO_ROOT_PASSWORD=replace-with-private-root-password-32-chars +S3_ACCESS_KEY=replace-with-separate-app-access-key +S3_SECRET_KEY=replace-with-separate-app-secret-at-least-32-chars S3_BUCKET=catch-screenshots OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/ diff --git a/README.md b/README.md index c293c9b..85e8c15 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Host-side мониторинг контейнеров, readiness, диска, р Ежедневный systemd timer создаёт проверяемую копию до retention-очистки, а production Compose ограничивает рост JSON-логов контейнеров. Фактическое состояние DNS/TLS домена и серверный чек-лист ведутся в [`docs/deployment-status.md`](docs/deployment-status.md). +Результаты security review и остаточные ограничения закрытой альфы записаны в [`docs/security-review.md`](docs/security-review.md). Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 403006e..2ec56db 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -3,7 +3,9 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 COPY apps/api/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY apps/api . -COPY rf4_research ./rf4_research +RUN useradd --create-home --uid 10001 rf4 +COPY --chown=rf4:rf4 apps/api . +COPY --chown=rf4:rf4 rf4_research ./rf4_research +USER rf4 EXPOSE 8000 CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"] diff --git a/apps/api/app/main.py b/apps/api/app/main.py index d7e8104..bb3b8b5 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -54,6 +54,10 @@ async def structured_request_log(request: Request, call_next): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Cross-Origin-Opener-Policy"] = "same-origin" + if request.url.path.startswith("/api/v1/admin/") or request.url.path == "/api/v1/catch-reports": + response.headers["Cache-Control"] = "no-store" if settings.deployment_environment == "production": response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response @@ -163,7 +167,8 @@ def records( def _admin(authorization: Annotated[str | None, Header()] = None) -> str: - if not authorization or authorization != f"Bearer {settings.admin_token}": + expected = f"Bearer {settings.admin_token}" + if not authorization or not hmac.compare_digest(authorization, expected): raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"}) return "admin" diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 7127cc0..d5de88a 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -59,6 +59,8 @@ def test_liveness_does_not_probe_dependencies() -> None: response = client.get("/health?token=must-not-be-logged") assert response.json() == {"status": "ok"} assert len(response.headers["X-Request-ID"]) == 32 + assert response.headers["X-Frame-Options"] == "DENY" + assert response.headers["Cross-Origin-Opener-Policy"] == "same-origin" def test_spot_detail_and_catches() -> None: @@ -80,11 +82,13 @@ def test_records_list_is_empty_before_import() -> None: def test_user_report_requires_moderation_before_activity() -> None: created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"}) assert created.status_code == 201 + assert created.headers["Cache-Control"] == "no-store" assert created.json()["moderation_status"] == "pending" report_id = created.json()["id"] headers = {"Authorization": "Bearer change-me-in-production"} pending = client.get("/api/v1/admin/catch-reports", headers=headers) assert pending.status_code == 200 + assert pending.headers["Cache-Control"] == "no-store" assert any(item["id"] == report_id for item in pending.json()) approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"}) assert approved.status_code == 200 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 397ae45..245ce39 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -13,5 +13,7 @@ ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production COPY --from=build /app/package*.json ./ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist +RUN chown -R node:node /app +USER node EXPOSE 4321 CMD ["node", "./dist/server/entry.mjs"] diff --git a/compose.production.yaml b/compose.production.yaml index a006329..6ec5de0 100644 --- a/compose.production.yaml +++ b/compose.production.yaml @@ -60,8 +60,8 @@ services: command: server /data restart: unless-stopped environment: - MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} - MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD} volumes: - minio_data:/data healthcheck: @@ -76,6 +76,28 @@ services: resources: limits: {cpus: "0.75", memory: 1G} + minio-init: + image: minio/mc:RELEASE.2025-07-21T05-28-08Z + restart: "no" + entrypoint: ["/bin/sh", "-c"] + command: + - >- + 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 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 + 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} + depends_on: + minio: {condition: service_healthy} + networks: [backend] + security_opt: [no-new-privileges:true] + logging: *default-logging + api: build: context: . @@ -108,6 +130,7 @@ services: depends_on: db: {condition: service_healthy} minio: {condition: service_healthy} + minio-init: {condition: service_completed_successfully} healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"] interval: 10s diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 59987ba..af9b9ce 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -14,6 +14,14 @@ reverse_proxy web:4321 } + @adminApi path /api/v1/admin/* + handle @adminApi { + basic_auth { + {$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH} + } + reverse_proxy api:8000 + } + @api path /api/* /health /ready handle @api { reverse_proxy api:8000 @@ -29,6 +37,9 @@ X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" Permissions-Policy "camera=(), microphone=(), geolocation=()" + X-Frame-Options "DENY" + Cross-Origin-Opener-Policy "same-origin" + Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" } } @@ -40,5 +51,7 @@ Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" Referrer-Policy "no-referrer" + X-Frame-Options "DENY" + Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox" } } diff --git a/deploy/README.md b/deploy/README.md index 6d7d69b..bf29083 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -17,11 +17,11 @@ Production-контур рассчитан на один Linux-сервер с D ```bash cp .env.production.example .env.production chmod 600 .env.production -openssl rand -base64 36 # повторить для пароля БД, ADMIN_TOKEN, RATE_LIMIT_SECRET и S3_SECRET_KEY +openssl rand -base64 36 # отдельные значения для БД, ADMIN_TOKEN, RATE_LIMIT_SECRET, MinIO root и S3 app docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ' ``` -Заполните `.env.production`. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами. +Заполните `.env.production`. Хеш Caddy содержит символы `$`, поэтому значение `ADMIN_BASIC_PASSWORD_HASH` в env-файле заключите в одинарные кавычки. `MINIO_ROOT_*` и `S3_*` обязаны быть разными: init-контейнер проверяет это, API получает только application credentials. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами. ## 3. Проверка и первый запуск diff --git a/deploy/test-backup-restore.sh b/deploy/test-backup-restore.sh index 9669be5..f04df12 100755 --- a/deploy/test-backup-restore.sh +++ b/deploy/test-backup-restore.sh @@ -17,6 +17,7 @@ cleanup() { 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 diff --git a/deploy/test-production-bootstrap.sh b/deploy/test-production-bootstrap.sh index 58bb6c3..cf20897 100755 --- a/deploy/test-production-bootstrap.sh +++ b/deploy/test-production-bootstrap.sh @@ -24,9 +24,12 @@ trap cleanup EXIT INT TERM $compose up --build -d --wait db minio api web curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null +curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY' +curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin' test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0010" test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2" test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from waterbody')" = "2" test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0" WEB_URL="http://127.0.0.1:$BOOTSTRAP_WEB_PORT" BOOTSTRAP_API_URL="http://127.0.0.1:$BOOTSTRAP_API_PORT" BOOTSTRAP_ADMIN_TOKEN=replace-with-at-least-32-random-characters npm --prefix apps/web run test:bootstrap +curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/catch-reports" | grep -qi '^cache-control: no-store' echo "Production bootstrap passed from empty volumes" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 43aad87..f06ee7f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,7 +65,7 @@ - [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками. - [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging. - [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота. -- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме. +- [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`. - [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно. - [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`. - [x] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: консистентные `pg_dump` и MinIO API mirror, контрольные суммы, runbook и успешный изолированный drill с намеренным удалением данных (6 сентября 2026). diff --git a/docs/security-review.md b/docs/security-review.md new file mode 100644 index 0000000..b6fc070 --- /dev/null +++ b/docs/security-review.md @@ -0,0 +1,38 @@ +# Production security review + +Проверка выполнена 7 сентября 2026 года для закрытой альфы. Она охватывает код и Compose-конфигурацию; firewall, SSH, DNS и фактические права файлов проверяются отдельно на целевом сервере. + +## Закрытые пункты + +| Область | Реализация | +|---|---| +| Внешняя поверхность | наружу публикуются только Caddy `80/443`; PostgreSQL, API, web и MinIO находятся во внутренних сетях | +| Admin web/API | `/admin/*` и `/api/v1/admin/*` требуют Caddy Basic Auth; API дополнительно требует длинный bearer token | +| Сравнение токена | constant-time `hmac.compare_digest` | +| CORS | production принимает только явно заданные HTTPS origins, wildcard запрещён конфигурацией | +| Чувствительные ответы | административные ответы и создание заявки получают `Cache-Control: no-store` | +| Browser headers | HSTS, `nosniff`, restrictive referrer/permissions, anti-framing CSP/X-Frame-Options и COOP | +| Скриншоты | MIME/расширение/фактический формат, лимит размера и пикселей, повторное кодирование без EXIF, одноразовый upload token | +| MinIO | Console не опубликована; root credentials отделены от application credentials и не передаются API | +| Секреты | production fail-fast отклоняет короткие/демонстрационные значения; `.env.production` исключён из Git и должен иметь mode `0600` | +| Контейнеры | API и web запускаются непривилегированными пользователями; для всех сервисов включён `no-new-privileges` | +| Логи | query string, IP, Authorization и payload не журналируются; известные формы секретов редактируются | +| Персональные данные | действует документированная retention-очистка, причём backup выполняется раньше удаления | + +## Остаточные ограничения закрытой альфы + +- Basic Auth и bearer token общие для всех модераторов: персональная атрибуция и отзыв одной учётной записи отсутствуют. +- Нет MFA и отдельного identity provider; admin credentials следует передавать только ограниченному числу операторов и ротировать при любом подозрении. +- Application user MinIO имеет встроенную политику `readwrite`; сейчас в инстансе используется один прикладной bucket. Перед появлением других bucket нужна отдельная bucket-scoped policy. +- Пользователь `rf4spotter`, запускающий Docker/systemd, фактически обладает высокими правами через Docker socket. SSH к нему должен быть ограничен ключами и firewall/VPN. +- Защита от volumetric DDoS и внешний WAF не входят в single-server Compose. + +## Проверка на сервере + +1. Убедиться, что `ss -lntup` не показывает наружу порты `4321`, `8000`, `9000`, `9001`, `5432`. +2. Проверить `stat -c '%a %U:%G' .env.production`: ожидается `600 rf4spotter:rf4spotter`. +3. Без Basic Auth запрос к `/admin/moderation` и `/api/v1/admin/catch-reports` должен вернуть `401` от Caddy. +4. С Basic Auth, но без bearer token административный API должен вернуть `401` от FastAPI. +5. Проверить security headers через `curl -I https://rf4spotter.ru/`. +6. Убедиться, что `https://files.rf4spotter.ru/` не открывает Console, list buckets или анонимную запись. +7. После проверки удалить команды с секретами из shell history либо вводить их через защищённый password manager.