Почему возникает ошибка 404 только под Firefox?

Есть расширение YaMuTools, которое добавляет функций для Яндекс.Музыки. Под Chrome работает как и задумано. Недавно портировал под Firefox и возникла проблема.

Один из get-запросов под Firefox возвращает ошибку 404. Все остальные со статусом 200. Под Chrome тот же запрос и тот же код работает без ошибки. В чем может быть проблема?

Чтобы воспроизвести ошибку, установите расширение, зайдите на страницу плейлиста Яндекс.Музыки (нужно быть залогиненным) и нажмите кнопки: YaMuTools > Фильтр треков > Вычитание треков. Появится всплывающее окно с бесконечной загрузкой. В консоле будет ошибка 404. Полный код есть здесь.

Запрос совершается через XMLHttpRequest. Пробовал fetch и очистку кэш/кук - не помогло.

Firefox, 404

XHRGET
404
Not Found
HTTP/1.1
GET /handlers/library.jsx?owner=music-blog&filter=playlists HTTP/1.1
Host: music.yandex.ru
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:92.0) Gecko/20100101 Firefox/92.0
Accept: */*
Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
X-Retpath-Y: https://music.yandex.ru/users/music-blog/playlists/2678
Content-Type: application/x-www-form-urlencoded
X-Current-UID: 224867931
Connection: keep-alive
Cookie: ...
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site

Тот же запрос в Chrome

GET /handlers/library.jsx?owner=music-blog&filter=playlists HTTP/1.1
Host: music.yandex.ru
Connection: keep-alive
sec-ch-ua: "Yandex";v="21", " Not;A Brand";v="99", "Chromium";v="93"
DNT: 1
X-Retpath-Y: https://music.yandex.ru/users/music-blog/playlists/2678
X-Current-UID: 224867931
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 YaBrowser/21.9.0.1044 Yowser/2.5 Safari/537.36
Content-Type: application/x-www-form-urlencoded
sec-ch-ua-platform: "Windows"
Accept: */*
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://music.yandex.ru/users/music-blog/playlists/2678
Accept-Encoding: gzip, deflate, br
Accept-Language: ru,en;q=0.9,la;q=0.8
Cookie: ...

Другой запрос под Firefox

XHRGET
200
OK
HTTP/1.1
GET /handlers/playlist.jsx?owner=music-blog&kinds=2678&light=false HTTP/1.1
Host: music.yandex.ru
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:92.0) Gecko/20100101 Firefox/92.0
Accept: */*
Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
X-Retpath-Y: https://music.yandex.ru/users/music-blog/playlists/2678
Content-Type: application/x-www-form-urlencoded
X-Current-UID: 224867931
Connection: keep-alive
Cookie: ...
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site

Этот код отправляет запрос во всех случаях.

function requestGET(url, callback) {
    requestOfType({
        type: 'GET',
        url: url,
        formData: null,
        callback: callback,
    });
}

let countRequest = 0;
function requestOfType(data) {
    if (countRequest > 100) {
        let timerId = setTimeout(() => {
            clearTimeout(timerId);
            requestOfType(data);
        }, 1000);
        return;
    }

    let request = new XMLHttpRequest();
    countRequest++;
    request.onreadystatechange = function () {
        if (request.readyState == 4) {
            countRequest--;
            if (request.status >= 400) {
                data.callback('error');
            } else {
                let responseJSON = JSON.parse(request.responseText);
                data.callback(responseJSON);
            }
        }
    };
    request.open(data.type, data.url);
    request.send(data.formData);
}

Ответы (1 шт):

Автор решения: yar85

В данном случае, сетевой запрос из расширения Firefox не содержит заголовок Referer. Некоторые реализации бэкэнда проверяют значение этого заголовка (для ограничения сторонних запросов с тем же ориджином)... то есть, как дополнение к CORS, немного помогающее бороться с запросами из инъекций кода в веб-страницы.
Добавлять этот заголовок самостоятельно, перед отправкой запроса - бесполезно: в соответствии со спецификацией XMLHttpRequest, реферер автоматически удаляется при отправке запроса (это мера обеспечения безопасности).

Почему в расширениях Firefox сам не добавляет Referer и Origin

В современных браузерах, скрипты страницы и скрипты расширений выполняются в изолированных сэндбоксах, для защиты от XSS-подобных атак. Но реализация такого разделения в данный момент не стандартизирована (как и браузерные расширения в целом), поэтому разные браузеры делают это по-разному.
Firefox, в частности, предоставляет контент-скриптам расширений отдельные от контекста страницы глобальные объекты, включая объект XMLHttpRequest (который в расширении получает повышенные привилегии, и поэтому должен быть изолирован от скриптов страницы). Это имеет позитивный побочный эффект отправки запросов без "утечки" кроссовых кук и реферера с ориджином - что мы и наблюдаем здесь.

Что же с этим делать

Когда в Firefox-расширении хочется отправить запрос "от имени" странички (с автодобавлением заголовка Referer и возможностью использования withCredentials в случае кроссдоменности) - надо всего лишь получить объект XMLHttpRequest из контекста страницы.
Сейчас это делается так (MDN):

const xhr = new content.XMLHttpRequest(); 

И современный фичедетект вариант (для кроссбраузерных расширений, ES11+):

const xhr = new (window.content?.XMLHttpRequest ?? window.XMLHttpRequest)(); 

В старом обсуждении на Mozilla Discourse (форум сообщества) можно найти еще и такой фичедетект вариант, который я тут слегка поправил:

function getXMLHttpRequest() {
  try {
    return XPCNativeWrapper(new window.wrappedJSObject.XMLHttpRequest());
  }
  catch () {
    return new XMLHttpRequest();
  }
}
// ....
const xhr = getXMLHttpRequest(); 

Классическое решение использует обертку объекта XMLHttpRequest страницы, полученного через wrappedJSObject (функция XPCNativeWrapper добавляет безопасности: любые обращения к свойствам обернутого объекта будут игнорировать переопределения, возвращая исходные значения. На данный момент, документацию этой функции почему-то выпилили из MDN).
Учитывая то что работа с wrappedJSObject официально не рекомендована как решение - первый способ из этого ответа (с window.content) воспринимается более надежным, чем классический. Использование нейтив-обертки опционально, по желанию... судя по базе знаний, она вообще по большей части предназначена для XUL-документов, и у меня есть сомнения насчет "не является ли грязным хаком ее использование в контент-скрипте расширения".

→ Ссылка