Приложение ВИШ — расписание РУТ (МИИТ)

Расписание Российского университета транспорта для студентов и сотрудников всех институтов. Расписание своей группы и любого преподавателя на день и на неделю, свободные аудитории МИИТ прямо сейчас, загруженность аудиторий, групп и преподавателей, уведомления об изменениях в расписании и ИИ-помощник по расписанию.

Институты

Приложение РУТ (МИИТ) охватывает пять институтов: ВИШ — Высшая инженерная школа, АГА — Академия гражданской авиации, АДХ — Академия дорожного хозяйства, ПИШ — Передовая инженерная школа «Академия ВСМ» и ИМТК — Институт международных транспортных коммуникаций. Расписание группы и расписание преподавателя ищутся одинаково в любом из них.

Что умеет приложение ВИШ
Разделы
Руководство

Безопасность интеграций

Обновлено 27.10.2026

Что сделать на своей стороне, чтобы интеграция с ВИШ не стала дырой: в ней ключи, токены и данные студентов. Список сверен с тем, как устроен ВИШ на 23 октября 2026 года, и с рекомендациями OAuth (RFC 9700).

Ключи API

  • Ключ — только на сервере. Не вставляйте его в код страницы, мобильное приложение или расширение браузера: оттуда его достанет любой. Если программа работает у пользователя, ей нужен не ваш ключ, а «Вход через ВИШ».
  • Храните в секретах, а не в репозитории: переменная окружения, хранилище секретов (Lockbox, Vault, секреты CI). Ключ начинается с vish_ — по этому префиксу его легко найти поиском утечек.
  • Минимум прав. При выпуске отмечайте только то, что нужно программе. Для чтения открытых данных ключ не нужен вовсе.
  • Срок. Ставьте разумную дату окончания; у ключа, выпущенного себе, она обязательна (не дольше 730 дней).
  • Засветился — перевыпустите кнопкой «Перевыпустить» на странице «Для разработчиков»: старая строка перестаёт работать сразу.
  • Не пишите в журналы заголовки X-Api-Key и Authorization, адреса с параметрами и тела ответов с личными данными.

«Вход через ВИШ»

  • PKCE S256 — всегда, и для закрытых приложений тоже. ВИШ других вариантов и не принимает.
  • state — случайный, свой на каждый вход; сверяйте его в адресе возврата до любых других действий. Это защита от подделки входа (login CSRF).
  • nonce — случайный, сверяйте с nonce в id_token: так ID-токен нельзя подсунуть из чужого входа.
  • iss в адресе возврата должен совпадать с issuer из документа настройки — защита от путаницы между провайдерами (mix-up).
  • Точные адреса возврата. Регистрируйте ровно те адреса, на которые возвращаетесь; ВИШ сравнивает их посимвольно и не допускает *. Не делайте адрес возврата «универсальным перенаправителем».
  • Проверяйте id_token: iss, aud — ваш client_id, nonce, exp. Подпись (ES256) — по ключам из jwks_uri, если токен пришёл не прямо из ответа /oauth/token.
  • Минимум разрешений. Просите только нужные scope: человеку показывают весь список, и длинный список — повод отказать.
  • Выход — с отзывом. Когда человек выходит из вашего приложения, отзовите refresh-токен через POST /oauth/revoke.

Где хранить токены

  • Токены — не в адресе. Ни access_token, ни refresh_token не должны попадать в адрес страницы, Referer или историю браузера. ВИШ сам выдаёт их только в теле ответа и отклоняет коды и токены в адресе протокольных запросов.
  • Лучше всего — на сервере (схема backend-for-frontend): браузер держит только cookie сессии вашего сайта с флагами HttpOnly, Secure, SameSite=Lax, а токены ВИШ лежат на сервере.
  • Если только браузер — держите токены в памяти страницы, а code_verifier и state — в sessionStorage на время входа. Не кладите токены в localStorage, особенно на сайте со сторонними скриптами: любой скрипт на странице их прочитает.
  • Refresh-токен меняется при каждом обновлении. Сохраняйте новый и не используйте старый: повторное использование ВИШ считает кражей и отзывает весь вход.

