ДЭГ-2026 · федеральная платформаОбзорРегионыАномалииМосква: проверка YouTubeМетод и данные

Московское электронное голосование проверяет, открывается ли у избирателя YouTube

Сайт голосования elec.mos.ru перед входом заставляет браузер избирателя загрузить картинку с YouTube. В России YouTube заблокирован. Если картинка загрузилась, у избирателя работает VPN. Результат браузер отправляет на сервер голосования. Через секунду избиратель входит через mos.ru под своим именем.

Как это работает

  1. Браузер открывает страницу входа и получает от сервера список адресов для проверки. Пять адресов — сервисы самого голосования, они должны отвечать. Шестой — https://i.ytimg.com/vi/mqdefault.jpg, картинка с YouTube. Он должен не отвечать.
  2. Браузер запрашивает все шесть адресов. К каждому запросу добавлен один и тот же случайный идентификатор проверки.
  3. Через пять секунд браузер отправляет серверу отчёт: идентификатор проверки и результат по каждому адресу. У избирателя без VPN в отчёте стоит youtube=connection timeout. У избирателя с VPN — код ответа YouTube, любой, включая 404.
  4. Если сервисы голосования доступны, браузер переходит на вход через mos.ru. Сервер получает фамилию, имя и отчество избирателя.

Отчёт отправляется при каждом входе. Он уходит с того же IP-адреса и в той же сессии, что и вход через mos.ru: сессионный cookie сайт ставит ещё до проверки. В записи реальной сессии 18 сентября 2026 года между отчётом и входом прошло 0,2 секунды.

Результат проверки не мешает голосовать. Ошибку «подключение блокирует VPN» сайт показывает, только когда не отвечают сервисы голосования, то есть когда VPN выводит за границу, откуда mos.ru недоступен. Российский VPN проверку проходит.

Что это значит

Оператор голосования получает по каждому избирателю ответ на вопрос «пользуется ли он VPN» и в ту же секунду узнаёт, кто этот избиратель. Для этого не нужно ничего взламывать: два запроса лежат рядом в одном журнале. Зачем нужен отчёт и как долго он хранится, из кода сайта не видно.

Связать признак VPN с содержанием голоса сложнее. Между входом по имени и бюллетенем стоит анонимайзер, который должен разрывать связь. Доказательств, что связь сохраняется, у нас нет. Но все места, где она существует, находятся у оператора: он выдаёт анонимный идентификатор бюллетеня, он присваивает «адрес транзакции», и в блокчейн попадает не тот шифртекст, который создал браузер избирателя, а перепакованный сервером. Проверить снаружи, что связь разорвана, нельзя.

Для технарей: как это реализовано

Где лежит код

Фронтенд шлюза — приложение gatekeeper версии 1.6.6 (React, сборка rolldown). Бандл gatekeeper/assets/index-DyWqi8Ek.js (468 КБ) отдаётся с cdn-deg.mos.ru/static/gatekeeper/; его подключает страница elec.mos.ru/gatekeeper. Копия бандла от 18.09.2026 сохранена.

Последовательность запросов

  1. POST /api/elect-gatekeeper-service/hello, тело {}. Ответ: {bitsComplexity: 15, task: <JWT>, votingStatus: "IN_PROGRESS", startDateTime, endDateTime}. Заголовок x-send-stats. Сервер ставит два cookie: session-cookie (Max-Age 86400, Path=/) и X-Check-Browser (HttpOnly, SameSite=Strict, Path=/api/elect-gatekeeper-service/check).
  2. Клиент решает proof-of-work: hashcash v1 на SHA-1, 15 ведущих нулевых бит. Штамп 1:15:YYYYMMDDHHMMSS:<task>::<rand base64 8 байт>:<base64(counter)>. task — JWT HS256 с exp = iat + 30 с, то есть штамп надо успеть за 30 секунд; на практике это 0,3–1 с. Реализация — модуль mint с CryptoJS SHA-1 и Long для счётчика.
  3. POST /api/elect-gatekeeper-service/check, тело {solution: <штамп>}, с cookie X-Check-Browser. Ответ: sudirUrl (OAuth-ссылка на login.mos.ru/sps/oauth/ae, client_id=elec.mos.ru, redirect_uri=…/sudir/catch, scope=openid+profile, state) и checkConnectionConfig.
  4. Функция проверки сети (в бандле — gc): генерирует guid = crypto.randomUUID(), для каждого элемента mustAvailable и shouldUnavailable вызывает fetch(url + "?guid=" + guid, {credentials: "omit", method: "GET", signal}) с таймаутом connectionTimeoutInMs (5000 мс, через AbortController). Доступность: status < 500. Для mustAvailable — до maxRetries (2) повторов; для shouldUnavailable — без повторов. Ошибка сети записывается как текст: "connection timeout" при аборте, иначе e.message или "connection error".
  5. Отчёт (функция hc): GET reportUrl?guid=…&<systemName>=<reportValue> для всех хостов, тоже credentials: "omit", тем же таймаутом, ошибки отправки игнорируются. Отправляется всегда, до любого ветвления.
  6. Ветвление: if (!mustAvailable) navigate("/gatekeeper/not-available/" + (vpnDetected ? 1010 : 1011)); else location.href = sudirUrl. Тексты: 1010 — «Возможно, подключение блокирует VPN. Пожалуйста, отключите VPN и откройте страницу заново», 1011 — то же плюс «или проверьте стабильность интернет-соединения».
  7. После SUDIR: GET /sudir/catch?code=… → 302 на /gatekeeper/queue?ticketId=…POST /wait {ticketId}. Ответ содержит registrationUrl, registrationToken и profile {lastName, firstName, middleName}. Дальше registration (SMS-код), get_ballot → anonUrl, анонимайзер, ballot/decrypt, ballot/vote.

