Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: external node in Firefox 85+ #957

Merged
merged 1 commit into from
Jan 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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