diff --git a/add-on/src/lib/ipfs-client/index.js b/add-on/src/lib/ipfs-client/index.js index c7b69ecae..a18ade5e4 100644 --- a/add-on/src/lib/ipfs-client/index.js +++ b/add-on/src/lib/ipfs-client/index.js @@ -6,11 +6,9 @@ const debug = require('debug') const log = debug('ipfs-companion:client') log.error = debug('ipfs-companion:client:error') -const browser = require('webextension-polyfill') const external = require('./external') const embedded = require('./embedded') const embeddedWithChromeSockets = require('./embedded-chromesockets') -const { webuiCid } = require('../state') let client @@ -33,7 +31,7 @@ async function initIpfsClient (opts) { const instance = await client.init(opts) easeApiChanges(instance) - _reloadIpfsClientDependents(instance) // async (API is present) + preloadWebui(instance, opts) return instance } @@ -43,43 +41,28 @@ async function destroyIpfsClient () { await client.destroy() } finally { client = null - await _reloadIpfsClientDependents() // sync (API stopped working) } } } -function _isWebuiTab (url) { - const bundled = !url.startsWith('http') && url.includes('/webui/index.html#/') - const ipns = url.includes('/webui.ipfs.io/#/') - return bundled || ipns -} - -async function _reloadIpfsClientDependents (instance, opts) { - // online || offline - if (browser.tabs && browser.tabs.query) { - const tabs = await browser.tabs.query({}) - if (tabs) { - tabs.forEach((tab) => { - // detect bundled webui in any of open tabs - if (_isWebuiTab(tab.url)) { - browser.tabs.reload(tab.id) - log('reloading bundled webui') - } - }) - } - } - // online only - if (client && instance) { - if (webuiCid && instance.refs) { - // Optimization: preload the root CID to speed up the first time - // Web UI is opened. If embedded js-ipfs is used it will trigger - // remote (always recursive) preload of entire DAG to one of preload nodes. - // This way when embedded node wants to load resource related to webui - // it will get it fast from preload nodes. - log(`preloading webui root at ${webuiCid}`) - instance.refs(webuiCid, { recursive: false }) - } - } +function preloadWebui (instance, opts) { + // run only when client still exists and async fetch is possible + if (!(client && instance && opts.webuiRootUrl && typeof fetch === 'function')) return + // Optimization: preload the root CID to speed up the first time + // Web UI is opened. If embedded js-ipfs is used it will trigger + // remote (always recursive) preload of entire DAG to one of preload nodes. + // This way when embedded node wants to load resource related to webui + // it will get it fast from preload nodes. + const webuiUrl = opts.webuiRootUrl + log(`preloading webui root at ${webuiUrl}`) + return fetch(webuiUrl, { redirect: 'follow' }) + .then(response => { + const webuiPath = new URL(response.url).pathname + log(`preloaded webui root at ${webuiPath}`) + // trigger recursive remote preload in js-ipfs + instance.refs(webuiPath, { recursive: false }) + }) + .catch(err => log.error(`failed to preload webui root`, err)) } const movedFilesApis = ['add', 'addPullStream', 'addReadableStream', 'cat', 'catPullStream', 'catReadableStream', 'get', 'getPullStream', 'getReadableStream'] diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js index ffc6f455b..8f350e397 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -6,7 +6,6 @@ const log = debug('ipfs-companion:main') log.error = debug('ipfs-companion:main:error') const browser = require('webextension-polyfill') -const toMultiaddr = require('uri-to-multiaddr') const { optionDefaults, storeMissingOptions, migrateOptions } = require('./options') const { initState, offlinePeerCount } = require('./state') const { createIpfsPathValidator } = require('./ipfs-path') @@ -98,7 +97,8 @@ module.exports = async function init () { function registerListeners () { const onBeforeSendInfoSpec = ['blocking', 'requestHeaders'] if (!runtime.isFirefox) { - // Chrome 72+ requires 'extraHeaders' for access to Referer header (used in cors whitelisting of webui) + // Chrome 72+ requires 'extraHeaders' for accessing all headers + // Note: we need this for code ensuring ipfs-http-client can talk to API without setting CORS onBeforeSendInfoSpec.push('extraHeaders') } browser.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, { urls: ['<all_urls>'] }, onBeforeSendInfoSpec) @@ -419,6 +419,7 @@ module.exports = async function init () { // See: https://github.com/ipfs/ipfs-companion/issues/286 try { // pass the URL of user-preffered public gateway + // TOOD: plan to remove this await browser.tabs.executeScript(details.tabId, { code: `window.ipfsCompanionPubGwURL = '${state.pubGwURLString}'`, matchAboutBlank: false, @@ -426,6 +427,7 @@ module.exports = async function init () { runAt: 'document_start' }) // inject script that normalizes `href` and `src` containing unhandled protocols + // TOOD: add deprecation warning and plan to remove this await browser.tabs.executeScript(details.tabId, { file: '/dist/bundles/normalizeLinksContentScript.bundle.js', matchAboutBlank: false, @@ -436,18 +438,6 @@ module.exports = async function init () { console.error(`Unable to normalize links at '${details.url}' due to`, error) } } - if (details.url.startsWith(state.webuiRootUrl)) { - // Ensure API backend points at one from IPFS Companion - const apiMultiaddr = toMultiaddr(state.apiURLString) - await browser.tabs.executeScript(details.tabId, { - runAt: 'document_start', - code: `if (!localStorage.getItem('ipfsApi')) { - console.log('[ipfs-companion] Setting API to ${apiMultiaddr}'); - localStorage.setItem('ipfsApi', '${apiMultiaddr}'); - window.location.reload(); - }` - }) - } } // API STATUS UPDATES @@ -643,6 +633,7 @@ module.exports = async function init () { case 'ipfsApiUrl': state.apiURL = new URL(change.newValue) state.apiURLString = state.apiURL.toString() + state.webuiRootUrl = `${state.apiURLString}webui` shouldRestartIpfsClient = true break case 'ipfsApiPollMs': @@ -651,7 +642,6 @@ module.exports = async function init () { case 'customGatewayUrl': state.gwURL = new URL(change.newValue) state.gwURLString = state.gwURL.toString() - state.webuiRootUrl = `${state.gwURLString}ipfs/${state.webuiCid}/` break case 'publicGatewayUrl': state.pubGwURL = new URL(change.newValue) diff --git a/add-on/src/lib/ipfs-request.js b/add-on/src/lib/ipfs-request.js index 5e5871b2a..16e471775 100644 --- a/add-on/src/lib/ipfs-request.js +++ b/add-on/src/lib/ipfs-request.js @@ -34,28 +34,6 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru const ignoredRequests = new LRU(requestCacheCfg) const ignore = (id) => ignoredRequests.set(id, true) const isIgnored = (id) => ignoredRequests.get(id) !== undefined - - const acrhHeaders = new LRU(requestCacheCfg) // webui cors fix in Chrome - const originUrls = new LRU(requestCacheCfg) // request.originUrl workaround for Chrome - const originUrl = (request) => { - // Firefox and Chrome provide relevant value in different fields: - // (Firefox) request object includes full URL of origin document, return as-is - if (request.originUrl) return request.originUrl - // (Chrome) is lacking: `request.initiator` is just the origin (protocol+hostname+port) - // To reconstruct originUrl we read full URL from Referer header in onBeforeSendHeaders - // and cache it for short time - // TODO: when request.originUrl is available in Chrome the `originUrls` cache can be removed - const cachedUrl = originUrls.get(request.requestId) - if (cachedUrl) return cachedUrl - if (request.requestHeaders) { - const referer = request.requestHeaders.find(h => h.name === 'Referer') - if (referer) { - originUrls.set(request.requestId, referer.value) - return referer.value - } - } - } - const preNormalizationSkip = (state, request) => { // skip requests to the custom gateway or API (otherwise we have too much recursion) if (request.url.startsWith(state.gwURLString) || request.url.startsWith(state.apiURLString)) { @@ -161,46 +139,23 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru // Special handling of requests made to API if (request.url.startsWith(state.apiURLString)) { - // Requests made by 'blessed' Web UI - // -------------------------------------------- - // Goal: Web UI works without setting CORS at go-ipfs - // (Without this snippet go-ipfs will return HTTP 403 due to additional origin check on the backend) - const origin = originUrl(request) - if (origin && origin.startsWith(state.webuiRootUrl)) { - // console.log('onBeforeSendHeaders', request) - // console.log('onBeforeSendHeaders.origin', origin) - // Swap Origin to pass server-side check - // (go-ipfs returns HTTP 403 on origin mismatch if there are no CORS headers) - const swapOrigin = (at) => { - request.requestHeaders[at].value = request.requestHeaders[at].value.replace(state.gwURL.origin, state.apiURL.origin) - } - let foundAt = request.requestHeaders.findIndex(h => h.name === 'Origin') - if (foundAt > -1) swapOrigin(foundAt) - foundAt = request.requestHeaders.findIndex(h => h.name === 'Referer') - if (foundAt > -1) swapOrigin(foundAt) - - // Save access-control-request-headers from preflight - foundAt = request.requestHeaders.findIndex(h => h.name && h.name.toLowerCase() === 'access-control-request-headers') - if (foundAt > -1) { - acrhHeaders.set(request.requestId, request.requestHeaders[foundAt].value) - // console.log('onBeforeSendHeaders FOUND access-control-request-headers', acrhHeaders.get(request.requestId)) - } - // console.log('onBeforeSendHeaders fixed headers', request.requestHeaders) - } - // '403 - Forbidden' fix for Chrome and Firefox // -------------------------------------------- - // We remove Origin header from requests made to API URL and WebUI - // by js-ipfs-http-client running in WebExtension context to remove need - // for manual CORS whitelisting via Access-Control-Allow-Origin at go-ipfs + // We remove Origin header from requests made to API URL + // by js-ipfs-http-client running in WebExtension context. + // This act as unification of CORS behavior across all vendors, + // where behavior was non-deterministic and changed between releases. + // Without this, some users would need to do manual CORS whitelisting + // by adding webExtensionOrigin to API.Access-Control-Allow-Origin at their IPFS node. // More info: // Firefox: https://github.com/ipfs-shipyard/ipfs-companion/issues/622 // Chromium 71: https://github.com/ipfs-shipyard/ipfs-companion/pull/616 // Chromium 72: https://github.com/ipfs-shipyard/ipfs-companion/issues/630 const isWebExtensionOrigin = (origin) => { // console.log(`origin=${origin}, webExtensionOrigin=${webExtensionOrigin}`) - // Chromium <= 71 returns opaque Origin as defined in + // Chromium <72 returns opaque Origin as defined in // https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin + // TODO: remove this when <72 is not used by users if (origin == null || origin === 'null') { return true } @@ -274,41 +229,6 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru return } - // Special handling of requests made to API - if (request.url.startsWith(state.apiURLString)) { - // Special handling of requests made by 'blessed' Web UI from local Gateway - // Goal: Web UI works without setting CORS at go-ipfs - // (This includes 'ignored' requests: CORS needs to be fixed even if no redirect is done) - const origin = originUrl(request) - if (origin && origin.startsWith(state.webuiRootUrl) && request.responseHeaders) { - // console.log('onHeadersReceived', request) - const acaOriginHeader = { name: 'Access-Control-Allow-Origin', value: state.gwURL.origin } - const foundAt = findHeaderIndex(acaOriginHeader.name, request.responseHeaders) - if (foundAt > -1) { - request.responseHeaders[foundAt].value = acaOriginHeader.value - } else { - request.responseHeaders.push(acaOriginHeader) - } - - // Restore access-control-request-headers from preflight - const acrhValue = acrhHeaders.get(request.requestId) - if (acrhValue) { - const acahHeader = { name: 'Access-Control-Allow-Headers', value: acrhValue } - const foundAt = findHeaderIndex(acahHeader.name, request.responseHeaders) - if (foundAt > -1) { - request.responseHeaders[foundAt].value = acahHeader.value - } else { - request.responseHeaders.push(acahHeader) - } - acrhHeaders.del(request.requestId) - // console.log('onHeadersReceived SET Access-Control-Allow-Headers', header) - } - - // console.log('onHeadersReceived fixed headers', request.responseHeaders) - return { responseHeaders: request.responseHeaders } - } - } - // Skip if request is marked as ignored if (isIgnored(request.requestId)) { return @@ -317,6 +237,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru if (state.redirect) { // Late redirect as a workaround for edge cases such as: // - CORS XHR in Firefox: https://github.com/ipfs-shipyard/ipfs-companion/issues/436 + // TODO: remove when Firefox with a fix landed in Stable channel if (onHeadersReceivedRedirect.has(request.requestId)) { onHeadersReceivedRedirect.delete(request.requestId) if (state.dnslinkPolicy) { @@ -529,6 +450,25 @@ function normalizedUnhandledIpfsProtocol (request, pubGwUrl) { } } -function findHeaderIndex (name, headers) { - return headers.findIndex(x => x.name && x.name.toLowerCase() === name.toLowerCase()) +/* not used at the moment, but this heuristic may be useful in the future +// Note: Chrome 72+ requires 'extraHeaders' for access to Referer header +const originUrls = new LRU(requestCacheCfg) // request.originUrl workaround for Chrome +const originUrl = (request) => { + // Firefox and Chrome provide relevant value in different fields: + // (Firefox) request object includes full URL of origin document, return as-is + if (request.originUrl) return request.originUrl + // (Chrome) is lacking: `request.initiator` is just the origin (protocol+hostname+port) + // To reconstruct originUrl we read full URL from Referer header in onBeforeSendHeaders + // and cache it for short time + // TODO: when request.originUrl is available in Chrome the `originUrls` cache can be removed + const cachedUrl = originUrls.get(request.requestId) + if (cachedUrl) return cachedUrl + if (request.requestHeaders) { + const referer = request.requestHeaders.find(h => h.name === 'Referer') + if (referer) { + originUrls.set(request.requestId, referer.value) + return referer.value + } + } } +*/ diff --git a/add-on/src/lib/state.js b/add-on/src/lib/state.js index b9fa09503..92d5a0d24 100644 --- a/add-on/src/lib/state.js +++ b/add-on/src/lib/state.js @@ -4,10 +4,6 @@ const { safeURL } = require('./options') const offlinePeerCount = -1 -// CID of a 'blessed' Web UI release -// which should work without setting CORS headers -const webuiCid = 'QmYTRvKFGhxgBiUreiw7ihn8g95tfJTWDt7aXqDsvAcJse' // v2.4.7 - function initState (options) { // we store options and some pregenerated values to avoid async storage // reads and minimize performance impact on overall browsing experience @@ -26,11 +22,9 @@ function initState (options) { state.gwURLString = state.gwURL.toString() delete state.customGatewayUrl state.dnslinkPolicy = String(options.dnslinkPolicy) === 'false' ? false : options.dnslinkPolicy - state.webuiCid = webuiCid - state.webuiRootUrl = `${state.gwURLString}ipfs/${state.webuiCid}/` + state.webuiRootUrl = `${state.apiURLString}webui` return state } exports.initState = initState exports.offlinePeerCount = offlinePeerCount -exports.webuiCid = webuiCid diff --git a/test/functional/lib/ipfs-request-workarounds.test.js b/test/functional/lib/ipfs-request-workarounds.test.js index 573c80c37..f9930608f 100644 --- a/test/functional/lib/ipfs-request-workarounds.test.js +++ b/test/functional/lib/ipfs-request-workarounds.test.js @@ -55,8 +55,9 @@ describe('modifyRequest processing', function () { }) }) - describe('a request to <apiURL>/api/v0/ with Origin=moz-extension://{extension-installation-id}', function () { - it('should remove Origin header with moz-extension://', async function () { + describe('a request to <apiURL>/api/v0/ made from extension with Origin header', function () { + it('should have it removed if Origin: moz-extension://{extension-installation-id}', async function () { + // Context: Firefox 65 started setting this header // set vendor-specific Origin for WebExtension context browser.runtime.getURL.withArgs('/').returns('moz-extension://0f334731-19e3-42f8-85e2-03dbf50026df/') // ensure clean modifyRequest @@ -73,10 +74,8 @@ describe('modifyRequest processing', function () { expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders).to.not.include(bogusOriginHeader) browser.runtime.getURL.flush() }) - }) - - describe('a request to <apiURL>/api/v0/ with Origin=chrome-extension://{extension-installation-id}', function () { - it('should remove Origin header with chrome-extension://', async function () { + it('should have it removed if Origin: chrome-extension://{extension-installation-id}', async function () { + // Context: Chromium 72 started setting this header // set vendor-specific Origin for WebExtension context browser.runtime.getURL.withArgs('/').returns('chrome-extension://trolrorlrorlrol/') // ensure clean modifyRequest @@ -93,10 +92,8 @@ describe('modifyRequest processing', function () { expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders).to.not.include(bogusOriginHeader) browser.runtime.getURL.flush() }) - }) - - describe('a request to <apiURL>/api/v0/ with Origin=null', function () { - it('should remove Origin header ', async function () { + it('should have it removed if Origin: null ', async function () { + // Context: Chromium <72 was setting this header // set vendor-specific Origin for WebExtension context browser.runtime.getURL.withArgs('/').returns(undefined) // ensure clean modifyRequest @@ -115,50 +112,6 @@ describe('modifyRequest processing', function () { }) }) - // Web UI is loaded from hardcoded 'blessed' CID, which enables us to remove - // CORS limitation. This makes Web UI opened from browser action work without - // the need for any additional configuration of go-ipfs daemon - describe('a request to API from blessed webuiRootUrl', function () { - it('should pass without CORS limitations ', async function () { - // ensure clean modifyRequest - runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests - modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime) - // test - const webuiOriginHeader = { name: 'Origin', value: state.webuiRootUrl } - const webuiRefererHeader = { name: 'Referer', value: state.webuiRootUrl } - // CORS whitelisting does not worh in Chrome 72 without passing/restoring ACRH preflight header - const acrhHeader = { name: 'Access-Control-Request-Headers', value: 'X-Test' } // preflight to store - - // Test request - let request = { - requestHeaders: [webuiOriginHeader, webuiRefererHeader, acrhHeader], - type: 'xmlhttprequest', - originUrl: state.webuiRootUrl, - url: `${state.apiURLString}api/v0/id` - } - request = modifyRequest.onBeforeRequest(request) || request // executes before onBeforeSendHeaders, may mutate state - const requestHeaders = modifyRequest.onBeforeSendHeaders(request).requestHeaders - - // "originUrl" should be swapped to look like it came from the same origin as HTTP API - const expectedOriginUrl = state.webuiRootUrl.replace(state.gwURLString, state.apiURLString) - expect(requestHeaders).to.deep.include({ name: 'Origin', value: expectedOriginUrl }) - expect(requestHeaders).to.deep.include({ name: 'Referer', value: expectedOriginUrl }) - expect(requestHeaders).to.deep.include(acrhHeader) - - // Test response - const response = Object.assign({}, request) - delete response.requestHeaders - response.responseHeaders = [] - const responseHeaders = modifyRequest.onHeadersReceived(response).responseHeaders - const corsHeader = { name: 'Access-Control-Allow-Origin', value: state.gwURL.origin } - const acahHeader = { name: 'Access-Control-Allow-Headers', value: acrhHeader.value } // expect value restored from preflight - expect(responseHeaders).to.deep.include(corsHeader) - expect(responseHeaders).to.deep.include(acahHeader) - - browser.runtime.getURL.flush() - }) - }) - after(function () { delete global.URL delete global.browser