Блог
Ссылка от агента должна проверять, кто её открыл
Агент присылает человеку ссылку для подключения аккаунта. Если ссылка сработает у кого угодно, пересланная ссылка свяжет не тот аккаунт не с тем агентом.
Автор: FoxAuth ·
ИИ-агенту Repo Helper нужно читать GitHub-репозитории одного человека. Доступа у агента пока нет, поэтому он пишет в чат: «Откройте эту ссылку, чтобы подключить свой аккаунт GitHub». Человек нажимает на ссылку. Он входит в GitHub и подтверждает доступ. Потом возвращается в чат. Repo Helper пробует ещё раз и на этот раз получает токен для GitHub.
Теперь добавим в эту историю второго человека. Мэллори просит свою копию Repo Helper о том же самом и получает такую же ссылку. Сама она ссылку не открывает. Она пересылает её коллеге Алисе с запиской: «Подключишь это для команды?» Алиса открывает ссылку. В GitHub она уже вошла, так что подтверждение занимает два клика. Но ссылка была сделана для Мэллори. Поэтому аккаунт GitHub Алисы теперь подключён к агенту Мэллори, и агент Мэллори может читать закрытые репозитории Алисы.
В обычном смысле в этой истории ничего не сломалось. Каждый редирект вёл туда, куда должен. Каждый токен был настоящим. GitHub видел настоящий вход настоящей Алисы. Не хватает одного шага: вопроса, который никто не задал, когда открывали ссылку. Сидит ли в этом браузере тот человек, для которого сделана ссылка?
Об этом вопросе и пойдёт речь. Я пишу сервер авторизации FoxAuth. С версии 1.4.0 он выдаёт агентам такие ссылки. Прежде чем их делать, я посмотрел, как такую же ссылку устроили пять других продуктов. Только у одного из них в документации описана проверка того, кто открывает ссылку. Многие агенты обращаются к инструментам через протокол MCP (Model Context Protocol). Спецификация MCP требует такую проверку и описывает ровно эту атаку.
Ссылка для подключения связывает две учётные записи, и вторую даёт тот, кто её открыл
У ссылки для подключения одна задача: связать две учётные записи. Первая принадлежит человеку в вашей собственной системе, то есть пользователю, на которого работает агент. Вторая принадлежит тому же человеку в другом сервисе, например в GitHub.
Ссылка создаётся, когда её просит агент. В этот момент сервер знает первую учётную запись, потому что в запросе агента назван человек. Вторую учётную запись сервер знать ещё не может. Она появится позже, из браузера, в котором откроют ссылку. Её даст тот, кто войдёт в GitHub в этом браузере.
Поэтому если ссылка не проверяет, кто её открыл, то что с чем связать, решает открывший. Ошибиться здесь можно в двух направлениях. История из начала статьи показана на правой половине схемы ниже, только на месте Алисы стоите вы.
Слева ваша ссылка попала к другому человеку. Может быть, вы вставили её в общий канал, переслали по ошибке или оставили в истории чата. Тот, кто её откроет, подключит к вашему агенту свой аккаунт GitHub. С этого момента ваш агент пишет в репозитории этого человека. Агент может скопировать туда ваши закрытые данные, потому что считает этот аккаунт вашим.
Справа чужая ссылка попала к вам. Вы подключаете свой аккаунт к чужому агенту. Именно этим направлением пользуется фишинг, потому что атакующий сам решает, когда отправить ссылку и что написать в сообщении рядом с ней.
Оба случая вырастают из одной ошибки. Ссылка понадеялась, что её откроет нужный человек.
Короткий срок жизни и одноразовость не спасают от пересланной ссылки
Обычно такие ссылки защищают так: дают им короткий срок жизни, разрешают воспользоваться ими только один раз или делают и то и другое. Вот что написано в документации каждого продукта на ноябрь 2026 года:
| Продукт | Сколько живёт ссылка | Описанная в документации проверка того, кто её открыл |
|---|---|---|
| Composio | 10 минут, чтобы завершить подключение | Нет; разработчика предупреждают, что сам по себе callback не доказывает, кому принадлежит аккаунт |
| Nango | 30 минут; «её можно использовать как волшебную ссылку» (can be used as a magic link) | Нет |
| Pipedream Connect | 4 часа; документация предлагает отправлять ссылку по email или SMS | Нет |
| Auth0 Token Vault | Сессия на 5 минут | Агент вообще не получает ссылку; в конце проверяются та же сессия и то же устройство |
| Arcade | Не описано | Да: прежде чем сохранить аккаунт, разработчик подтверждает пользователя |
В таблице записано только то, что сказано в документации. Продукт может проверять больше, чем описано. Но разработчик, который строит на этом продукте свою систему, рассчитывать на это не может.
Ни одна из этих защит не останавливает атаку из начала статьи. Мэллори сама выбирает, когда отправить ссылку, поэтому может запросить свежую ссылку за минуту до отправки. Одноразовая ссылка тоже не помогает. Алиса первая и единственная, кто ею пользуется. Ссылка работает ровно так, как задумано. Просто ею пользуется не тот человек.
Auth0 обходит эту проблему по-другому. В схеме Auth0 агент вообще не видит ссылку. Ссылку запрашивает приложение, в котором работает человек, и запрашивает её с токеном доступа самого человека. Потом приложение отправляет человека по этой ссылке. Когда человек возвращается, приложение завершает подключение. В этот момент Auth0 проверяет, что запрос относится к той же сессии, в которой человек вошёл, и пришёл с того же устройства.
Такая схема работает, когда агент живёт внутри одного веб-приложения с одной сессией в браузере. Для агента в чат-клиенте, в терминале или в хосте MCP она не работает. Хостом MCP называют программу (например, чат-приложение на компьютере), которая подключает агента к его инструментам. В таких случаях до человека можно добраться только одним способом: показать ему адрес.
Проверяйте человека в тот момент, когда он открывает ссылку
Спецификация MCP говорит об этом прямо. MCP-сервер может попросить программу, с которой работает человек, показать ему адрес. Спецификация называет это URL elicitation, то есть запросом к пользователю через URL. Раздел спецификации о безопасности почти по шагам пересказывает историю из начала статьи. Атакующий получает ссылку и обманом заставляет другого пользователя её открыть. В итоге токены жертвы оказываются привязаны к учётной записи атакующего. Отсюда правило: сервер ОБЯЗАН (MUST) проверить личность пользователя, который открывает URL, прежде чем принимать от него информацию. Пример в спецификации не отправляет человека сразу в другой сервис. Он отправляет человека на страницу самого MCP-сервера. Эта страница сравнивает пользователя, который вошёл на ней, с пользователем, от которого пришёл запрос.
FoxAuth работает так же. У Repo Helper есть токен доступа, который FoxAuth выдал для одного человека. С этим токеном агент просит токен GitHub того же человека. Если человек не подключил GitHub или не разрешил этому агенту им пользоваться, FoxAuth отказывает. В отказе лежит ссылка, сделанная для этого человека.
Ссылка ведёт на страницу в проекте этого человека. Проектом в FoxAuth называется набор людей и приложений, в которые они входят, со своей страницей входа. Когда ссылку открывают, сервер сначала проходит короткий список проверок и только потом что-то показывает:
- Код в конце ссылки выдан этим сервером, и ссылка ещё действует? Неизвестный код, просроченный код, код, от которого человек отказался, и код, который заменила более новая ссылка, получают одну и ту же страницу 404. Поэтому тот, кто подбирает коды, ничего не узнаёт о том, какие ссылки существуют.
- В проекте кто-нибудь вошёл? Если нет, человек сначала входит, а потом возвращается к ссылке.
- Вошёл тот самый человек, для которого сделана ссылка? Если нет, страница сообщает, что ссылка сделана для другого аккаунта, и ничего не меняется. Ещё сервер отправляет событие об этом отказе, и оператор может на него подписаться.
- Этот человек входил за последние пятнадцать минут? Если нет, он входит ещё раз. Браузера, в котором кто-то остался залогинен на общем компьютере, недостаточно, чтобы подключить аккаунт.
- Только теперь страница предлагает то, ради чего сделана ссылка. Вариантов три: подключить GitHub и разрешить этому агенту им пользоваться, разрешить этому агенту пользоваться уже подключённым аккаунтом GitHub или переподключить аккаунт, у которого перестал работать доступ к GitHub.
Прогоним историю из начала статьи через эти проверки. Алиса открывает ссылку Мэллори. Алиса вошла, но она не Мэллори, поэтому её останавливает проверка 3, и ничего не подключается. Теперь допустим, что к Мэллори попала ссылка Алисы. Мэллори остановит проверка 2 или проверка 3, потому что войти как Алиса она не может.
Сам отказ представляет собой обычную ошибку OAuth от эндпоинта токенов. Эндпоинтом токенов называют адрес, по которому приложения просят у FoxAuth токены. Repo Helper просит токен GitHub этого человека через обмен токенов (token exchange, RFC 8693). Агент отправляет токен доступа человека и называет GitHub аудиторией (audience), то есть получателем будущего токена. Если человек ещё не подключил GitHub, обмен в сокращённом виде выглядит так:
POST /token HTTP/1.1Authorization: Basic …Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=…the person's access token…&subject_token_type=urn:ietf:params:oauth:token-type:access_token&requested_token_type=urn:ietf:params:oauth:token-type:access_token&audience=…GitHub's identifier in this project…HTTP/1.1 400 Bad RequestContent-Type: application/jsonCache-Control: no-store
{ "error": "invalid_grant", "error_description": "the person has not connected a GitHub account", "reason": "connect_required", "connect_uri": "https://auth.example.com/account/services/link/q3Jx…", "connect_id": "c6m2…", "connect_expires_in": 1800, "connect_message": "Open this link to connect your GitHub account and let Repo Helper use it."}connect_expires_in указан в секундах: 1800 секунд дают те самые тридцать минут жизни ссылки, о
которых речь пойдёт ниже.
Часто токен GitHub просит не сам агент, а MCP-сервер, который агент вызывает. Этот MCP-сервер передаёт
ссылку дальше без изменений. Клиенту на версии MCP 2025-11-25 он возвращает ошибку вызова инструмента
с кодом -32042. Этот код означает: покажи человеку эти адреса и попробуй снова. connect_id при этом
становится elicitationId:
{ "code": -32042, "message": "Open this link to connect your GitHub account and let Repo Helper use it.", "data": { "elicitations": [ { "mode": "url", "elicitationId": "c6m2…", "url": "https://auth.example.com/account/services/link/q3Jx…", "message": "Open this link to connect your GitHub account and let Repo Helper use it." } ] }}Ссылка никого не впускает, поэтому ею может воспользоваться только тот, для кого она сделана
Код в конце ссылки состоит из 32 случайных байт. Вариантов так много, что угадать код невозможно. Код только указывает на запись на сервере. В записи сказано, для какого проекта, какого человека, какого агента и какого сервиса сделана ссылка. Ещё там сказано, о чём ссылка просит: подключить, разрешить или переподключить. Сам код никого не впускает и ничего не говорит о человеке.
Спецификация MCP запрещает класть в ссылку учётные данные. Адрес, который показывают через URL elicitation, не должен содержать учётных данных. И он не должен быть «заранее аутентифицированным» (pre-authenticated): само открытие адреса не должно впускать кого-либо под именем этого человека.
Проверка 3 возможна именно потому, что код только указывает на запись. Заранее аутентифицированная ссылка несла бы внутри себя сессию, в которую вошла Мэллори. Тогда любой, кто откроет такую ссылку, действовал бы как Мэллори. Простой код заставляет страницу спросить у браузера, кто перед ней. Ответ берётся из входа, который этот человек выполнил сам.
Простой код меняет и цену утечки. Допустим, ссылку вставили в публичный канал. Посторонний, который её откроет, упрётся во вход, который не сможет пройти, и дальше не продвинется. Ссылка всё равно истекает через тридцать минут, потому что запрос, который никто не завершил, не должен ждать вечно. Этот срок нужен для порядка на сервере. Человека защищает не он.
Сервер хранит одну ссылку на каждое сочетание человека, агента и сервиса. Если агент просит ссылку снова, пока она действует, он получает ту же самую ссылку. Поэтому агент, который повторяет запрос, не засыпает чат человека несколькими рабочими ссылками. Ссылка заменяется, только когда меняется то, о чём она просит, например «подключить» сменилось на «разрешить». Тогда старый код ведёт на страницу 404.
Результат агент узнаёт, когда спрашивает ещё раз
Когда человек закончил, страница предлагает ему вернуться к агенту. Никуда дальше она его не перенаправляет. Ссылка, которая отправляет браузер по адресу, выбранному агентом, дала бы фишингу ещё одну лазейку.
Агент узнаёт результат, когда повторяет запрос токена. Если человек подключил GitHub и разрешил агенту им пользоваться, следующий запрос вернёт токен. До тех пор запрос возвращает тот же отказ и ту же ссылку.
MCP пошёл в ту же сторону. В версии 2026-07-28 убрали уведомление, которое сообщало клиенту, что запрос через URL завершён. Теперь «клиент узнаёт результат взаимодействия вне основного канала, повторяя исходный запрос». Поэтому агенту не нужен адрес, по которому сервер сообщит ему о результате, и не нужен отдельный эндпоинт для проверки статуса.
Когда эта проверка нужна, а когда можно положиться на другое
Проверка нужна всегда, когда ссылка уходит из того места, где вы уже знаете, кто перед вами. Так бывает в чат-клиентах, в агентах в терминале и в хостах MCP. Так бывает в любом случае, когда ссылку можно скопировать, переслать или открыть на другом устройстве. Во всех этих случаях до человека доходит только адрес. Поэтому страница за этим адресом должна сама выяснить, кто пришёл.
Положиться на другое можно, когда агент работает внутри вашего собственного веб-приложения, а ссылка никогда не покидает сессию в браузере, в которой её создали. Тогда сессия уже говорит вам, кто этот человек, как в схеме Auth0. Но и в этом случае проверьте в конце, что вернулся тот же пользователь.
Короткий срок жизни ссылки оставляйте в обоих случаях. Он убирает запросы, которые никто не завершил. Просто от постороннего человека он не защищает.
У проверки есть и предел. Она доказывает, кто этот человек в FoxAuth. Но она не доказывает, какой аккаунт GitHub этот человек выберет. Допустим, браузер человека всё ещё залогинен в чужой GitHub, например на общем компьютере. Тогда человек может подключить этот чужой аккаунт и не заметить. На странице его учётной записи каждый подключённый аккаунт показан с именем в GitHub, так что там ошибку можно увидеть. Никакая проверка на сервере не увидит эту ошибку за человека.
Чего я ещё не сделал: не проверил эту схему с настоящим MCP-клиентом, который показывает запросы через URL. Серверную часть покрывают тесты через HTTP, и я прошёл её вручную в браузере. Чтобы превратить отказ в запрос через URL, в вашем MCP-сервере хватит нескольких строк. Но я ещё не видел, как клиент показывает ссылку человеку, а потом сам повторяет запрос.
Как это настроить, описано на странице Connected services. Там подробно разобраны отказ и ссылка, в том числе то, как передать ссылку клиентам обеих версий MCP. Если вы только знакомитесь с авторизацией в MCP, про сторону токенов рассказывает статья MCP-сервер должен проверять, кому выдан токен.