diff --git a/add-on/_locales/en/messages.json b/add-on/_locales/en/messages.json index c97e29ebf..49834a847 100644 --- a/add-on/_locales/en/messages.json +++ b/add-on/_locales/en/messages.json @@ -460,11 +460,11 @@ "description": "Button title for revoking a permission (page_proxyAcl_revoke_button_title)" }, "page_proxyAccessDialog_title": { - "message": "Allow $1 to access ipfs.$2?", + "message": "Should IPFS Companion allow «$1» to access «$2» at the connected node?", "description": "Main title of the access permission dialog (page_proxyAccessDialog_title)" }, "page_proxyAccessDialog_wildcardCheckbox_label": { - "message": "Apply this decision to all permissions in this scope", + "message": "Apply this decision to all current and future permissions in this scope", "description": "Label for the apply permissions to all checkbox (page_proxyAccessDialog_wildcardCheckbox_label)" }, "page_proxyAcl_revoke_all_button_title": { diff --git a/add-on/src/contentScripts/ipfs-proxy/page.js b/add-on/src/contentScripts/ipfs-proxy/page.js index 8864348ec..3926af706 100644 --- a/add-on/src/contentScripts/ipfs-proxy/page.js +++ b/add-on/src/contentScripts/ipfs-proxy/page.js @@ -1,7 +1,52 @@ 'use strict' -const { createProxyClient } = require('ipfs-postmsg-proxy') const _Buffer = Buffer +const { assign, freeze } = Object + +// TODO: (wip) this should not be injected by default into every page, +// instead should be lazy-loaded when .enable() method is called for the first time +const { createProxyClient } = require('ipfs-postmsg-proxy') + +function createEnableCommand (proxyClient) { + return { + enable: async (opts) => { + // Send message to proxy server for additional validation + // eg. trigger user prompt if a list of requested capabilities is not empty + // or fail fast and throw if IPFS Proxy is disabled globally + await require('postmsg-rpc').call('proxy.enable', opts) + // Create client + const proxyClient = createProxyClient() + // Additional client-side features + if (opts && opts.experiments) { + if (opts.experiments.ipfsx) { + // Experiment: wrap API with https://github.com/alanshaw/ipfsx + return freeze(require('ipfsx')(proxyClient)) + } + } + return freeze(proxyClient) + } + } +} + +function createWindowIpfs () { + const proxyClient = createProxyClient() + + // Add deprecation warning to window.ipfs. + for (let cmd in proxyClient) { + let fn = proxyClient[cmd] + proxyClient[cmd] = function () { + console.warn('Calling commands directly on window.ipfs is deprecated and will be removed on 2019-04-01. Use API instance returned by window.ipfs.enable() instead. More: https://github.com/ipfs-shipyard/ipfs-companion/blob/master/docs/window.ipfs.md') + return fn.apply(this, arguments) + } + } + + // TODO: return thin object with lazy-init inside of window.ipfs.enable + assign(proxyClient, createEnableCommand()) + + return freeze(proxyClient) +} +// TODO: we should remove Buffer and add support for Uint8Array/ArrayBuffer natively +// See: https://github.com/ipfs/interface-ipfs-core/issues/404 window.Buffer = window.Buffer || _Buffer -window.ipfs = window.ipfs || createProxyClient() +window.ipfs = window.ipfs || createWindowIpfs() diff --git a/add-on/src/lib/ipfs-proxy/access-control.js b/add-on/src/lib/ipfs-proxy/access-control.js index c8f2f243b..71dc91b6a 100644 --- a/add-on/src/lib/ipfs-proxy/access-control.js +++ b/add-on/src/lib/ipfs-proxy/access-control.js @@ -78,6 +78,7 @@ class AccessControl extends EventEmitter { ) } + // Return current access rights to given permission. async getAccess (scope, permission) { if (!isScope(scope)) throw new TypeError('Invalid scope') if (!isString(permission)) throw new TypeError('Invalid permission') @@ -101,12 +102,15 @@ class AccessControl extends EventEmitter { } } - return allow == null ? null : { scope: matchingScope, permission, allow } + return allow == null ? null : { scope: matchingScope, permissions: [permission], allow } } - async setAccess (scope, permission, allow) { + // Set access rights to given permissions. + // 'permissions' can be an array of strings or a single string + async setAccess (scope, permissions, allow) { + permissions = Array.isArray(permissions) ? permissions : [permissions] if (!isScope(scope)) throw new TypeError('Invalid scope') - if (!isString(permission)) throw new TypeError('Invalid permission') + if (!isStringArray(permissions)) throw new TypeError('Invalid permissions') if (!isBoolean(allow)) throw new TypeError('Invalid allow') return this._writeQ.add(async () => { @@ -114,29 +118,29 @@ class AccessControl extends EventEmitter { // Trying to set access for non-wildcard permission, when wildcard // permission is already granted? - if (allAccess.has('*') && permission !== '*') { + if (allAccess.has('*') && !permissions.includes('*')) { if (allAccess.get('*') === allow) { // Noop if requested access is the same as access for wildcard grant - return { scope, permission, allow } + return { scope, permissions, allow } } else { // Fail if requested access is the different to access for wildcard grant - throw new Error(`Illegal set access for ${permission} when wildcard exists`) + throw new Error(`Illegal set access for '${permissions}' when wildcard exists`) } } // If setting a wildcard permission, remove existing grants - if (permission === '*') { + if (permissions.includes('*')) { allAccess.clear() } - allAccess.set(permission, allow) + permissions.forEach(permission => allAccess.set(permission, allow)) const accessKey = this._getAccessKey(scope) await this._storage.local.set({ [accessKey]: JSON.stringify(Array.from(allAccess)) }) await this._addScope(scope) - return { scope, permission, allow } + return { scope, permissions, allow } }) } @@ -199,4 +203,5 @@ const isScope = (value) => { } const isString = (value) => Object.prototype.toString.call(value) === '[object String]' +const isStringArray = (value) => Array.isArray(value) && value.length && value.every(isString) const isBoolean = (value) => value === true || value === false diff --git a/add-on/src/lib/ipfs-proxy/acl-whitelist.json b/add-on/src/lib/ipfs-proxy/acl-whitelist.json deleted file mode 100644 index 17ac05ae5..000000000 --- a/add-on/src/lib/ipfs-proxy/acl-whitelist.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - "block.get", - "block.stat", - "dag.get", - "dag.tree", - "dht.get", - "dht.findprovs", - "dht.findpeer", - "dht.query", - "files.cat", - "files.catPullStream", - "files.get", - "files.getReadableStream", - "files.getPullStream", - "object.get", - "object.data", - "object.links", - "object.stat", - "pubsub.subscribe", - "pubsub.unsubscribe", - "pubsub.peers", - "swarm.peers", - "swarm.addrs", - "swarm.localAddrs" -] diff --git a/add-on/src/lib/ipfs-proxy/api-whitelist.json b/add-on/src/lib/ipfs-proxy/command-whitelist.json similarity index 100% rename from add-on/src/lib/ipfs-proxy/api-whitelist.json rename to add-on/src/lib/ipfs-proxy/command-whitelist.json diff --git a/add-on/src/lib/ipfs-proxy/enable-command.js b/add-on/src/lib/ipfs-proxy/enable-command.js new file mode 100644 index 000000000..918ba50bd --- /dev/null +++ b/add-on/src/lib/ipfs-proxy/enable-command.js @@ -0,0 +1,52 @@ +const { inCommandWhitelist, createCommandWhitelistError } = require('./pre-command') +const { createProxyAclError } = require('./pre-acl') + +// Artificial API command responsible for backend orchestration +// during window.ipfs.enable() +function createEnableCommand (getIpfs, getState, getScope, accessControl, requestAccess) { + return async (opts) => { + const scope = await getScope() + console.log(`[ipfs-companion] received window.ipfs.enable request from ${scope}`, opts) + + // Check if all access to the IPFS node is disabled + if (!getState().ipfsProxy) throw new Error('User disabled access to API proxy in IPFS Companion') + + // NOOP if .enable() was called without any arguments + if (!opts) return + + // Validate and prompt for any missing permissions in bulk + // if a list of needed commands is announced up front + if (opts.commands) { + let missingAcls = [] + let deniedAcls = [] + for (let command of opts.commands) { + // Fail fast if command is not allowed to be proxied at all + if (!inCommandWhitelist(command)) { + throw createCommandWhitelistError(command) + } + // Get the current access flag to decide if it should be added + // to the list of permissions to be prompted about in the next step + let access = await accessControl.getAccess(scope, command) + if (!access) { + missingAcls.push(command) + } else if (access.allow !== true) { + deniedAcls.push(command) + } + } + // Fail fast if user already denied any of requested permissions + if (deniedAcls.length) { + throw createProxyAclError(scope, deniedAcls) + } + // Display a single prompt with all missing permissions + if (missingAcls.length) { + const { allow, wildcard } = await requestAccess(scope, missingAcls) + let access = await accessControl.setAccess(scope, wildcard ? '*' : missingAcls, allow) + if (!access.allow) { + throw createProxyAclError(scope, missingAcls) + } + } + } + } +} + +module.exports = createEnableCommand diff --git a/add-on/src/lib/ipfs-proxy/index.js b/add-on/src/lib/ipfs-proxy/index.js index c261d3034..f895ae1b9 100644 --- a/add-on/src/lib/ipfs-proxy/index.js +++ b/add-on/src/lib/ipfs-proxy/index.js @@ -3,9 +3,11 @@ const browser = require('webextension-polyfill') const { createProxyServer, closeProxyServer } = require('ipfs-postmsg-proxy') +const { expose } = require('postmsg-rpc') const AccessControl = require('./access-control') -const createPreApiWhitelist = require('./pre-api-whitelist') -const createPreAcl = require('./pre-acl') +const createEnableCommand = require('./enable-command') +const { createPreCommand } = require('./pre-command') +const { createPreAcl } = require('./pre-acl') const createPreMfsScope = require('./pre-mfs-scope') const createRequestAccess = require('./request-access') @@ -28,16 +30,26 @@ function createIpfsProxy (getIpfs, getState) { return origin + pathname } - const proxy = createProxyServer(getIpfs, { + // https://github.com/ipfs-shipyard/ipfs-postmsg-proxy#api + const proxyCfg = { addListener: (_, handler) => port.onMessage.addListener(handler), removeListener: (_, handler) => port.onMessage.removeListener(handler), postMessage: (data) => port.postMessage(data), getMessageData: (d) => d, pre: (fnName) => [ - createPreApiWhitelist(fnName), + createPreCommand(fnName), createPreAcl(fnName, getState, getScope, accessControl, requestAccess), createPreMfsScope(fnName, getScope, getIpfs) ] + } + + const proxy = createProxyServer(getIpfs, proxyCfg) + + // Extend proxy with Companion-specific commands: + const enableCommand = createEnableCommand(getIpfs, getState, getScope, accessControl, requestAccess) + Object.assign(proxy, { + // window.ipfs.enable(opts) + 'proxy.enable': expose('proxy.enable', enableCommand, proxyCfg) }) const close = () => { diff --git a/add-on/src/lib/ipfs-proxy/pre-acl.js b/add-on/src/lib/ipfs-proxy/pre-acl.js index 3061d1285..b8b2a7612 100644 --- a/add-on/src/lib/ipfs-proxy/pre-acl.js +++ b/add-on/src/lib/ipfs-proxy/pre-acl.js @@ -1,34 +1,58 @@ -// This are the functions that DO NOT require an allow/deny decision by the user. -// All other IPFS functions require authorization. -const ACL_WHITELIST = Object.freeze(require('./acl-whitelist.json')) - // Creates a "pre" function that is called prior to calling a real function // on the IPFS instance. It will throw if access is denied, and ask the user if // no access decision has been made yet. function createPreAcl (permission, getState, getScope, accessControl, requestAccess) { return async (...args) => { // Check if all access to the IPFS node is disabled - if (!getState().ipfsProxy) throw new Error('User disabled access to IPFS') - - // No need to verify access if permission is on the whitelist - if (ACL_WHITELIST.includes(permission)) return args + if (!getState().ipfsProxy) throw new Error('User disabled access to API proxy in IPFS Companion') const scope = await getScope() - let access = await accessControl.getAccess(scope, permission) - - if (!access) { - const { allow, wildcard } = await requestAccess(scope, permission) - access = await accessControl.setAccess(scope, wildcard ? '*' : permission, allow) - } + const access = await getAccessWithPrompt(accessControl, requestAccess, scope, permission) if (!access.allow) { - const err = new Error(`User denied access to ${permission}`) - err.output = { payload: { isIpfsProxyAclError: true, permission, scope } } - throw err + throw createProxyAclError(scope, permission) } return args } } -module.exports = createPreAcl +async function getAccessWithPrompt (accessControl, requestAccess, scope, permission) { + let access = await accessControl.getAccess(scope, permission) + if (!access) { + const { allow, wildcard } = await requestAccess(scope, permission) + access = await accessControl.setAccess(scope, wildcard ? '*' : permission, allow) + } + return access +} + +// Standardized error thrown when a command access is denied +// TODO: return errors following conventions from https://github.com/ipfs/js-ipfs/pull/1746 +function createProxyAclError (scope, permission) { + const err = new Error(`User denied access to selected commands over IPFS proxy: ${permission}`) + const permissions = Array.isArray(permission) ? permission : [permission] + err.output = { + payload: { + // Error follows convention from https://github.com/ipfs/js-ipfs/pull/1746/files + code: 'ERR_IPFS_PROXY_ACCESS_DENIED', + permissions, + scope, + // TODO: remove below after deprecation period ends with Q1 + get isIpfsProxyAclError () { + console.warn("[ipfs-companion] reading .isIpfsProxyAclError from Ipfs Proxy errors is deprecated, use '.code' instead") + return true + }, + get permission () { + if (!this.permissions || !this.permissions.length) return undefined + console.warn("[ipfs-companion] reading .permission from Ipfs Proxy errors is deprecated, use '.permissions' instead") + return this.permissions[0] + } + } + } + return err +} + +module.exports = { + createPreAcl, + createProxyAclError +} diff --git a/add-on/src/lib/ipfs-proxy/pre-api-whitelist.js b/add-on/src/lib/ipfs-proxy/pre-api-whitelist.js deleted file mode 100644 index 816e985bb..000000000 --- a/add-on/src/lib/ipfs-proxy/pre-api-whitelist.js +++ /dev/null @@ -1,24 +0,0 @@ -// Some APIs are too sensitive to be exposed to dapps on every website -// We follow a safe security practice of denying everything and allowing access -// to a pre-approved list of known APIs. This way if a new API is added -// it will be blocked by default, until it is explicitly enabled below. -const API_WHITELIST = Object.freeze(require('./api-whitelist.json')) - -// Creates a "pre" function that is called prior to calling a real function -// on the IPFS instance. It will throw if access is denied due to API not being whitelisted -function createPreApiWhitelist (permission) { - return async (...args) => { - // Fail fast if API or namespace is not explicitly whitelisted - const permRoot = permission.split('.')[0] - if (!(API_WHITELIST.includes(permRoot) || API_WHITELIST.includes(permission))) { - console.log(`[ipfs-companion] Access to ${permission} API over window.ipfs is blocked. If you feel it should be allowed, open an issue at https://github.com/ipfs-shipyard/ipfs-companion/issues/new`) - const err = new Error(`Access to ${permission} API is globally blocked for window.ipfs`) - err.output = { payload: { isIpfsProxyWhitelistError: true, permission } } - throw err - } - - return args - } -} - -module.exports = createPreApiWhitelist diff --git a/add-on/src/lib/ipfs-proxy/pre-command.js b/add-on/src/lib/ipfs-proxy/pre-command.js new file mode 100644 index 000000000..564e4f33f --- /dev/null +++ b/add-on/src/lib/ipfs-proxy/pre-command.js @@ -0,0 +1,55 @@ +// Some API commands are too sensitive to be exposed to dapps on every website +// We follow a safe security practice of denying everything and allowing access +// to a pre-approved list of known APIs. This way if a new API is added +// it will be blocked by default, until it is explicitly enabled below. +const COMMAND_WHITELIST = Object.freeze(require('./command-whitelist.json')) + +// Creates a "pre" function that is called prior to calling a real function +// on the IPFS instance. It will throw if access is denied due to API not being whitelisted +function createPreCommand (permission) { + return async (...args) => { + if (!inCommandWhitelist(permission)) { + throw createCommandWhitelistError(permission) + } + return args + } +} + +function inCommandWhitelist (permission) { + // Fail fast if API or namespace is not explicitly whitelisted + const permRoot = permission.split('.')[0] + return COMMAND_WHITELIST.includes(permRoot) || COMMAND_WHITELIST.includes(permission) +} + +// Standardized error thrown when a command is not on the COMMAND_WHITELIST +// TODO: return errors following conventions from https://github.com/ipfs/js-ipfs/pull/1746 +function createCommandWhitelistError (permission) { + const permissions = Array.isArray(permission) ? permission : [permission] + console.warn(`[ipfs-companion] Access to '${permission}' commands over window.ipfs is blocked. If you feel it should be allowed, open an issue at https://github.com/ipfs-shipyard/ipfs-companion/issues/new`) + const err = new Error(`Access to '${permission}' commands over IPFS Proxy is globally blocked`) + err.output = { + payload: { + // Error follows convention from https://github.com/ipfs/js-ipfs/pull/1746/files + code: 'ERR_IPFS_PROXY_ACCESS_DENIED', + permissions, + // TODO: remove below after deprecation period ends with Q1 + get isIpfsProxyWhitelistError () { + console.warn("[ipfs-companion] reading .isIpfsProxyWhitelistError from Ipfs Proxy errors is deprecated, use '.code' instead") + return true + }, + get permission () { + if (!this.permissions || !this.permissions.length) return undefined + console.warn("[ipfs-companion] reading .permission from Ipfs Proxy errors is deprecated, use '.permissions' instead") + return this.permissions[0] + } + } + } + return err +} + +module.exports = { + createPreCommand, + createCommandWhitelistError, + inCommandWhitelist, + COMMAND_WHITELIST +} diff --git a/add-on/src/lib/ipfs-proxy/request-access.js b/add-on/src/lib/ipfs-proxy/request-access.js index 686bfcef0..c8ba8d3df 100644 --- a/add-on/src/lib/ipfs-proxy/request-access.js +++ b/add-on/src/lib/ipfs-proxy/request-access.js @@ -8,14 +8,17 @@ const DIALOG_PATH = 'dist/pages/proxy-access-dialog/index.html' const DIALOG_PORT_NAME = 'proxy-access-dialog' function createRequestAccess (browser, screen) { - // piggybacker allows multiple requests for access to the same permission to + // piggybacker allows multiple requests for access to the same permissions to // receive the same response i.e. don't popup multiple dialogs for the - // same permission request. - return piggyback(requestAccess, (scope, permission) => `${scope}/${permission}`) + // same permissions request. + return piggyback(requestAccess, (scope, permissions) => `${scope}/${permissions}`) - async function requestAccess (scope, permission, opts) { + async function requestAccess (scope, permissions, opts) { opts = opts || {} + // TODO: cleanup so below stub is not needed + permissions = Array.isArray(permissions) ? permissions : [permissions] + const url = browser.extension.getURL(opts.dialogPath || DIALOG_PATH) let dialogTabId @@ -37,9 +40,9 @@ function createRequestAccess (browser, screen) { } // Resolves with { allow, wildcard } - const userResponse = getUserResponse(dialogTabId, scope, permission, opts) + const userResponse = getUserResponse(dialogTabId, scope, permissions, opts) // Never resolves, might reject if user closes the tab - const userTabRemoved = getUserTabRemoved(dialogTabId, scope, permission) + const userTabRemoved = getUserTabRemoved(dialogTabId, scope, permissions) let response @@ -56,7 +59,7 @@ function createRequestAccess (browser, screen) { return response } - function getUserResponse (tabId, scope, permission, opts) { + function getUserResponse (tabId, scope, permissions, opts) { opts = opts || {} const dialogPortName = opts.dialogPortName || DIALOG_PORT_NAME @@ -69,8 +72,8 @@ function createRequestAccess (browser, screen) { browser.runtime.onConnect.removeListener(onPortConnect) - // Tell the dialog what scope/permission it is about - port.postMessage({ scope, permission }) + // Tell the dialog what scope/permissions it is about + port.postMessage({ scope, permissions }) // Wait for the user response const onMessage = ({ allow, wildcard }) => { @@ -92,14 +95,14 @@ function createRequestAccess (browser, screen) { // Since the dialog is a tab not a real dialog it can be closed by the user // with no response, this function creates a promise that will reject if the tab // is removed. - function getUserTabRemoved (tabId, scope, permission) { + function getUserTabRemoved (tabId, scope, permissions) { let onTabRemoved const userTabRemoved = new Promise((resolve, reject) => { onTabRemoved = (id) => { if (id !== tabId) return - const err = new Error(`Failed to obtain access response for ${permission} at ${scope}`) - err.output = { payload: { isIpfsProxyAclError: true, scope, permission } } + const err = new Error(`IPFS Proxy failed to obtain access response for '${permissions}' at ${scope}`) + err.output = { payload: { isIpfsProxyError: true, isIpfsProxyAclError: true, scope, permissions } } reject(err) } browser.tabs.onRemoved.addListener(onTabRemoved) diff --git a/add-on/src/pages/proxy-access-dialog/page.js b/add-on/src/pages/proxy-access-dialog/page.js index a7d5fcf81..1ac35fddb 100644 --- a/add-on/src/pages/proxy-access-dialog/page.js +++ b/add-on/src/pages/proxy-access-dialog/page.js @@ -8,7 +8,7 @@ function createProxyAccessDialogPage (i18n) { const onDeny = () => emit('deny') const onWildcardToggle = () => emit('wildcardToggle') - const { loading, scope, permission } = state + const { loading, scope, permissions } = state return html`
@@ -16,9 +16,9 @@ function createProxyAccessDialogPage (i18n) { ${loading ? null : html`

- ${i18n.getMessage('page_proxyAccessDialog_title', [scope, permission])} + ${i18n.getMessage('page_proxyAccessDialog_title', [scope, permissions.join(', ')])}

-

+