feat: harden production security boundaries
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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. Проверка и первый запуск
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user