From bdcdf9b1f5430760be605a7a5e84440e324de7b5 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sun, 2 Aug 2020 18:58:19 -0400 Subject: Strip request origin (#710) * Add web request permissions * Create fetch wrapper that anonymizes the request * Fix Firefox not supporting 'extraHeaders' option --- ext/bg/background.html | 1 + ext/bg/js/audio-uri-builder.js | 39 +++++++----- ext/bg/js/backend.js | 7 ++- ext/bg/js/request-builder.js | 133 +++++++++++++++++++++++++++++++++++++++++ ext/manifest.json | 4 +- ext/mixed/js/audio-system.js | 33 +++++----- 6 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 ext/bg/js/request-builder.js (limited to 'ext') diff --git a/ext/bg/background.html b/ext/bg/background.html index ab84f69a..0f856441 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -40,6 +40,7 @@ + diff --git a/ext/bg/js/audio-uri-builder.js b/ext/bg/js/audio-uri-builder.js index 11738ef3..a6b563d8 100644 --- a/ext/bg/js/audio-uri-builder.js +++ b/ext/bg/js/audio-uri-builder.js @@ -20,7 +20,8 @@ */ class AudioUriBuilder { - constructor() { + constructor({requestBuilder}) { + this._requestBuilder = requestBuilder; this._getUrlHandlers = new Map([ ['jpod101', this._getUriJpod101.bind(this)], ['jpod101-alternate', this._getUriJpod101Alternate.bind(this)], @@ -82,14 +83,21 @@ class AudioUriBuilder { } async _getUriJpod101Alternate(definition) { - const responseText = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data'))); - xhr.addEventListener('load', () => resolve(xhr.responseText)); - xhr.send(`post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}&vulgar=true`); + const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'; + const data = `post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}&vulgar=true`; + const response = await this._requestBuilder.fetchAnonymous(fetchUrl, { + method: 'POST', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data }); + const responseText = await response.text(); const dom = new DOMParser().parseFromString(responseText, 'text/html'); for (const row of dom.getElementsByClassName('dc-result-row')) { @@ -108,13 +116,16 @@ class AudioUriBuilder { } async _getUriJisho(definition) { - const responseText = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('GET', `https://jisho.org/search/${definition.expression}`); - xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data'))); - xhr.addEventListener('load', () => resolve(xhr.responseText)); - xhr.send(); + const fetchUrl = `https://jisho.org/search/${definition.expression}`; + const response = await this._requestBuilder.fetchAnonymous(fetchUrl, { + method: 'GET', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' }); + const responseText = await response.text(); const dom = new DOMParser().parseFromString(responseText, 'text/html'); try { diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 9e7ac76a..85b9b5e6 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -28,6 +28,7 @@ * Mecab * ObjectPropertyAccessor * OptionsUtil + * RequestBuilder * TemplateRenderer * Translator * conditionsTestValue @@ -49,9 +50,13 @@ class Backend { this._options = null; this._optionsSchema = null; this._defaultAnkiFieldTemplates = null; - this._audioUriBuilder = new AudioUriBuilder(); + this._requestBuilder = new RequestBuilder(); + this._audioUriBuilder = new AudioUriBuilder({ + requestBuilder: this._requestBuilder + }); this._audioSystem = new AudioSystem({ audioUriBuilder: this._audioUriBuilder, + requestBuilder: this._requestBuilder, useCache: false }); this._ankiNoteBuilder = new AnkiNoteBuilder({ diff --git a/ext/bg/js/request-builder.js b/ext/bg/js/request-builder.js new file mode 100644 index 00000000..011d6bb8 --- /dev/null +++ b/ext/bg/js/request-builder.js @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +class RequestBuilder { + constructor() { + this._extraHeadersSupported = null; + this._onBeforeSendHeadersExtraInfoSpec = ['blocking', 'requestHeaders', 'extraHeaders']; + } + + async fetchAnonymous(url, init) { + const originURL = this._getOriginURL(url); + const modifications = [ + ['cookie', null], + ['origin', {name: 'Origin', value: originURL}] + ]; + return this.fetchModifyHeaders(url, init, modifications); + } + + async fetchModifyHeaders(url, init, modifications) { + const matchURL = this._getMatchURL(url); + + let done = false; + const callback = (details) => { + if (done || details.url !== url) { return {}; } + done = true; + + const requestHeaders = details.requestHeaders; + this._modifyHeaders(requestHeaders, modifications); + return {requestHeaders}; + }; + const filter = { + urls: [matchURL], + types: ['xmlhttprequest'] + }; + + let needsCleanup = false; + try { + this._onBeforeSendHeadersAddListener(callback, filter); + needsCleanup = true; + } catch (e) { + // NOP + } + + try { + return await fetch(url, init); + } finally { + if (needsCleanup) { + try { + chrome.webRequest.onBeforeSendHeaders.removeListener(callback); + } catch (e) { + // NOP + } + } + } + } + + // Private + + _onBeforeSendHeadersAddListener(callback, filter) { + const extraInfoSpec = this._onBeforeSendHeadersExtraInfoSpec; + for (let i = 0; i < 2; ++i) { + try { + chrome.webRequest.onBeforeSendHeaders.addListener(callback, filter, extraInfoSpec); + if (this._extraHeadersSupported === null) { + this._extraHeadersSupported = true; + } + break; + } catch (e) { + // Firefox doesn't support the 'extraHeaders' option and will throw the following error: + // Type error for parameter extraInfoSpec (Error processing 2: Invalid enumeration value "extraHeaders") for webRequest.onBeforeSendHeaders. + if (this._extraHeadersSupported !== null || !`${e.message}`.includes('extraHeaders')) { + throw e; + } + } + + // addListener failed; remove 'extraHeaders' from extraInfoSpec. + this._extraHeadersSupported = false; + const index = extraInfoSpec.indexOf('extraHeaders'); + if (index >= 0) { extraInfoSpec.splice(index, 1); } + } + } + + _getMatchURL(url) { + const url2 = new URL(url); + return `${url2.protocol}//${url2.host}${url2.pathname}`; + } + + _getOriginURL(url) { + const url2 = new URL(url); + return `${url2.protocol}//${url2.host}`; + } + + _modifyHeaders(headers, modifications) { + modifications = new Map(modifications); + + for (let i = 0, ii = headers.length; i < ii; ++i) { + const header = headers[i]; + const name = header.name.toLowerCase(); + const modification = modifications.get(name); + if (typeof modification === 'undefined') { continue; } + + modifications.delete(name); + + if (modification === null) { + headers.splice(i, 1); + --i; + --ii; + } else { + headers[i] = modification; + } + } + + for (const header of modifications.values()) { + if (header !== null) { + headers.push(header); + } + } + } +} diff --git a/ext/manifest.json b/ext/manifest.json index 619c18c1..8360939f 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -67,7 +67,9 @@ "storage", "clipboardWrite", "unlimitedStorage", - "nativeMessaging" + "nativeMessaging", + "webRequest", + "webRequestBlocking" ], "optional_permissions": [ "clipboardRead" diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js index fdfb0b10..07e1a79b 100644 --- a/ext/mixed/js/audio-system.js +++ b/ext/mixed/js/audio-system.js @@ -66,10 +66,11 @@ class TextToSpeechAudio { } class AudioSystem { - constructor({audioUriBuilder, useCache}) { + constructor({audioUriBuilder, requestBuilder=null, useCache}) { this._cache = useCache ? new Map() : null; this._cacheSizeMaximum = 32; this._audioUriBuilder = audioUriBuilder; + this._requestBuilder = requestBuilder; if (typeof speechSynthesis !== 'undefined') { // speechSynthesis.getVoices() will not be populated unless some API call is made. @@ -169,22 +170,22 @@ class AudioSystem { }); } - _createAudioBinaryFromUrl(url) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.responseType = 'arraybuffer'; - xhr.addEventListener('load', async () => { - const arrayBuffer = xhr.response; - if (!await this._isAudioBinaryValid(arrayBuffer)) { - reject(new Error('Could not retrieve audio')); - } else { - resolve(arrayBuffer); - } - }); - xhr.addEventListener('error', () => reject(new Error('Failed to connect'))); - xhr.open('GET', url); - xhr.send(); + async _createAudioBinaryFromUrl(url) { + const response = await this._requestBuilder.fetchAnonymous(url, { + method: 'GET', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' }); + const arrayBuffer = await response.arrayBuffer(); + + if (!await this._isAudioBinaryValid(arrayBuffer)) { + throw new Error('Could not retrieve audio'); + } + + return arrayBuffer; } _isAudioValid(audio) { -- cgit v1.2.3