# Security QA AGIL Calls — стенд `https://calls.guardify.ru`

> Независимое тестирование безопасности **развёрнутого приложения** (не unit/e2e),
> версия отчёта: **v1.0 (13.11.2026)**. Исходники приложения не изменялись.
> Все мутации demo-конфига (гео, integrity, лимиты, access policy, роли,
> провайдеры, вебхуки) кратковременные и восстановлены из snapshot;
> seed-данные не удалялись.
>
> Окружение: `calls.guardify.ru` (HAProxy→Caddy→контейнер `agil-calls`,
> 95.182.94.7, Docker volume `.data`), Node 23, прямой SSH и admin API
> (`x-api-key` тенанта `demo`, ключ в отчёт не попадает), Chrome/puppeteer-core
> для скриншотов, `webhook.site` как внешний получатель вебхуков.

Артефакты: `qa/security/` — `results/*.json`, `screens/*.png`, `artifacts/*.json`,
`snapshots/*.json`; инструкция запуска — `qa/security/README.md`.

## 1. Сводка

| Показатель | Значение |
|---|---|
| Наборов проверок | 13 (12 API-наборов + browser-набор) |
| Всего проверок | **417** (403 API + 14 UI) |
| Pass | **409** |
| Fail (включая подтверждённые дефекты) | **8** |
| Подтверждённых дефектов | **11** |
| P0 | **0** |
| P1 | **2** — S-01 (обходимая гео-политика), S-02 (несовместимая/непокрывающая timestamp подпись вебхуков) |
| P2 | **4** — S-03 (перенос роли токеном между комнатами), S-04 (статические TURN-креды публично), S-05 (media-integrity вердикт от клиента), S-06 (нельзя отозвать userOverrides) |
| P3 | **5** — S-07…S-11 (см. таблицу) |
| Скриншотов UI | **16** (`qa/security/screens/`), 0 JS-ошибок, 0 failed HTTP-запросов в UI-прогоне |
| Состояние demo после прогона | восстановлено (99-final-restore 12/12), seed-комнаты/роли на месте |

Разбивка по наборам (`qa/security/results/*-latest.json`):

| Набор | Pass/Всего | Дефекты |
|---|---|---|
| 00-selftest (snapshot/restore) | 21/21 | — |
| 01-auth-idor-docs | 78/80 | S-03, S-04 |
| 02-room-security | 52/52 | — |
| 03-geo | 26/27 | S-01 |
| 03b-geo-xff | 15/15 | — |
| 04-roles-access | 47/48 | S-06 (дубль-свидетельство) |
| 05-audit-overview | 37/37 | — |
| 06-providers-webhooks | 39/40 | S-02 |
| 07-files-integrity | 36/37 | S-05, S-07 (note) |
| 08-policy-clear | 13/14 | S-06 |
| 09-browser-screens | 14/14 | 16 скриншотов |
| 10-evidence-pack | 19/20 | S-09 (CSP) |
| 99-final-restore | 12/12 | состояние восстановлено |

> 8 «failed» проверок соответствуют 11 findings: S-01…S-06 и S-09 — падающие
> проверки (S-06 подтверждён двумя наборами), а S-07/S-08/S-10/S-11 — дефекты/
> наблюдения, зафиксированные PASS-проверками с явным отклонением от ожидаемого
> уровня защиты (см. колонку «Фактически» и `notes` в JSON).

**Итог:** блокеров P0 нет; TLS/заголовки, пароли комнат/lockout/lock/kick,
waiting room, rate-limit, роли и access-policy enforcement, аудит с IP-хэшами,
SSRF-политика для providers/webhooks/config, подписанные ссылки файлов/записей,
аудит-фильтры/пагинация — работают. Критично к релизу: S-01 и S-02 (P1), затем
P2-блок (особенно S-06 — невозможность отозвать персональные права и S-03 —
роль токена не привязана к комнате).

## 2. Таблица дефектов

