Я написал расширение, которое показывает код ответа открытой страницы на значке, а по нажатию: цепочку редиректов, время ответа, срок 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 для этого не требуется.
SSL-сертификат: API есть, но работает только у разработчика
Сертификат сайта расширению не получить, хотя в документации описан способ. В webRequest.onHeadersReceived можно передать securityInfoRawDer, и тогда в событии должно появиться поле securityInfo с сертификатом в формате DER. Остаётся разобрать DER и достать дату окончания.
chrome.webRequest.onHeadersReceived.addListener( (d) => { const cert = d.securityInfo?.certificates?.[0]?.rawDER; // разбираем DER, достаём notAfter }, { 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 хорош, пока домен не в зоне .ru
Срок регистрации домена можно узнать прямо из браузера по протоколу RDAP, но не для всех зон. RDAP пришёл на смену whois: это обычный HTTPS-запрос с ответом в JSON. Сам whois работает через порт 43, и из браузера к нему не подключиться.
Адрес RDAP-сервера для зоны берётся из реестра IANA, его разумно кэшировать на сутки.
const base = rdapServers[tld]; // таблица из реестра IANAif (!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.onErrorOccurredtry { 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 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» помещаются, что-то длиннее уже нет.
Итог: что можно узнать из расширения
Что нужно
Из браузера
Как
Код ответа страницы
Да
webRequest, тип main_frame
Серверные редиректы
Да
onBeforeRedirect по requestId
Редиректы через JS и meta refresh
Да
webNavigation, client_redirect
Время до первого байта
Да
метки времени событий webRequest
Ошибки ресурсов страницы
Да, с фильтром шума
onCompleted и onErrorOccurred
Страница из кэша в памяти, bfcache
Нет
запроса не было, показывать прочерк
Сертификат просрочен
Да
ERR_CERT_DATE_INVALID
Срок сертификата
Нет
API за флагом, нужен сервер
Срок домена в .com, .org, .net
Да
RDAP
Срок домена в .ru, .рф, .su, .io, .de
Нет
RDAP нет, нужен whois на сервере
Проверка по расписанию
Пока открыт браузер
alarms и fetch из service worker
Если вы знаете способ достать сертификат из расширения без флага и без сервера, расскажите в комментариях: я такого не нашёл.
Источник: habr.com