feat: harden production security boundaries

This commit is contained in:
ik
2026-09-07 07:51:25 +07:00
parent 2fa6b68279
commit 687b4c9cb5
13 changed files with 105 additions and 11 deletions
+5 -3
View File
@@ -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/
+1
View File
@@ -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-артефакты.
+4 -2
View File
@@ -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"]
+6 -1
View File
@@ -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"
+4
View File
@@ -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
+2
View File
@@ -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"]
+25 -2
View File
@@ -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
+13
View File
@@ -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"
}
}
+2 -2
View File
@@ -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. Проверка и первый запуск
+1
View File
@@ -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
+3
View File
@@ -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"
+1 -1
View File
@@ -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).
+38
View File
@@ -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.