Что может связать сервер

Никаких скрытых каналов не требуется: достаточно журнала запросов nginx на elec.mos.ru.

Как проверить самому

  1. Открыть https://elec.mos.ru/gatekeeper с российского адреса, в DevTools вкладка Network, фильтр check_connection.
  2. Увидеть шесть GET-запросов с одним guid и через 5 секунд GET на check_connection/report с параметром youtube=….
  3. Скачать бандл с cdn-deg.mos.ru/static/gatekeeper/assets/index-DyWqi8Ek.js и найти строки shouldUnavailable, vpnDetected, reportUrl.

Код проверки (сокращённая расшифровка бандла)

dc = (url, timeout) => { const c = new AbortController(); setTimeout(() => c.abort(), timeout);
                        return fetch(url, {credentials: "omit", method: "GET", signal: c.signal}) }
fc = (url, guid)  => { const u = new URL(url, location.href); u.searchParams.set("guid", guid); return u.toString() }
pc = (url, t)     => dc(url, t).then(r => ({available: r.status < 500, reportValue: String(r.status)}))
                               .catch(e => ({available: false, reportValue: lc(e)}))   // "connection timeout" | message
mc = async (host, guid, t, retries) => { for (i = 0; i <= retries; i++) { r = await pc(fc(host.url, guid), t); if (r.available) break }
                                        return {...r, systemName: host.systemName} }
hc = async (cfg, guid, results, t) => { u = new URL(cfg.reportUrl, location.href); u.searchParams.set("guid", guid);
                                        results.forEach(r => u.searchParams.set(r.systemName, r.reportValue)); await dc(u, t).catch(() => {}) }
gc = async cfg => { guid = crypto.randomUUID();
  [must, should] = await Promise.all([ Promise.all(cfg.mustAvailable.map(h => mc(h, guid, 5000, 2))),
                                       Promise.all(cfg.shouldUnavailable.map(h => mc(h, guid, 5000, 0))) ]);
  await hc(cfg, guid, [...must, ...should], 5000);
  return {mustAvailable: must.every(r => r.available), vpnDetected: should.some(r => r.available)} }

sendCheck(hello).then(async ({checkConnectionConfig, sudirUrl}) => {
  if (checkConnectionConfig) { r = await gc(checkConnectionConfig);
    if (!r.mustAvailable) return navigate("/gatekeeper/not-available/" + (r.vpnDetected ? 1010 : 1011)) }
  location.href = sudirUrl })

Ответ сервера на /check, 18.09.2026

"checkConnectionConfig": {
  "mustAvailable": [
    {"url": "https://elec.mos.ru/registration/check_connection/check",              "systemName": "elect-registration-ui"},
    {"url": "https://elec.mos.ru/api/elect-registration-service/check_connection/check", "systemName": "elect-registration-service"},
    {"url": "https://elec.mos.ru/api/elect-anonymizer-service/check_connection/check",   "systemName": "elect-anonymizer-service"},
    {"url": "https://elec.moscow/ballot/check_connection/check",                     "systemName": "elect-ballot-ui"},
    {"url": "https://elec.moscow/api/elect-ballot-service/check_connection/check",   "systemName": "elect-ballot-service"}
  ],
  "shouldUnavailable": [
    {"url": "https://i.ytimg.com/vi/mqdefault.jpg", "systemName": "youtube"}
  ],
  "reportUrl": "/api/elect-gatekeeper-service/check_connection/report",
  "connectionTimeoutInMs": 5000,
  "maxRetries": 2
}

Запись сессии голосования, 18.09.2026, Москва (HAR)

Идентификаторы и персональные данные сокращены.

14:25:47.974  POST elec.mos.ru/api/elect-gatekeeper-service/hello      → 200, Set-Cookie: session-cookie (24 ч), X-Check-Browser
14:25:48.260  POST …/check  {solution: "1:15:20260918112548:<jwt>::zZGLtTxrlaQ=:NTExNA=="}   → sudirUrl + checkConnectionConfig

14:25:48.326  GET elec.mos.ru/registration/check_connection/check?guid=e8a8…        → 200  (15 мс)
14:25:48.328  GET elec.mos.ru/api/elect-registration-service/check_connection/…      → 200  (15 мс)
14:25:48.329  GET elec.mos.ru/api/elect-anonymizer-service/check_connection/…        → 200  (15 мс)
14:25:48.330  GET elec.moscow/ballot/check_connection/check?guid=e8a8…               → 200  (52 мс)
14:25:48.330  GET elec.moscow/api/elect-ballot-service/check_connection/…           → 200  (30 мс)
14:25:48.331  GET i.ytimg.com/vi/mqdefault.jpg?guid=e8a8…                            → нет ответа

14:25:53.336  GET elec.mos.ru/api/elect-gatekeeper-service/check_connection/report
                  ?guid=e8a8…&elect-registration-ui=200&elect-registration-service=200
                  &elect-anonymizer-service=200&elect-ballot-ui=200&elect-ballot-service=200
                  &youtube=connection timeout
14:25:53.501  GET …/sudir/catch?code=…                                               → 302 → /gatekeeper/queue?ticketId=…
14:25:53.899  POST …/wait {ticketId}                                                 → registrationToken, profile {lastName, firstName, middleName}