| ID | Sev | Зона | Шаги воспроизведения | Ожидаемо | Фактически | Скриншот | Доказательство |
|---|---|---|---|---|---|---|---|
| **S-01** | **P1** | geo/join | `PATCH security.geo = {enabled:true, allowedCountries:['DE']}`; `POST /v1/rooms/:id/join` с заголовком `x-agil-country: DE`; затем `allowedCountries:['RU']` + `x-agil-country: RU` | Страна определяется сервером/edge; клиент не может выбрать страну; allowed/blocked нельзя обойти | Один и тот же клиент проходит **обе** политики, просто меняя заголовок (`200` в обоих случаях); страна в аудите (`country`) тоже подделывается. `AGIL_GEO_PROVIDER=header` + `AGIL_TRUST_PROXY=1`, HAProxy/Caddy не вырезают `x-agil-country` | `screens/06-dashboard-security-*.png` (форма гео-политики; настройка demo) | `results/03-geo-latest.json` (GEO-03/04/10); лог запросов с заголовком |
| **S-02** | **P1** | webhooks/HMAC | `PUT /v1/admin/webhooks` на публичный receiver (webhook.site) с `secretEnv=AGIL_PUBLIC_ORIGIN`; `POST /v1/admin/webhooks/test`; получить доставку и проверить `x-agil-signature` | Подпись = `hex HMAC-SHA256(secret, ${timestamp}.${rawBody})` (docs/contract.md §19, helper `verifyWebhookSignature` из `@agil/security`), timestamp покрыт → нет replay | Подпись = `HMAC(secret, rawBody)` **без timestamp**; `verifyWebhookSignature` из `@agil/security` отвергает такую доставку; подделать/переслать тело с тем же timestamp можно бесконечно (replay) | `screens/12-dashboard-integrations-providers-*.png`, `screens/10-dashboard-access-*.png` (генерация токена — не тут, см. артефакт) | `results/06-providers-webhooks-latest.json` (HOOK-08…HOOK-11); `artifacts/webhook-delivery-evidence-latest.json` (headers+body+обе HMAC) |
| **S-03** | **P2** | JWT/join | Создать комнату A и B; выпустить host-токен для A (`POST /v1/tokens`); `POST /v1/rooms/B/join` с `body.token` из A | Токен room-scoped: чужой комнате не доверяем, роль из токена другой комнаты не применяем | Join B проходит (`200`), роль `host` и права из токена A переносятся в B. Если у участника есть host-ссылка на одну комнату, он входит организатором в любую другую комнату тенанта | `screens/10-dashboard-access-*.png` (генератор токена с roleId/permissions) | `results/01-auth-idor-docs-latest.json` (TOK-08); `results/10-*` |
| **S-04** | **P2** | TURN/ICE | `GET /v1/ice` без авторизации | Публичный ICE отдаёт только STUN и/или TURN с **ephemeral** credentials (REST time-based) | В ответе `turn:95.182.94.7:3478` со статическими `username`+`credential` (TURN_USER/TURN_PASSWORD из `.env`) → любой может использовать TURN как открытый релей (abuse/трафик/затраты), креды не ротируются | — (API-находка; UI не показывает) | `results/01-auth-idor-docs-latest.json` (SEC-06); `artifacts/10-public-ice-latest.json` (значения замаскированы) |
| **S-05** | **P2** | media integrity | Включить/выключить `security.mediaIntegrity`; с WS отправить `{type:'integrity', status:'verified', score:0}` без liveness/лица | Сервер не доверяет вердикту клиента без проверки; `verified` означает проверенную личность/liveness | При `enabled=false` сервер принимает `status=verified` как есть; при `enabled=true` «пересчитывает» только из клиентских `score`/`flags` (`score` подставляется как `frameTimingRegularity`), liveness/face — тоже клиентские флаги; внешний детектор (`AGIL_INTEGRITY_URL`) не вызывается. «verified» в admin API недостоверен; юридической силы нет | `screens/05-dashboard-room-detail-*.png` (integrity участника), `screens/06-dashboard-security-*.png` | `results/07-files-integrity-latest.json` (INT-02/INT-03/INT-04) |
| **S-06** | **P2** | access policy | `PUT /v1/admin/access-policy` с `userOverrides:{user:['chat']}` → затем `PUT {policy:{..., userOverrides:{}}}`; `GET /v1/admin/access-policy` | Пустой `userOverrides` отзывает персональные права (как `deniedFeatures:[]`) | `PUT` → `200`, но override остаётся: снять персональные права через API нельзя (только полный `PUT /v1/tenants/:id/config`). «Редактор userOverrides» в панели не сможет отозвать выдачу | `screens/10-dashboard-access-*.png` (редактор userOverrides) | `results/08-policy-clear-latest.json` (APC-02/APC-03); `results/04-roles-access-latest.json` (ACCESS-10) |
| **S-07** | **P3** | files gating | `PATCH session.fileShare.enabled=false`; загрузить файл до этого; открыть ранее выданную signed-ссылку | Выключение файлов у тенанта закрывает и скачивание (или явно документируется TTL) | Upload → `403`, но подписанная ссылка продолжает отдавать файл (`200`) до `exp`: гейт на download проверяет только `features.fileShare`, не `session.fileShare.enabled` | `screens/05-dashboard-room-detail-*.png` (вкладка записей/файлов комнаты) | `results/07-files-integrity-latest.json` (FILE-11, note) |
| **S-08** | **P3** | API errors | `POST /v1/admin/roles` с JSON-телом > 1 MB | `413` с кодом `file_too_large` неуместен; ожидается `payload_too_large`/`body_too_large` | `413`, `code=file_too_large` (клиенты не могут отличить лимит тела от лимита файла) | — | `results/01-auth-idor-docs-latest.json` (AUTH-09) |
| **S-09** | **P3** | headers/CSP | `GET /`, `/dashboard/`, `/docs` | CSP ограничивает XSS-поверхность панели (inline/script-src), особенно при хранении admin-ключа в localStorage | Заголовок `content-security-policy` отсутствует на HTML-страницах (есть HSTS/XCTO/XFO/Referrer/COOP/Permissions-Policy) | — | `results/10-evidence-pack-latest.json` (EVI-04/05); `artifacts/10-security-headers-latest.json` |
| **S-10** | **P3** | CORS | `OPTIONS /v1/admin/overview` c `Origin: https://evil.example` | Admin API не должен разрешать произвольные origin (ключ серверный, не браузерный) | `Access-Control-Allow-Origin: *` + `x-api-key` в allow-headers. Без ключа бесполезно, но при XSS/утечке ключа из localStorage панели запросы возможны с любого origin; лучше origin-allowlist для admin | — | `results/01-auth-idor-docs-latest.json` (AUTH-13); `artifacts/10-cors-preflight-latest.json` |
| **S-11** | **P3** | docs exposure | `GET /openapi.admin.json` без ключа | В production полная админ-спека закрыта (ключ/IP allowlist) | `200`, 49 путей, включая `/v1/admin/*`; при `AGIL_DOCS_ENABLED=1` это ожидаемо для demo, но для prod нужно выключить/закрыть. Публичная `/openapi.json` admin-путей не содержит (корректно) | `screens/15-openapi-admin-public-*.png`, `screens/14-api-explorer-docs-*.png` | `results/10-evidence-pack-latest.json` (EVI-02/03); `artifacts/10-docs-openapi-exposure-latest.json` |

