Skip to content

Commit

Permalink
fix: external node in Firefox 85 (#957)
Browse files Browse the repository at this point in the history
Closes #955 and refactors the way we detect requests coming from the
Companion extension to be independent of the brittle Origin HTTP
header. Instead, we now inspect request via lower level WebExtension API.
  • Loading branch information
lidel authored Jan 7, 2021
1 parent b5cddcf commit 95dbb50
Show file tree
Hide file tree
Showing 2 changed files with 169 additions and 54 deletions.
73 changes: 46 additions & 27 deletions add-on/src/lib/ipfs-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,24 @@ const onHeadersReceivedRedirect = new Set()
function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, runtime) {
const browser = runtime.browser
const runtimeRoot = browser.runtime.getURL('/')
const webExtensionOrigin = runtimeRoot ? new URL(runtimeRoot).origin : 'null'
const webExtensionOrigin = runtimeRoot ? new URL(runtimeRoot).origin : 'http://companion-origin' // avoid 'null' because it has special meaning
const isCompanionRequest = (request) => {
// We inspect webRequest object (WebExtension API) instead of Origin HTTP
// header because the value of the latter changed over the years ad
// absurdum. It leaks the unique extension ID and no vendor seem to have
// coherent policy around it, Firefox and Chromium flip back and forth:
// Firefox Nightly 65 sets moz-extension://{extension-installation-id}
// Chromium <72 sets null
// Chromium Beta 72 sets chrome-extension://{uid}
// Firefox Nightly 85 sets null
const { originUrl, initiator } = request
// Of course, getting "Origin" is vendor-specific:
// FF: originUrl (Referer-like Origin URL with path)
// Chromium: initiator (just Origin, no path)
// Because of this mess, we normalize Origin by reading it from URL.origin
const { origin } = new URL(originUrl || initiator || 'http://missing-origin')
return origin === webExtensionOrigin
}

// Various types of requests are identified once and cached across all browser.webRequest hooks
const requestCacheCfg = { max: 128, maxAge: 1000 * 30 }
Expand Down Expand Up @@ -192,32 +209,34 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
// Special handling of requests made to API
if (sameGateway(request.url, state.apiURL)) {
const { requestHeaders } = request
// '403 - Forbidden' fix for Chrome and Firefox
// --------------------------------------------
// We update "Origin: *-extension://" HTTP headers in requests made to API
// by js-ipfs-http-client running in the background page of browser
// extension. Without this, some users would need to do manual CORS
// whitelisting by adding "..extension://<UUID>" to
// API.HTTPHeaders.Access-Control-Allow-Origin in go-ipfs config.
// With this, API calls made by browser extension look like ones made
// by webui loaded from the API port.
// 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

// Firefox Nightly 65 sets moz-extension://{extension-installation-id}
// Chromium Beta 72 sets chrome-extension://{uid}
const isWebExtensionOrigin = (origin) =>
origin &&
(origin.startsWith('moz-extension://') ||
origin.startsWith('chrome-extension://')) &&
new URL(origin).origin === webExtensionOrigin

// Replace Origin header matching webExtensionOrigin with API one
const foundAt = requestHeaders.findIndex(h => h.name === 'Origin' && isWebExtensionOrigin(h.value))
if (foundAt > -1) {
requestHeaders[foundAt].value = state.apiURL.origin

if (isCompanionRequest(request)) {
// '403 - Forbidden' fix for Chrome and Firefox
// --------------------------------------------
// We update "Origin: *-extension://" HTTP headers in requests made to API
// by js-ipfs-http-client running in the background page of browser
// extension. Without this, some users would need to do manual CORS
// whitelisting by adding "..extension://<UUID>" to
// API.HTTPHeaders.Access-Control-Allow-Origin in go-ipfs config.
// With this, API calls made by browser extension look like ones made
// by webui loaded from the API port.
// More info:
// Firefox 65: https://github.com/ipfs-shipyard/ipfs-companion/issues/622
// Firefox 85: https://github.com/ipfs-shipyard/ipfs-companion/issues/955
// Chromium 71: https://github.com/ipfs-shipyard/ipfs-companion/pull/616
// Chromium 72: https://github.com/ipfs-shipyard/ipfs-companion/issues/630
const foundAt = requestHeaders.findIndex(h => h.name.toLowerCase() === 'origin')
const { origin } = state.apiURL
if (foundAt > -1) {
// Replace existing Origin with the origin of the API itself.
// This removes the need for CORS setup in go-ipfs config and
// ensures there is no HTTP Error 403 Forbidden.
requestHeaders[foundAt].value = origin
} else { // future-proofing
// Origin is missing, and go-ipfs requires it in browsers:
// https://github.com/ipfs/go-ipfs-cmds/pull/193
requestHeaders.push({ name: 'Origin', value: origin })
}
}

// Fix "http: invalid Read on closed Body"
Expand Down
150 changes: 123 additions & 27 deletions test/functional/lib/ipfs-request-workarounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,12 @@ describe('modifyRequest processing', function () {
let state, getState, dnslinkResolver, ipfsPathValidator, modifyRequest, runtime

before(function () {
// stub URL.origin in test context to return something other than null
Object.defineProperty(URL.prototype, 'origin', {
get: function () {
const fakeOrigin = this.href.split('/')
if (fakeOrigin.length >= 3) {
return fakeOrigin.slice(0, 3).join('/')
}
}
})
global.URL = URL
global.browser = browser
})

beforeEach(async function () {
browser.runtime.getURL.flush()
state = initState(optionDefaults)
getState = () => state
const getIpfs = () => {}
Expand Down Expand Up @@ -105,72 +97,176 @@ describe('modifyRequest processing', function () {
})
})

describe('a request to <apiURL>/api/v0/ made with extension:// Origin', function () {
it('should have it replaced with API one if Origin: moz-extension://{extension-installation-id}', async function () {
// The Origin header set by browser for requests coming from within a browser
// extension has been a mess for years, and by now we simply have zero trust
// in stability of this header. Instead, we use WebExtension's webRequest API
// to tell if a request comes from our browser extension and manually set
// Origin to look like a request coming from the same Origin as IPFS API.
//
// The full context can be found in ipfs-request.js, where isCompanionRequest
// check is executed.
describe('Origin header in a request to <apiURL>/api/v0/', function () {
it('set to API if request comes from Companion in Firefox <85', 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
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
// test
const bogusOriginHeader = { name: 'Origin', value: 'moz-extension://0f334731-19e3-42f8-85e2-03dbf50026df' }
const originalOriginHeader = { name: 'Origin', value: 'moz-extension://0f334731-19e3-42f8-85e2-03dbf50026df' }
const apiOriginHeader = { name: 'Origin', value: getState().apiURL.origin }
const request = {
requestHeaders: [originalOriginHeader],
originUrl: 'moz-extension://0f334731-19e3-42f8-85e2-03dbf50026df/path/to/background.html', // FF specific WebExtension API
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders)
.to.deep.include(apiOriginHeader)
})

it('set to API if request comes from Companion in Firefox 85', async function () {
// Context: https://github.com/ipfs-shipyard/ipfs-companion/issues/955#issuecomment-753413988
browser.runtime.getURL.withArgs('/').returns('moz-extension://0f334731-19e3-42f8-85e2-03dbf50026df/')
// ensure clean modifyRequest
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
// test
const originalOriginHeader = { name: 'Origin', value: 'null' }
const apiOriginHeader = { name: 'Origin', value: getState().apiURL.origin }
const request = {
requestHeaders: [originalOriginHeader],
originUrl: 'moz-extension://0f334731-19e3-42f8-85e2-03dbf50026df/path/to/background.html', // FF specific WebExtension API
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders)
.to.deep.include(apiOriginHeader)
})

it('set to API if request comes from Companion in Chromium <72', async function () {
// set vendor-specific Origin for WebExtension context
browser.runtime.getURL.withArgs('/').returns('chrome-extension://nibjojkomfdiaoajekhjakgkdhaomnch/')
// ensure clean modifyRequest
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
// test
const bogusOriginHeader = { name: 'Origin', value: 'null' }
const apiOriginHeader = { name: 'Origin', value: getState().apiURL.origin }
const request = {
requestHeaders: [bogusOriginHeader],
initiator: 'chrome-extension://nibjojkomfdiaoajekhjakgkdhaomnch/', // Chromium specific WebExtension API
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders)
.to.deep.include(apiOriginHeader)
browser.runtime.getURL.flush()
})
})

describe('should have it removed if Origin: chrome-extension://{extension-installation-id}', function () {
it('should have it swapped with API one if Origin: with chrome-extension://', async function () {
// Context: Chromium 72 started setting this header
it('set to API if request comes from Companion in Chromium 72', async function () {
// Context: Chromium 72 started setting this header to chrome-extension:// URI
// set vendor-specific Origin for WebExtension context
browser.runtime.getURL.withArgs('/').returns('chrome-extension://trolrorlrorlrol/')
browser.runtime.getURL.withArgs('/').returns('chrome-extension://nibjojkomfdiaoajekhjakgkdhaomnch/')
// ensure clean modifyRequest
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
// test
const bogusOriginHeader = { name: 'Origin', value: 'chrome-extension://trolrorlrorlrol' }
const bogusOriginHeader = { name: 'Origin', value: 'chrome-extension://nibjojkomfdiaoajekhjakgkdhaomnch' }
const apiOriginHeader = { name: 'Origin', value: getState().apiURL.origin }
const request = {
requestHeaders: [bogusOriginHeader],
initiator: 'chrome-extension://nibjojkomfdiaoajekhjakgkdhaomnch/', // Chromium specific WebExtension API
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders)
.to.deep.include(apiOriginHeader)
browser.runtime.getURL.flush()
})
})

describe('a request to <apiURL>/api/v0/ with Origin=null', function () {
it('should keep the "Origin: null" header ', async function () {
it('keep Origin as-is if request does not come from Companion (Chromium)', async function () {
browser.runtime.getURL.withArgs('/').returns('chrome-extension://nibjojkomfdiaoajekhjakgkdhaomnch/')
// ensure clean modifyRequest
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
// test
const originHeader = { name: 'Origin', value: 'https://some.website.example.com' }
const expectedOriginHeader = { name: 'Origin', value: 'https://some.website.example.com' }
const request = {
requestHeaders: [originHeader],
initiator: 'https://some.website.example.com', // Chromium specific WebExtension API
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders)
.to.deep.include(expectedOriginHeader)
})

it('keep Origin as-is if request does not come from Companion (Firefox)', async function () {
browser.runtime.getURL.withArgs('/').returns('moz-extension://0f334731-19e3-42f8-85e2-03dbf50026df/')
// ensure clean modifyRequest
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
// test
const originHeader = { name: 'Origin', value: 'https://some.website.example.com' }
const expectedOriginHeader = { name: 'Origin', value: 'https://some.website.example.com' }
const request = {
requestHeaders: [originHeader],
originUrl: 'https://some.website.example.com/some/path.html', // Firefox specific WebExtension API
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders)
.to.deep.include(expectedOriginHeader)
})

it('keep the "Origin: null" if request does not come from Companion (Chromium)', async function () {
// Presence of Origin header is important as it protects API from XSS via sandboxed iframe
// NOTE: Chromium <72 was setting this header in requests sent by browser extension,
// but they fixed it since then.
browser.runtime.getURL.withArgs('/').returns(undefined)
// but they fixed it since then, and we switched to reading origin via webRequest API,
// which is independent from the HTTP header.
browser.runtime.getURL.withArgs('/').returns('chrome-extension://nibjojkomfdiaoajekhjakgkdhaomnch/')
// ensure clean modifyRequest
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
// test
const nullOriginHeader = { name: 'Origin', value: 'null' }
const expectedOriginHeader = { name: 'Origin', value: 'null' }
const request = {
requestHeaders: [nullOriginHeader],
initiator: 'https://random.website.example.com', // Chromium specific WebExtension API
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders)
.to.deep.include(expectedOriginHeader)
})

it('keep the "Origin: null" if request does not come from Companion (Firefox)', async function () {
// Presence of Origin header is important as it protects API from XSS via sandboxed iframe
browser.runtime.getURL.withArgs('/').returns('moz-extension://0f334731-19e3-42f8-85e2-03dbf50026df/')
// ensure clean modifyRequest
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
modifyRequest = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
// test
const nullOriginHeader = { name: 'Origin', value: 'null' }
const expectedOriginHeader = { name: 'Origin', value: 'null' }
const request = {
requestHeaders: [nullOriginHeader],
originUrl: 'https://random.website.example.com/some/path.html', // Firefox specific WebExtension API
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders)
.to.deep.include(nullOriginHeader)
browser.runtime.getURL.flush()
.to.deep.include(expectedOriginHeader)
})
})

Expand Down

0 comments on commit 95dbb50

Please sign in to comment.