Что расширение Chrome может узнать о сайте, а что нет: грабли Manifest V3
- пятница, 13 ноября 2026 г. в 00:00:08
Я написал расширение, которое показывает код ответа открытой страницы на значке, а по нажатию: цепочку редиректов, время ответа, срок SSL-сертификата и срок регистрации домена. Задача выглядела простой: у Chrome есть webRequest, он видит все запросы, осталось красиво показать.
На деле половина пунктов упёрлась в ограничения, о которых документация либо молчит, либо говорит одной строкой. Сертификат сайта расширению не отдают, хотя API для этого есть. Срок домена в зоне .ru из браузера не узнать. Фоновый скрипт засыпает через 30 секунд и забывает всё, что помнил.
Ниже разбор по пунктам: что работает, что нет и чем я это заменил. Всё проверено на Chromium 156 и по документации Chrome на ноябрь 2026 года.
Код ответа страницы отдаёт chrome.webRequest, если слушать запросы типа main_frame. В Manifest V3 у обычных расширений отобрали блокировку запросов, но наблюдение осталось, а больше здесь ничего не нужно.
const MAIN = { urls: ["http://*/*", "https://*/*"], types: ["main_frame"] }; chrome.webRequest.onHeadersReceived.addListener((d) => { hops(d.requestId).push({ url: d.url, status: d.statusCode, to: null }); }, MAIN); chrome.webRequest.onBeforeRedirect.addListener((d) => { hops(d.requestId).at(-1).to = d.redirectUrl; }, MAIN);
Цепочка серверных редиректов собирается по requestId: он один на все шаги. Редиректы через JavaScript и meta refresh webRequest редиректами не считает, для него это два независимых перехода. Их видно в chrome.webNavigation: у второго перехода в transitionQualifiers стоит client_redirect.
Два случая в цепочке легко пропустить. Редирект, который браузер взял из кэша, приходит без onHeadersReceived, сразу как onBeforeRedirect. А переход с http на https по HSTS выглядит как «307 Internal Redirect» без IP-адреса: к сайту браузер не обращался, и выдавать этот шаг за ответ сервера нельзя.
Время ответа я сначала собирался брать из performance в content script. Оказалось, что он не нужен: у каждого события webRequest есть метка времени. Время до первого байта равно onResponseStarted минус onBeforeRequest первого шага цепочки. Минус один скрипт на каждой странице и минус одно разрешение.
Теперь о том, чего значок не видит.
Кэш в памяти и bfcache. Если страница поднята из памяти, запроса не было и событий нет. При возврате кнопкой «Назад» не приходит даже onDOMContentLoaded. Честный ответ здесь: серый прочерк, а не вчерашние «200».
Service worker самого сайта. Страницу, которую он отдал из своего кэша, webRequest может не увидеть.
Одностраничные приложения. Переход внутри SPA нового запроса не делает, на значке остаётся код первой загрузки. Смену адреса можно поймать через webNavigation.onHistoryStateUpdated.
Предзагрузка. Chrome запрашивает страницу до того, как её показал. У событий есть поле documentLifecycle (prerender, active), а когда предзагруженная вкладка подменяет текущую, приходит webNavigation.onTabReplaced. Значок надо ставить только для active и переносить при подмене.
Порядок событий. Между webRequest и webNavigation он не определён. Код, который ждёт одно раньше другого, будет работать у вас и ломаться у пользователей.
Отдельно про сам значок. Chrome сбрасывает текст на значке вкладки при переходе и при показе своей страницы с предупреждением о сертификате, причём уже после ваших событий. Документация об этом молчит. Приходится перекрашивать значок при каждом изменении записи и ещё раз в tabs.onUpdated. Разрешение tabs для этого не требуется.
Сертификат сайта расширению не получить, хотя в документации описан способ. В webRequest.onHeadersReceived можно передать securityInfoRawDer, и тогда в событии должно появиться поле securityInfo с сертификатом в формате DER. Остаётся разобрать DER и достать дату окончания.
chrome.webRequest.onHeadersReceived.addListener( (d) => { const cert = d.securityInfo?.certificates?.[0]?.rawDER; // разбираем DER, достаём notAfter }, { urls: ["<all_urls>"], types: ["main_frame"] }, ["securityInfoRawDer"] );
Я собрал прототип и проверил его на Chromium 156. У обычного пользователя это не работает: Chrome принимает параметр, обработчик срабатывает, а поля securityInfo в событии нет.
Причина нашлась в исходниках Chromium: функция закрыта флагом kWebRequestSecurityInfo, который выключен по умолчанию. В chrome://flags это #web-request-security-info. Стоит его включить, и всё работает как в документации. Сертификат приходит при первом заходе, при повторном по открытому соединению, при ответе 304, на каждом шаге редиректа и даже для fetch из фонового скрипта. Но просить пользователей включать флаг ради расширения нельзя.
Единственный след у обычного пользователя: строка в журнале ошибок расширения на chrome://extensions.
ExtraInfoSpec.securityInfo is allowed only with WebRequestSecurityInfo developer flag
Документация об этом ограничении молчит.
Без флага расширение видит только один факт о сертификате: если он просрочен, приходит onErrorOccurred с net::ERR_CERT_DATE_INVALID. То есть узнать, что сертификат уже кончился, можно, а что он кончится через неделю, нельзя.
Решение скучное: срок сертификата проверяет сервер. Расширение по кнопке отправляет ему имя хоста, сервер открывает TLS-соединение и возвращает дату. Без нажатия ничего не уходит: расширение видит каждую открытую страницу, и молча слать адреса на сервер я не хотел.
Вывод для себя: если API описан в документации, это ещё не значит, что он включён. Прототип на чистом профиле стоит делать до того, как строить на этом функцию.
Срок регистрации домена можно узнать прямо из браузера по протоколу RDAP, но не для всех зон. RDAP пришёл на смену whois: это обычный HTTPS-запрос с ответом в JSON. Сам whois работает через порт 43, и из браузера к нему не подключиться.
Адрес RDAP-сервера для зоны берётся из реестра IANA, его разумно кэшировать на сутки.
const base = rdapServers[tld]; // таблица из реестра IANA if (!base) return { status: "no-rdap" }; const json = await getJson(base + "domain/" + encodeURIComponent(name)); const expires = json.events?.find((e) => e.eventAction === "expiration")?.eventDate ?? null;
Отдельный вопрос: какое имя спрашивать. Для shop.example.com это example.com, а для example.co.uk всё имя целиком. Список публичных суффиксов я в расширение не клал. Вместо него пробую два варианта по очереди: два последних уровня и три. Если предпоследняя часть короткая (co, com), начинаю с трёх. Ответ 404 значит «такого домена нет», и тогда пробую второй вариант. Ответы кэширую на сутки.
Для .com, .org, .net и новых зон это работает. А вот «.ru», «.рф» и «.su» в реестре IANA нет, как и .io, .de и ряда других национальных зон. Популярный посредник rdap.org для .ru отвечает 404. Национальные зоны не обязаны поддерживать RDAP, и многие не поддерживают.
Для таких зон остаётся whois, а значит, снова сервер. У меня это тот же запрос по кнопке, что и для сертификата. Если сервера у вас нет, честнее показать «нет данных», чем молча скрыть строку.
Есть и тонкость с правилами происхождения. Запросы из content script подчиняются CORS страницы, даже если у расширения есть доступ ко всем сайтам. Запросы к RDAP и к своему серверу надо делать из фонового скрипта или из окна расширения.
В расширении есть список сайтов, которые оно само проверяет раз в несколько минут. В Manifest V3 фоновой страницы больше нет, вместо неё service worker, и живёт он по жёстким правилам:
засыпает через 30 секунд без событий;
одно событие может обрабатываться не дольше 5 минут;
если ответ на fetch не пришёл за 30 секунд, Chrome гасит скрипт;
глобальные переменные при засыпании теряются.
Из этого следуют три правила. Всё состояние хранится в chrome.storage.session (живёт до перезапуска браузера) или chrome.storage.local. Обработчики событий регистрируются синхронно на верхнем уровне скрипта, иначе проснувшийся скрипт событие пропустит. Тайм-аут проверки должен быть заметно меньше 30 секунд.
Расписание держит chrome.alarms. С ним свои особенности. Пока устройство спит, будильник не срабатывает, а после пробуждения все пропущенные срабатывания схлопываются в одно. Сохранность будильников после перезапуска браузера документация до Chrome 150 называет непредсказуемой, поэтому при каждом старте скрипта проверяю, что будильник на месте.
Сама проверка: GET без cookie и без кэша, тайм-аут 15 секунд, тело ответа обрываю сразу после заголовков.
// p.error заполняет обработчик webRequest.onErrorOccurred try { const r = await fetch(url, { redirect: "follow", credentials: "omit", cache: "no-store", signal: ctl.signal }); return { status: r.status, error: null }; } catch (e) { // fetch падает раньше, чем webRequest сообщает причину: ждём её до секунды for (let i = 0; !timedOut && !p.error && i < 10; i++) await new Promise((ok) => setTimeout(ok, 100)); return { status: null, error: timedOut ? "timeout" : p.error ?? e.message }; } finally { ctl.abort(); // тело ответа не нужно }
Повтор через 3 секунды делаю только при ошибке сети, чтобы разовый сбой не превращался в ложную тревогу. Ответ сайта с кодом 4xx или 5xx не переспрашиваю. Причина в том, что после ответов 5xx Chrome начинает притормаживать запросы расширения к этому сайту, и лишний запрос только приближает это. Выглядит торможение так: fetch падает, а webRequest молчит, потому что до сети запрос не дошёл. Такую «тихую» ошибку я тоже не повторяю.
Здесь нашлась самая неочевидная вещь. fetch при ошибке сети говорит только «Failed to fetch», а причину (ERR_NAME_NOT_RESOLVED, ERR_CONNECTION_REFUSED) знает webRequest.onErrorOccurred. Но события webRequest для запроса из service worker приходят позже, чем fetch вернул ответ или бросил ошибку. Если не подождать, причина теряется. Я жду событие до секунды.
Сопоставлять такие запросы с событиями приходится по адресу: у них tabId: -1, а своего requestId fetch не сообщает. Так же собирается цепочка редиректов: сам fetch отдаёт только итоговый статус и флаг redirected.
И главное ограничение, которое не обойти: всё это работает, только пока браузер открыт. Расширение не заменяет мониторинг с сервера, и в интерфейсе об этом стоит сказать прямо.
Список незагрузившихся ресурсов страницы без фильтра бесполезен: почти на каждом сайте он непустой. Вот что я отбрасываю:
/favicon.ico с кодом 404: браузер запрашивает его сам;
401 на запрос типа xmlhttprequest: так сайты проверяют, вошёл ли пользователь;
ERR_ABORTED: запрос отменила сама страница;
ERR_BLOCKED_BY_CLIENT и ERR_BLOCKED_BY_ADMINISTRATOR: запрос заблокировал блокировщик рекламы или политика организации;
ERR_CACHE_MISS.
Смешанный контент (http-ресурсы на https-странице) я тоже показываю, но срабатывает этот пункт редко. Скрипты Chrome блокирует ещё до сети, а картинки сам переводит на https.
Со значком была задача: код ответа у каждой вкладки свой, а красная точка «один из ваших сайтов упал» должна быть видна везде. Решается разделением. Текст с кодом ставится через action.setBadgeText с tabId. Картинка с точкой рисуется в service worker на OffscreenCanvas и ставится через action.setIcon без tabId, то есть сразу на все вкладки.
После перезапуска браузера Chrome эту картинку сбрасывает, так что её надо перерисовывать при старте service worker. На значок влезает около четырёх символов: «200», «404» и «ERR» помещаются, что-то длиннее уже нет.
Что нужно | Из браузера | Как |
|---|---|---|
Код ответа страницы | Да |
|
Серверные редиректы | Да |
|
Редиректы через JS и meta refresh | Да |
|
Время до первого байта | Да | метки времени событий |
Ошибки ресурсов страницы | Да, с фильтром шума |
|
Страница из кэша в памяти, bfcache | Нет | запроса не было, показывать прочерк |
Сертификат просрочен | Да |
|
Срок сертификата | Нет | API за флагом, нужен сервер |
Срок домена в .com, .org, .net | Да | RDAP |
Срок домена в .ru, .рф, .su, .io, .de | Нет | RDAP нет, нужен whois на сервере |
Проверка по расписанию | Пока открыт браузер |
|
Если вы знаете способ достать сертификат из расширения без флага и без сервера, расскажите в комментариях: я такого не нашёл.
Источники: документация Chrome по webRequest, webNavigation, alarms, action и жизненному циклу service worker, реестр RDAP IANA.