### Мелочи/наблюдения (не баги или осознанные ограничения)

- **Позитив:** пароль комнаты — только scrypt-хэш (в ответах/аудите нет ни пароля, ни хэша); битый/чужой/усечённый хэш → `false`; lockout по числу ошибок с `retry-after` и истечением; admin-lock `423`; kick → WS `bye reason=kicked` + закрытие `1008`; waiting room пускает гостя после подключения хоста; capacity `409 room_full`; join rate-limit `10/мин` (ключ IP+room), не обходится `X-Forwarded-For` (edge перезаписывает заголовок: ipHash стабилен); роли — reserved/duplicate/лимит 30/`assigned`, roleId+roleName в welcome, откат на baseRole после удаления роли; access policy — матрица/`deniedFeatures`/`userOverrides`/`strict`/порядок precedence, enforcement на сервере (`403 feature_disabled`); аудит — фильтры/пагинация/`total`, `ipHash` только 32 hex, нет сырых IP/паролей/секретов, tenant-изоляция; security overview — счётчики и страны; providers/webhooks — 9 SSRF-векторов отклонены (http/loopback/RFC1918/link-local/metadata/ftp/credentials), секреты только именами env, нет утечки секрета в ответ; вебхуки — delivered/statusCode, failed с ошибкой, реальное событие `room.created` доставлено; файлы/записи — подписанные ссылки (tamper/exp/traversal → 403/404), MIME allow-list и блок активного контента, лимиты, `features.fileShare` гейтит upload **и** download; GPS — только по consent, координаты server-side (`/v1/admin/rooms/:id/locations`), в broadcast не уходят; токен-подпись/alg:none/expired (с учётом 30s clock-skew) отклоняются; docs/static — нет `.env`, SDK/панель без ключа, markdown-доки доступны.
- **Наблюдение:** истёкший на 5s токен ещё принимается — это `clockToleranceSec=30` в `verifyToken` (осознанный skew); при +95s — `401` (проверено).
- **Наблюдение:** lockout отвечает `429 rate_limited` (а не отдельным кодом `lockout`) — контракт §14 допускает `rate_limited`; аудит пишет отдельное событие `lockout`, UI различает по тексту/`retry-after`.
- **Наблюдение:** `x-agil-country` при `AGIL_GEO_PROVIDER=header` не даёт VPN-флага — `blockVpn` в demo фактически не работает (ограничение header-провайдера; для VPN нужен `ipapi`/внешний lookup). Задокументировано в `docs/security.md`.
- **Документация:** `docs/security-audit.md` и `docs/security.md` описывают модель угроз/меры; после QA стоит добавить в `docs/api.md` примечание, что вердикт `integrity` — client-reported (S-05).

