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
|
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
|
||||||
ADMIN_BASIC_USER=rf4admin
|
ADMIN_BASIC_USER=rf4admin
|
||||||
# Generate with: docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'YOUR PASSWORD'
|
# 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
|
MINIO_ROOT_USER=replace-with-private-root-access-key
|
||||||
S3_SECRET_KEY=replace-with-at-least-32-random-characters
|
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
|
S3_BUCKET=catch-screenshots
|
||||||
|
|
||||||
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
|
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Host-side мониторинг контейнеров, readiness, диска, р
|
|||||||
Ежедневный systemd timer создаёт проверяемую копию до retention-очистки, а production Compose ограничивает рост JSON-логов контейнеров.
|
Ежедневный systemd timer создаёт проверяемую копию до retention-очистки, а production Compose ограничивает рост JSON-логов контейнеров.
|
||||||
|
|
||||||
Фактическое состояние DNS/TLS домена и серверный чек-лист ведутся в [`docs/deployment-status.md`](docs/deployment-status.md).
|
Фактическое состояние 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-артефакты.
|
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
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
COPY apps/api/requirements.txt .
|
COPY apps/api/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY apps/api .
|
RUN useradd --create-home --uid 10001 rf4
|
||||||
COPY rf4_research ./rf4_research
|
COPY --chown=rf4:rf4 apps/api .
|
||||||
|
COPY --chown=rf4:rf4 rf4_research ./rf4_research
|
||||||
|
USER rf4
|
||||||
EXPOSE 8000
|
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"]
|
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["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
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":
|
if settings.deployment_environment == "production":
|
||||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
return response
|
return response
|
||||||
@@ -163,7 +167,8 @@ def records(
|
|||||||
|
|
||||||
|
|
||||||
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
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"})
|
raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
|
||||||
return "admin"
|
return "admin"
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ def test_liveness_does_not_probe_dependencies() -> None:
|
|||||||
response = client.get("/health?token=must-not-be-logged")
|
response = client.get("/health?token=must-not-be-logged")
|
||||||
assert response.json() == {"status": "ok"}
|
assert response.json() == {"status": "ok"}
|
||||||
assert len(response.headers["X-Request-ID"]) == 32
|
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:
|
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:
|
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"})
|
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.status_code == 201
|
||||||
|
assert created.headers["Cache-Control"] == "no-store"
|
||||||
assert created.json()["moderation_status"] == "pending"
|
assert created.json()["moderation_status"] == "pending"
|
||||||
report_id = created.json()["id"]
|
report_id = created.json()["id"]
|
||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
|
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
|
||||||
assert pending.status_code == 200
|
assert pending.status_code == 200
|
||||||
|
assert pending.headers["Cache-Control"] == "no-store"
|
||||||
assert any(item["id"] == report_id for item in pending.json())
|
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"})
|
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
|
||||||
assert approved.status_code == 200
|
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/package*.json ./
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
USER node
|
||||||
EXPOSE 4321
|
EXPOSE 4321
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|||||||
+25
-2
@@ -60,8 +60,8 @@ services:
|
|||||||
command: server /data
|
command: server /data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
|
||||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -76,6 +76,28 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
limits: {cpus: "0.75", memory: 1G}
|
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:
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -108,6 +130,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db: {condition: service_healthy}
|
db: {condition: service_healthy}
|
||||||
minio: {condition: service_healthy}
|
minio: {condition: service_healthy}
|
||||||
|
minio-init: {condition: service_completed_successfully}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
reverse_proxy web:4321
|
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
|
@api path /api/* /health /ready
|
||||||
handle @api {
|
handle @api {
|
||||||
reverse_proxy api:8000
|
reverse_proxy api:8000
|
||||||
@@ -29,6 +37,9 @@
|
|||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
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"
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
Referrer-Policy "no-referrer"
|
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
|
```bash
|
||||||
cp .env.production.example .env.production
|
cp .env.production.example .env.production
|
||||||
chmod 600 .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-ПАРОЛЬ'
|
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. Проверка и первый запуск
|
## 3. Проверка и первый запуск
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ cleanup() {
|
|||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
$compose up -d --wait db minio
|
$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 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 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
|
$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_API_PORT/ready" >/dev/null
|
||||||
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/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 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 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 waterbody')" = "2"
|
||||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0"
|
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
|
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"
|
echo "Production bootstrap passed from empty volumes"
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
|
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
|
||||||
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
|
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
|
||||||
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
|
- [ ] Проверить необходимые индексы 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] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||||
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
- [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).
|
- [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