Свой сайт

  • CSRF. Если ваш сайт работает на cookie, изменяющие запросы защищайте SameSite и CSRF-токеном (или проверкой Origin). Адреса ВИШ от этого не зависят: ВИШ не использует cookie, ключ и токен передаются только заголовками.
  • Открытые перенаправления. Параметр вроде ?returnTo= после входа сверяйте со списком своих адресов; не перенаправляйте на что угодно из адреса.
  • Кликджекинг. Запрещайте показывать свои страницы входа и подтверждения во фрейме: Content-Security-Policy: frame-ancestors 'none' (или X-Frame-Options: DENY). Страница согласия ВИШ сама так защищена и во фрейме не открывается.
  • XSS. Тексты из ВИШ (заметки, названия) — это данные: экранируйте их при выводе, а Markdown заметок отображайте через санитайзер.

Личные данные

Имя, группа, роль и расписание человека — персональные данные. Берите только то, что нужно, храните не дольше, чем нужно, и удаляйте, когда человек отключил ваше приложение (после этого GET /oauth/userinfo с его токенами отвечает invalid_token). Объясните людям в своей политике конфиденциальности, что вы получаете из ВИШ и зачем.

Проверка на практике

Пример создаёт state и nonce, сравнивает их за постоянное время, проверяет адрес returnTo по списку и убеждается, что ВИШ отклоняет код в адресе запроса и не даёт показать страницу согласия во фрейме:

// State and nonce, constant-time comparison, a returnTo allowlist, and two defences VISH enforces.
import { randomBytes, timingSafeEqual } from 'node:crypto'

const API = process.env.VISH_API ?? 'https://d1.vish-app.ru/api/v1'

const state = randomBytes(32).toString('base64url')
const nonce = randomBytes(32).toString('base64url')
const same = (left, right) =>
	typeof left === 'string' && left.length === right.length && timingSafeEqual(Buffer.from(left), Buffer.from(right))
if (!same(state, state) || same('forged', state) || state === nonce) throw new Error('state check is wrong')

// Only our own pages after sign-in: no open redirect through ?returnTo=.
const ORIGIN = 'https://app.example.org'
// A backslash reads as a slash to browsers, and a result starting with // is another host.
const safeReturnTo = (value) => {
	if (typeof value !== 'string' || value.includes('\\')) return '/'
	const target = new URL(value, ORIGIN)
	const path = target.pathname + target.search
	return target.origin === ORIGIN && path.startsWith('/') && !path.startsWith('//') ? path : '/'
}
const hostile = ['https://evil.example/', '//evil.example', '/.//evil.example', '/\\evil.example', '/\\\\evil.example', '\\\\evil.example']
if (safeReturnTo('/timetable?week=8') !== '/timetable?week=8' || hostile.some((value) => safeReturnTo(value) !== '/')) {
	throw new Error('returnTo check is wrong')
}
// Percent-encoded slashes stay a path of our own site.
if (safeReturnTo('%2F%2Fevil.example') !== '/%2F%2Fevil.example') throw new Error('returnTo check is wrong')

// VISH refuses a code or token in the URL of a protocol request.
const inUrl = await fetch(`${API}/oauth/token?code=leaked`, {
	method: 'POST',
	headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
	body: new URLSearchParams({ grant_type: 'authorization_code' }),
})
const refusal = await inUrl.json()
if (inUrl.status !== 400 || refusal.error !== 'invalid_request') throw new Error(`code in URL: ${inUrl.status} ${JSON.stringify(refusal)}`)

// VISH's consent page cannot be framed.
const consent = await fetch(new URL('/oauth/authorize', API))
const policy = `${consent.headers.get('content-security-policy')} ${consent.headers.get('x-frame-options')}`
if (!/frame-ancestors 'none'|DENY/u.test(policy)) throw new Error('consent page may be framed')

console.log('state/nonce, returnTo and constant-time checks pass')
console.log('code in URL ->', inUrl.status, refusal.error)
console.log('consent page framing ->', consent.headers.get('x-frame-options'))

Нашли уязвимость

Напишите нам — контакт указан в /.well-known/security.txt на сайте ВИШ. Не проверяйте находку на чужих аккаунтах и данных production: для опытов есть стенд d1.

Проверено: 2026-10, PWA aa337c45, platform bff65fce.