## 3. Что проверено (матрица потоков)

| Набор → поток | Кол-во проверок | Итог |
|---|---|---|
| 00-selftest: snapshot/restore demo state, PATCH config, temp role | 21 | PASS |
| 01-auth-idor-docs: admin auth (401/403, malformed/oversized body, CORS), cross-tenant, IDOR комнат/файлов/записей, JWT (tamper/alg:none/expiry, WS), docs/static/secrets, headers | 80 | 2 дефекта (S-03, S-04) |
| 02-room-security: пароль (нет/неверный/верный), lockout+истечение, lock/unlock, kick+WS 1008, waiting room, capacity, join rate-limit, invites | 52 | PASS |
| 03-geo: allowed/blocked/unknown/регистр, header provider, спуфинг страны, blockedCountries, audit | 27 | S-01 |
| 03b-geo-xff: VPN-ограничение header-провайдера, X-Forwarded-For spoofing (lockout + rate-limit) | 15 | PASS (edge перезаписывает XFF) |
| 04-roles-access: список/создание/дубль/reserved/id/feature-валидация/лимит 30, token roleId→roleName, baseRole-откат, pinned permissions, userOverrides precedence, deniedFeatures, strict, effective matrix, enforcement | 48 | S-06 |
| 05-audit-overview: пагинация, фильтры type/severity/roomId, валидация, приватность (ipHash 32 hex, нет сырых IP/паролей), security overview и счётчики | 37 | PASS |
| 06-providers-webhooks: providers SSRF (9 векторов)/validate/test, webhooks SSRF, secretEnv, реальная HMAC-доставка на webhook.site, события room.created, retry/failed, config-PATCH SSRF | 40 | S-02 |
| 07-files-integrity: upload/download/signed URL (tamper/exp/traversal), MIME allow-list/active content, лимиты, features.fileShare upload+download, recordings, media integrity (client verdict/flags/auto-mute), GPS consent + server-side coords | 37 | S-05, S-07 |
| 08-policy-clear: отзыв userOverrides через access-policy | 14 | S-06 |
| 09-browser-screens: 16 UI-шагов (логин, обзор, комнаты, модалка создания, карточка комнаты с integrity, безопасность, аудит+фильтр+экспорт, доступы+модалка роли, интеграции/провайдеры, настройки, /docs, /openapi.admin.json, demo) | 14 | PASS |
| 10-evidence-pack: ICE (redacted), OpenAPI exposure, security headers, CORS, статистика аудита, overview, конфиг, webhook-лог | 20 | S-09 |
| 99-final-restore: сравнение с baseline, отсутствие наших временных комнат, скан артефактов на ключ | 12 | PASS (state == baseline) |

