Skip to content

Commit

Permalink
refactor: remove code for "blessing" custom webui
Browse files Browse the repository at this point in the history
This restores the proper way of opening webui in version provided by IPFS node.

Context:

Before subdomain gateway support was added, we loaded webui from gateway port.

Why? API port has a hardcoded list of whitelisted webui versions and it
is not possible to load non-whitelisted CID when new webui was released.

To enable API access from webui loaded via Gateway port, the Companion
extension removed Origin header for requests coming from its background
page. This let us avoid the need for manual CORS setup, but was seen in
the diff, was pretty complex process.

Webui is stable now, so to decrease maintenance burden we move away from
that complexity and just load version whitelisted on API port.

What if someone wants to run newest webui? They can now load it from
webui.ipfs.io.ipns.localhost:8080 (whitelist API access from that
specific Origin by appending it to
API.HTTPHeaders.Access-Control-Allow-Origin in go-ipfs config)

Closes #736
  • Loading branch information
lidel committed Apr 3, 2020
1 parent eb66dfa commit aec856f
Show file tree
Hide file tree
Showing 7 changed files with 33 additions and 198 deletions.
19 changes: 3 additions & 16 deletions add-on/src/lib/ipfs-companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 pMemoize = require('p-memoize')
const { optionDefaults, storeMissingOptions, migrateOptions, guiURLString } = require('./options')
const { initState, offlinePeerCount } = require('./state')
Expand Down Expand Up @@ -107,7 +106,8 @@ module.exports = async function init () {
function registerListeners () {
const onBeforeSendInfoSpec = ['blocking', 'requestHeaders']
if (browser.webRequest.OnBeforeSendHeadersOptions && 'EXTRA_HEADERS' in browser.webRequest.OnBeforeSendHeadersOptions) {
// 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)
Expand Down Expand Up @@ -385,18 +385,6 @@ module.exports = async function init () {
log.error(`Unable to linkify DOM 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
Expand Down Expand Up @@ -615,6 +603,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':
Expand All @@ -623,8 +612,6 @@ module.exports = async function init () {
case 'customGatewayUrl':
state.gwURL = new URL(change.newValue)
state.gwURLString = state.gwURL.toString()
// TODO: for now we load webui from API port, should we remove this?
// state.webuiRootUrl = `${state.gwURLString}ipfs/${state.webuiCid}/`
break
case 'publicGatewayUrl':
state.pubGwURL = new URL(change.newValue)
Expand Down
118 changes: 12 additions & 106 deletions add-on/src/lib/ipfs-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,6 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
const isIgnored = (id) => ignoredRequests.get(id) !== undefined
const errorInFlight = new LRU({ max: 3, maxAge: 1000 })

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
}
}
}

// Returns a canonical hostname representing the site from url
// Main reason for this is unwrapping DNSLink from local subdomain
// <fqdn>.ipns.localhost → <fqdn>
Expand Down Expand Up @@ -208,59 +187,25 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru

// Special handling of requests made to API
if (sameGateway(request.url, state.apiURL)) {
// 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: *-extension://" header from 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.
// 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
// https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin
if (origin == null || origin === 'null') {
return true
}
// Firefox Nightly 65 sets moz-extension://{extension-installation-id}
// Chromium Beta 72 sets chrome-extension://{uid}
if (origin &&

// 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) {
return true
}
return false
}
origin.startsWith('chrome-extension://')) &&
new URL(origin).origin === webExtensionOrigin

// Remove Origin header matching webExtensionOrigin
const foundAt = request.requestHeaders.findIndex(h => h.name === 'Origin' && isWebExtensionOrigin(h.value))
Expand Down Expand Up @@ -317,41 +262,6 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
const state = getState()
if (!state.active) return

// Special handling of requests made to API
if (sameGateway(request.url, state.apiURL)) {
// 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
Expand Down Expand Up @@ -651,10 +561,6 @@ function normalizedUnhandledIpfsProtocol (request, pubGwUrl) {
}
}

function findHeaderIndex (name, headers) {
return headers.findIndex(x => x.name && x.name.toLowerCase() === name.toLowerCase())
}

// RECOVERY OF FAILED REQUESTS
// ===================================================================

Expand Down
5 changes: 4 additions & 1 deletion add-on/src/lib/precache.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ const drain = require('pull-stream/sinks/drain')
const toStream = require('it-to-stream')
const tar = require('tar-stream')
const CID = require('cids')
const { webuiCid } = require('./state')

const debug = require('debug')
const log = debug('ipfs-companion:precache')
log.error = debug('ipfs-companion:precache:error')

// Web UI release that should be precached
// WARNING: do not remove this constant, as its used in package.json
const webuiCid = 'Qmexhq2sBHnXQbvyP2GfUdbnY7HCagH2Mw5vUNSBn2nxip' // v2.7.2

const PRECACHE_ARCHIVES = [
{ tarPath: '/dist/precache/webui.tar', cid: webuiCid }
]
Expand Down
20 changes: 0 additions & 20 deletions add-on/src/lib/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 'Qmexhq2sBHnXQbvyP2GfUdbnY7HCagH2Mw5vUNSBn2nxip' // v2.7.2

function initState (options, overrides) {
// we store options and some pregenerated values to avoid async storage
// reads and minimize performance impact on overall browsing experience
Expand All @@ -29,21 +25,6 @@ function initState (options, overrides) {
state.gwURLString = state.gwURL.toString()
delete state.customGatewayUrl
state.dnslinkPolicy = String(options.dnslinkPolicy) === 'false' ? false : options.dnslinkPolicy
state.webuiCid = webuiCid

// TODO: unify the way webui is opened
// - https://github.com/ipfs-shipyard/ipfs-companion/pull/737
// - https://github.com/ipfs-shipyard/ipfs-companion/pull/738
// Context: previously, we loaded webui from gateway port
// (`${state.gwURLString}ipfs/${state.webuiCid}/`) because API port
// has hardcoded list of whitelisted webui versions.
// To enable API access from webui loaded from Gateway port Companion
// removed Origin header to avoid CORS, now we move away from that
// complexity and for now just load version whitelisted on API port.
// In the future, we want to load webui from $webuiCid.ipfs.localhost
// and whitelist API access from that specific hostname
// by appending it to API.HTTPHeaders.Access-Control-Allow-Origin list
// When that is possible, we can remove Origin manipulation (see PR #737 for PoC)
state.webuiRootUrl = `${state.apiURLString}webui/`

// attach helper functions
Expand All @@ -69,4 +50,3 @@ function initState (options, overrides) {

exports.initState = initState
exports.offlinePeerCount = offlinePeerCount
exports.webuiCid = webuiCid
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"build:bundle-all": "cross-env RELEASE_CHANNEL=${RELEASE_CHANNEL:=dev} run-s bundle:chromium bundle:brave:$RELEASE_CHANNEL bundle:firefox:$RELEASE_CHANNEL",
"build:rename-artifacts": "./scripts/rename-artifacts.js",
"precache:clean": "shx rm -rf add-on/dist/precache",
"precache:webui:cid": "shx grep 'const webuiCid' add-on/src/lib/state.js | shx sed \"s/^const webuiCid = '//\" | shx sed \"s/'.*$//\"",
"precache:webui:cid": "shx grep 'const webuiCid' add-on/src/lib/precache.js | shx sed \"s/^const webuiCid = '//\" | shx sed \"s/'.*$//\"",
"precache:webui": "shx mkdir -p add-on/dist/precache && ipfs-or-gateway -c $(npm run -s precache:webui:cid) -p add-on/dist/precache/webui.tar --archive",
"bundle": "run-s bundle:*",
"bundle:chromium": "run-s precache:webui && shx cat add-on/manifest.common.json add-on/manifest.chromium.json | json --deep-merge > add-on/manifest.json && web-ext build -a build/chromium && run-s build:rename-artifacts",
Expand Down Expand Up @@ -158,7 +158,6 @@
"tachyons": "4.11.1",
"tar-stream": "2.1.2",
"timers-browserify-full": "0.0.1",
"uri-to-multiaddr": "3.0.1",
"webextension-polyfill": "0.6.0",
"webrtc-ips": "0.1.4"
},
Expand Down
64 changes: 12 additions & 52 deletions test/functional/lib/ipfs-request-workarounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,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 with extension:// Origin', 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
Expand All @@ -125,8 +126,9 @@ describe('modifyRequest processing', function () {
})
})

describe('a request to <apiURL>/api/v0/ with Origin=chrome-extension://{extension-installation-id}', function () {
describe('should have it removed if Origin: chrome-extension://{extension-installation-id}', function () {
it('should remove Origin header with chrome-extension://', 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
Expand All @@ -146,65 +148,23 @@ describe('modifyRequest processing', function () {
})

describe('a request to <apiURL>/api/v0/ with Origin=null', function () {
it('should remove Origin header ', async function () {
// set vendor-specific Origin for WebExtension context
it('should keep the "Origin: null" header ', 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)
// 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 nullOriginHeader = { name: 'Origin', value: 'null' }
const request = {
requestHeaders: [bogusOriginHeader],
requestHeaders: [nullOriginHeader],
type: 'xmlhttprequest',
url: `${state.apiURLString}api/v0/id`
}
modifyRequest.onBeforeRequest(request) // executes before onBeforeSendHeaders, may mutate state
expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders).to.not.include(bogusOriginHeader)
browser.runtime.getURL.flush()
})
})

// 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)

expect(modifyRequest.onBeforeSendHeaders(request).requestHeaders).to.include(nullOriginHeader)
browser.runtime.getURL.flush()
})
})
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -14965,7 +14965,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"

uri-to-multiaddr@3.0.1, uri-to-multiaddr@^3.0.1:
uri-to-multiaddr@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/uri-to-multiaddr/-/uri-to-multiaddr-3.0.1.tgz#460bd5d78074002c47b60fdc456efd009e7168ae"
integrity sha512-77slJiNB/IxM35zgflBEgp8T8ywpyYAbEh8Ezdnq7kAuA6TRg6wfvNTi4Uixfh6CsPv9K2fAkI5+E4C2dw3tXA==
Expand Down

0 comments on commit aec856f

Please sign in to comment.