Skip to content

Commit

Permalink
feat: window.ipfs.enable()
Browse files Browse the repository at this point in the history
This change adds async `enable` function that takes optional list of commands
to grant access to via user prompt.

The goal is to move away from synchronous way of accessing API instance
and provide UX incentive to use `window.ipfs.enable` instead.

When called without any arguments, command will just return API instance
equal to the old `window.ipfs` or throw an error if IPFS Proxy is
disabled in Preferences.

When called  with options object `{ commands: ['id','peers'] }`
access rights for specified commands will be validated:

- if any of the commands is denied or blocked, function will throw
- if any of the commands require user approval, user will be presented
with a single prompt dialog that lists all requested permissions and URL
that requests them
  - if user approves, ACLs are saved and future calls will not trigger
  prompt
  - if user denies, ACLs are saved and an error is thrown for current
  and all future executions (unless user removed scope from blacklist)

TODO (to be addressed in future commits)

- add deprecation warning to API calls executed on `window.ipfs`
- improve UX of permission dialog
- add ability to return `ipfsx` version fo the API
- disable `window.ipfs` injection via manifest in Chromium
- stop exposing methods on `window.ipfs`
    - minimize the size of content script responsible for `window.ipfs`
    - lazy-init IPFS Proxy client on first call to `window.ipfs.enable()`
  • Loading branch information
lidel committed Dec 12, 2018
1 parent 4e6f65d commit b0110a0
Show file tree
Hide file tree
Showing 18 changed files with 520 additions and 94 deletions.
4 changes: 2 additions & 2 deletions add-on/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
38 changes: 27 additions & 11 deletions add-on/src/contentScripts/ipfs-proxy/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,38 @@

const _Buffer = Buffer

// TODO: this should not be injected by default into every page,
// 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')
const { call } = require('postmsg-rpc')

function windowIpfs2 () {
return Object.freeze({
enable: async (args) => {
// TODO: pass args to ipfs-postmsg-proxy constructor
// to trigger user prompt if list of requested capabilities is not empty
const proxyClient = createProxyClient()
console.log('Called window.ipfs.enable', args)
return proxyClient
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 call('proxy.enable', opts)
// Additional client-side features
if (opts) {
if (opts.ipfsx === true) {
// TODO: wrap API in https://github.com/alanshaw/ipfsx
// return ipfsx(proxyClient)
}
}
return Object.freeze(proxyClient)
}
})
}
}

function createWindowIpfs () {
const proxyClient = createProxyClient()
Object.assign(proxyClient, createEnableCommand(proxyClient))
// TODO: return thin object with lazy-init inside of window.ipfs.enable
return Object.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 || windowIpfs2()
window.ipfs = window.ipfs || createWindowIpfs()
23 changes: 14 additions & 9 deletions add-on/src/lib/ipfs-proxy/access-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -101,42 +102,45 @@ 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 () => {
const allAccess = await this._getAllAccess(scope)

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

Expand Down Expand Up @@ -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
54 changes: 54 additions & 0 deletions add-on/src/lib/ipfs-proxy/enable-command.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const { inApiWhitelist, createProxyWhitelistError } = require('./pre-api-whitelist')
const { inNoAclPromptWhitelist, 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 (!inApiWhitelist(command)) {
throw createProxyWhitelistError(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
if (!inNoAclPromptWhitelist(command)) {
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
18 changes: 15 additions & 3 deletions add-on/src/lib/ipfs-proxy/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 { createPreApiWhitelist } = require('./pre-api-whitelist')
const { createPreAcl } = require('./pre-acl')
const createPreMfsScope = require('./pre-mfs-scope')
const createRequestAccess = require('./request-access')

Expand All @@ -28,7 +30,8 @@ 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),
Expand All @@ -38,6 +41,15 @@ function createIpfsProxy (getIpfs, getState) {
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 = () => {
Expand Down
51 changes: 39 additions & 12 deletions add-on/src/lib/ipfs-proxy/pre-acl.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,54 @@ const ACL_WHITELIST = Object.freeze(require('./acl-whitelist.json'))
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')
if (!getState().ipfsProxy) throw new Error('User disabled access to API proxy in IPFS Companion')

// No need to verify access if permission is on the whitelist
if (ACL_WHITELIST.includes(permission)) return args
if (inNoAclPromptWhitelist(permission)) return args

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
function inNoAclPromptWhitelist (permission) {
return ACL_WHITELIST.includes(permission)
}

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 is not on the ACL_WHITELIST
// 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: {
isIpfsProxyError: true,
isIpfsProxyAclError: true,
permissions,
scope
}
}
return err
}

module.exports = {
createPreAcl,
createProxyAclError,
inNoAclPromptWhitelist,
ACL_WHITELIST
}
39 changes: 30 additions & 9 deletions add-on/src/lib/ipfs-proxy/pre-api-whitelist.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,38 @@ const API_WHITELIST = Object.freeze(require('./api-whitelist.json'))
// 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
if (!inApiWhitelist(permission)) {
throw createProxyWhitelistError(permission)
}

return args
}
}

module.exports = createPreApiWhitelist
function inApiWhitelist (permission) {
// Fail fast if API or namespace is not explicitly whitelisted
const permRoot = permission.split('.')[0]
return API_WHITELIST.includes(permRoot) || API_WHITELIST.includes(permission)
}

// Standardized error thrown when a command is not on the API_WHITELIST
// TODO: return errors following conventions from https://github.com/ipfs/js-ipfs/pull/1746
function createProxyWhitelistError (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: {
isIpfsProxyError: true,
isIpfsProxyWhitelistError: true,
permissions
}
}
return err
}

module.exports = {
createPreApiWhitelist,
createProxyWhitelistError,
inApiWhitelist,
API_WHITELIST
}
Loading

0 comments on commit b0110a0

Please sign in to comment.