## 4. P0/P1 — срочно

**P0 — нет.** P1 — два:

1. **S-01 (гео-политика обходится заголовком `x-agil-country`).** Пока стенд использует
   `AGIL_GEO_PROVIDER=header` при `AGIL_TRUST_PROXY=1`, любой клиент назначает себе
   страну → `allowedCountries/blockedCountries` не защищают. Исправление: доверять
   только заголовку, который перезаписывает edge (Cloudflare `CF-IPCountry`,
   Vercel), либо перейти на `AGIL_GEO_PROVIDER=ipapi`; на HAProxy/Caddy **удалять**
   входящий `x-agil-country` (unset header) для внешних запросов; в аудите помечать
   `source` и не использовать client-reported страну как основание для блокировки.
2. **S-02 (подпись вебхуков несовместима с контрактом и не защищает от replay).**
   Подпись должна считаться по `${timestamp}.${rawBody}` (helper
   `@agil/security` и §19). Исправление: в `packages/server/src/webhooks.ts`
   подписывать `timestamp + "." + body` и валидировать окно timestamp на
   получателе; после фикса — пересобрать/передеплоить контейнер и добавить
   интеграционный тест «доставка проходит `verifyWebhookSignature`».

## 5. Рекомендации (порядок фикса)

1. S-01/S-02 — до любого прод-использования гео- и вебхук-функций (P1).
2. S-06 — заменить merge на replace для `userOverrides` (или `DELETE
   /v1/admin/access-policy/overrides/:userId`) — иначе нельзя отозвать
   персональные права (P2, при инциденте/увольнении это блокер).
3. S-03 — при join не использовать role/claims из токена другой комнаты
   (только `authorized` токен, иначе новый токен с ролью `guest`).
4. S-04 — TURN ephemeral credentials (REST time-based), ротация; закрыть
   публичный статический креденшл.
5. S-05 — не считать `integrity=verified` подтверждением личности: либо
   подписывать вердикт внешним детектором/liveness с серверной проверкой, либо
   переименовать в UI в «эвристика»; добавить в API `source: 'client'`.
6. S-07 — гейтить download по `session.fileShare.enabled` (или
   документировать окно TTL); S-09 — CSP на панель/docs (`frame-ancestors`
   из `integrations.allowedOrigins`); S-10/S-11 — origin-allowlist для admin
   и закрыть admin-спеку в prod; S-08 — код `payload_too_large`.

## 6. Ограничения методики

- Тестирование на **demo-стенде** (общий с другими QA-агентами), кратковременные
  мутации конфига; пересечения с другими прогонами могли влиять на отдельные
  счётчики аудита. Конфиг/access policy/роли/providers/webhooks восстановлены
  (см. `results/99-final-restore-latest.json`), seed-данные не удалялись.
- Реальный anti-deepfake/liveness ML, E2EE, TURN-only медиа-приватность,
  DNS-rebinding для SSRF, нагрузочные/DoS-тесты и внешний пентест **не
  проводились** (см. `docs/security-audit.md` — это отдельный scope).
- SSRF проверен по литералам URL (http/loopback/RFC1918/link-local/metadata,
  ftp, credentials); DNS-имена, резолвящиеся в private IP, требуют egress-политики.
- Для проверки HMAC вебхука использовался внешний receiver `webhook.site`;
  секретом был `AGIL_PUBLIC_ORIGIN` (публичное значение) — чтобы проверить
  алгоритм, не раскрывая боевых секретов; endpoint после теста удалён.
- Скриншоты — headless Chrome, десктоп 1440×900; мобильные/тёмная тема — вне scope
  этого прогона (см. `docs/qa-visual.md`).

## 7. Как воспроизвести

```bash
export AGIL_ADMIN_KEY=$(ssh root@95.182.94.7 "grep -oP '(?<=ADMIN_KEY=).*' /root/agil-admin-key.txt")
export BASE_URL=https://calls.guardify.ru
cd qa/security
node 00-selftest.mjs && node 01-auth.mjs && node 02-room-security.mjs && \
node 03-geo.mjs && node 03b-geo-xff.mjs && node 04-roles-access.mjs && \
node 05-audit-overview.mjs && node 06-providers-webhooks.mjs && \
node 07-files-integrity.mjs && node 08-policy-clear.mjs && \
node 09-browser-screens.mjs && node 10-evidence-pack.mjs && node 99-final-restore.mjs
```

## 8. Артефакты

| Путь | Что |
|---|---|
| `qa/security/results/*-latest.json` | проверки по наборам (+ истории прогонов) |
| `qa/security/screens/*.png` | 16 шагов UI: логин, обзор, комнаты, модалка комнаты, карточка комнаты (integrity), безопасность, аудит/фильтр/экспорт, доступы/роли, интеграции/провайдеры, настройки, /docs, /openapi.admin.json, demo |
| `qa/security/artifacts/` | `10-public-ice`, `10-docs-openapi-exposure`, `10-security-headers`, `10-cors-preflight`, `10-audit-stats`, `10-security-overview`, `10-config-security`, `webhook-delivery-evidence`, `09-browser-screens` (консоль/ошибки сети), `99-final-state` |
| `qa/security/snapshots/` | demo config/policy/roles/providers/webhooks до и после мутаций |
| `qa/security/README.md` | инструкция запуска |

**Подтверждение восстановления состояния:** `results/99-final-restore-latest.json`
(сравнение с baseline snapshot: config.security/integrations/providers/features/session,
access policy, custom roles, webhooks; скан артефактов на admin-ключ).

---

## 7. Серверные исправления по findings (backend)

| ID | Severity | Статус | Что изменено в `packages/server` | Проверка |
|---|---|---|---|---|
| S-01 | P1 | ✅ fixed | `x-agil-country` игнорируется без `AGIL_GEO_HEADER_ALLOW=1`; страна берётся из ipapi/edge. Демо/audit-стенд включает флаг явно | `test/security-regression.test.ts` (spoof → 403, opt-in → 200) |
| S-02 | P1 | ✅ fixed | Подпись вебхуков = hex HMAC-SHA256(`${x-agil-timestamp}.${rawBody}`), заголовки timestamp/signature; helper `verifyWebhookSignature` из `@agil/security` | `test/integrations.test.ts` (HMAC с timestamp, ±300 s reject) |
| S-03 | P2 | ✅ fixed | Join-токен комнаты A не доверяется в комнате B: чужие claims игнорируются, новый токен — `guest` | `test/security-regression.test.ts` |
| S-06 | P2 | ✅ fixed | `PUT /v1/admin/access-policy` поддерживает `clearUserOverrides:[userId]` и `policy.userOverrides:{}` (реальная замена, не deep-merge) | `test/security-regression.test.ts` |
| S-08 | P3 | ✅ fixed | JSON больше 1 MiB → `413 payload_too_large`; файлы сохраняют `413 file_too_large` | `test/security-regression.test.ts` |
| S-10 | P3 | ✅ fixed | Admin CORS никогда не отдаёт `*`; только origin из `integrations.allowedOrigins`, иначе ACAO отсутствует | `test/security-regression.test.ts` |

Evidence: `qa/security/artifacts/webhook-delivery-evidence-latest.json` (S-02 —
timestamp+signature), прогоны: `npm test -w @agil/calls-server` 86/86,
`node scripts/security-audit.mjs` 32/32, `node scripts/e2e.mjs --only=api` 11/11.
