diff --git a/add-on/_locales/en/messages.json b/add-on/_locales/en/messages.json index c3a696c24..6ae6c8578 100644 --- a/add-on/_locales/en/messages.json +++ b/add-on/_locales/en/messages.json @@ -355,22 +355,22 @@ "description": "Message displayed when no permissions have been granted (page_proxyAcl_no_perms)" }, "page_proxyAcl_confirm_revoke": { - "message": "Revoke permission $PERMISSION$ for $ORIGIN$?", - "description": "Confirmation message for revoking a permission for an origin (page_proxyAcl_confirm_revoke)", + "message": "Revoke permission $PERMISSION$ for $SCOPE$?", + "description": "Confirmation message for revoking a permission for a scope (page_proxyAcl_confirm_revoke)", "placeholders": { "permission": { "content": "$1" }, - "origin": { + "scope": { "content": "$2" } } }, "page_proxyAcl_confirm_revoke_all": { - "message": "Revoke all permissions for $ORIGIN$?", - "description": "Confirmation message for revoking all permissions for an origin (page_proxyAcl_confirm_revoke_all)", + "message": "Revoke all permissions for $SCOPE$?", + "description": "Confirmation message for revoking all permissions for an scope (page_proxyAcl_confirm_revoke_all)", "placeholders": { - "origin": { + "scope": { "content": "$1" } } @@ -401,10 +401,10 @@ } }, "page_proxyAccessDialog_title": { - "message": "Allow $ORIGIN$ to access ipfs.$PERMISSION$?", + "message": "Allow $SCOPE$ to access ipfs.$PERMISSION$?", "description": "Main title of the access permission dialog (page_proxyAccessDialog_title)", "placeholders": { - "origin": { + "scope": { "content": "$1" }, "permission": { @@ -413,13 +413,8 @@ } }, "page_proxyAccessDialog_wildcardCheckbox_label": { - "message": "Apply to all permissions for $ORIGIN$", - "description": "Label for the apply permissions to all checkbox (page_proxyAccessDialog_wildcardCheckbox_label)", - "placeholders": { - "origin": { - "content": "$1" - } - } + "message": "Apply this decision to all permissions in this scope", + "description": "Label for the apply permissions to all checkbox (page_proxyAccessDialog_wildcardCheckbox_label)" }, "page_proxyAcl_revoke_all_button_title": { "message": "Revoke all permissions", diff --git a/add-on/src/lib/ipfs-proxy/access-control.js b/add-on/src/lib/ipfs-proxy/access-control.js index 08a4959e0..c8f2f243b 100644 --- a/add-on/src/lib/ipfs-proxy/access-control.js +++ b/add-on/src/lib/ipfs-proxy/access-control.js @@ -16,67 +16,108 @@ class AccessControl extends EventEmitter { async _onStorageChange (changes) { const prefix = this._storageKeyPrefix - const aclChangeKeys = Object.keys(changes).filter((key) => key.startsWith(prefix)) + const scopesKey = this._getScopesKey() + const aclChangeKeys = Object.keys(changes).filter((key) => { + return key !== scopesKey && key.startsWith(prefix) + }) if (!aclChangeKeys.length) return - // Map { origin => Map { permission => allow } } + // Map { scope => Map { permission => allow } } this.emit('change', aclChangeKeys.reduce((aclChanges, key) => { return aclChanges.set( - key.slice(prefix.length + 1), + key.slice(prefix.length + ('.access'.length) + 1), new Map(JSON.parse(changes[key].newValue)) ) }, new Map())) } - _getGrantsKey (origin) { - return `${this._storageKeyPrefix}.${origin}` + _getScopesKey () { + return `${this._storageKeyPrefix}.scopes` } - // Get a Map of granted permissions for a given origin - // e.g. Map { 'files.add' => true, 'object.new' => false } - async _getGrants (origin) { - const key = this._getGrantsKey(origin) - return new Map( + // Get the list of scopes stored in the acl + async _getScopes () { + const key = this._getScopesKey() + return new Set( JSON.parse((await this._storage.local.get({ [key]: '[]' }))[key]) ) } - async _setGrants (origin, grants) { - const key = this._getGrantsKey(origin) - return this._storage.local.set({ [key]: JSON.stringify(Array.from(grants)) }) + async _addScope (scope) { + const scopes = await this._getScopes() + scopes.add(scope) + + const key = this._getScopesKey() + await this._storage.local.set({ [key]: JSON.stringify(Array.from(scopes)) }) + } + + // ordered by longest first + async _getMatchingScopes (scope) { + const scopes = await this._getScopes() + const origin = new URL(scope).origin + + return Array.from(scopes) + .filter(s => { + if (origin !== new URL(s).origin) return false + return scope.startsWith(s) + }) + .sort((a, b) => b.length - a.length) } - async getAccess (origin, permission) { - if (!isOrigin(origin)) throw new TypeError('Invalid origin') + _getAccessKey (scope) { + return `${this._storageKeyPrefix}.access.${scope}` + } + + // Get a Map of granted permissions for a given scope + // e.g. Map { 'files.add' => true, 'object.new' => false } + async _getAllAccess (scope) { + const key = this._getAccessKey(scope) + return new Map( + JSON.parse((await this._storage.local.get({ [key]: '[]' }))[key]) + ) + } + + async getAccess (scope, permission) { + if (!isScope(scope)) throw new TypeError('Invalid scope') if (!isString(permission)) throw new TypeError('Invalid permission') - const grants = await this._getGrants(origin) + const matchingScopes = await this._getMatchingScopes(scope) + + let allow = null + let matchingScope + + for (matchingScope of matchingScopes) { + const allAccess = await this._getAllAccess(matchingScope) + + if (allAccess.has('*')) { + allow = allAccess.get('*') + break + } - if (grants.has('*')) { - return { origin, permission, allow: grants.get('*') } + if (allAccess.has(permission)) { + allow = allAccess.get(permission) + break + } } - return grants.has(permission) - ? { origin, permission, allow: grants.get(permission) } - : null + return allow == null ? null : { scope: matchingScope, permission, allow } } - async setAccess (origin, permission, allow) { - if (!isOrigin(origin)) throw new TypeError('Invalid origin') + async setAccess (scope, permission, allow) { + if (!isScope(scope)) throw new TypeError('Invalid scope') if (!isString(permission)) throw new TypeError('Invalid permission') if (!isBoolean(allow)) throw new TypeError('Invalid allow') return this._writeQ.add(async () => { - const access = { origin, permission, allow } - const grants = await this._getGrants(origin) + const allAccess = await this._getAllAccess(scope) // Trying to set access for non-wildcard permission, when wildcard // permission is already granted? - if (grants.has('*') && permission !== '*') { - if (grants.get('*') === allow) { + if (allAccess.has('*') && permission !== '*') { + if (allAccess.get('*') === allow) { // Noop if requested access is the same as access for wildcard grant - return access + return { scope, permission, 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`) @@ -85,47 +126,54 @@ class AccessControl extends EventEmitter { // If setting a wildcard permission, remove existing grants if (permission === '*') { - grants.clear() + allAccess.clear() } - grants.set(permission, allow) - await this._setGrants(origin, grants) + 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 access + return { scope, permission, allow } }) } - // Map { origin => Map { permission => allow } } + // Map { scope => Map { permission => allow } } async getAcl () { - const data = await this._storage.local.get() - const prefix = this._storageKeyPrefix + const scopes = await this._getScopes() + const acl = new Map() + + await Promise.all(Array.from(scopes).map(scope => { + return (async () => { + const allAccess = await this._getAllAccess(scope) + acl.set(scope, allAccess) + })() + })) - return Object.keys(data) - .reduce((acl, key) => { - return key.startsWith(prefix) - ? acl.set(key.slice(prefix.length + 1), new Map(JSON.parse(data[key]))) - : acl - }, new Map()) + return acl } // Revoke access to the given permission // if permission is null, revoke all access - async revokeAccess (origin, permission = null) { - if (!isOrigin(origin)) throw new TypeError('Invalid origin') + async revokeAccess (scope, permission = null) { + if (!isScope(scope)) throw new TypeError('Invalid scope') if (permission && !isString(permission)) throw new TypeError('Invalid permission') return this._writeQ.add(async () => { - let grants + let allAccess if (permission) { - grants = await this._getGrants(origin) - if (!grants.has(permission)) return - grants.delete(permission) + allAccess = await this._getAllAccess(scope) + if (!allAccess.has(permission)) return + allAccess.delete(permission) } else { - grants = new Map() + allAccess = new Map() } - await this._setGrants(origin, grants) + const key = this._getAccessKey(scope) + await this._storage.local.set({ [key]: JSON.stringify(Array.from(allAccess)) }) }) } @@ -136,7 +184,7 @@ class AccessControl extends EventEmitter { module.exports = AccessControl -const isOrigin = (value) => { +const isScope = (value) => { if (!isString(value)) return false let url @@ -147,7 +195,7 @@ const isOrigin = (value) => { return false } - return url.origin === value + return url.origin + url.pathname === value } const isString = (value) => Object.prototype.toString.call(value) === '[object String]' diff --git a/add-on/src/lib/ipfs-proxy/index.js b/add-on/src/lib/ipfs-proxy/index.js index f58e9bacf..a9bb00430 100644 --- a/add-on/src/lib/ipfs-proxy/index.js +++ b/add-on/src/lib/ipfs-proxy/index.js @@ -20,14 +20,18 @@ function createIpfsProxy (getIpfs, getState) { const onPortConnect = (port) => { if (port.name !== 'ipfs-proxy') return - const { origin } = new URL(port.sender.url) + const getScope = async () => { + const tab = await browser.tabs.get(port.sender.tab.id) + const { origin, pathname } = new URL(tab.url) + return origin + pathname + } const proxy = createProxyServer(getIpfs, { addListener: (_, handler) => port.onMessage.addListener(handler), removeListener: (_, handler) => port.onMessage.removeListener(handler), postMessage: (data) => port.postMessage(data), getMessageData: (d) => d, - pre: (fnName) => createPreAcl(getState, accessControl, origin, fnName, requestAccess) + pre: (fnName) => createPreAcl(getState, accessControl, getScope, fnName, requestAccess) }) 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 60d9015a7..6ecfcc2b6 100644 --- a/add-on/src/lib/ipfs-proxy/pre-acl.js +++ b/add-on/src/lib/ipfs-proxy/pre-acl.js @@ -5,7 +5,7 @@ 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 (getState, accessControl, origin, permission, requestAccess) { +function createPreAcl (getState, accessControl, getScope, permission, 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') @@ -13,11 +13,12 @@ function createPreAcl (getState, accessControl, origin, permission, requestAcces // No need to verify access if permission is on the whitelist if (ACL_WHITELIST.includes(permission)) return args - let access = await accessControl.getAccess(origin, permission) + const scope = await getScope() + let access = await accessControl.getAccess(scope, permission) if (!access) { - const { allow, wildcard } = await requestAccess(origin, permission) - access = await accessControl.setAccess(origin, wildcard ? '*' : permission, allow) + const { allow, wildcard } = await requestAccess(scope, permission) + access = await accessControl.setAccess(scope, wildcard ? '*' : permission, allow) } if (!access.allow) throw new Error(`User denied access to ${permission}`) diff --git a/add-on/src/lib/ipfs-proxy/request-access.js b/add-on/src/lib/ipfs-proxy/request-access.js index 59f27dc30..631a7bf75 100644 --- a/add-on/src/lib/ipfs-proxy/request-access.js +++ b/add-on/src/lib/ipfs-proxy/request-access.js @@ -1,29 +1,41 @@ 'use strict' const DIALOG_WIDTH = 540 -const DIALOG_HEIGHT = 200 +const DIALOG_HEIGHT = 220 const DIALOG_PATH = 'dist/pages/proxy-access-dialog/index.html' const DIALOG_PORT_NAME = 'proxy-access-dialog' function createRequestAccess (browser, screen) { - return async function requestAccess (origin, permission, opts) { + return async function requestAccess (scope, permission, opts) { opts = opts || {} - const width = opts.dialogWidth || DIALOG_WIDTH - const height = opts.dialogHeight || DIALOG_HEIGHT - const url = browser.extension.getURL(opts.dialogPath || DIALOG_PATH) - const currentWin = await browser.windows.getCurrent() - - const top = Math.round(((screen.width / 2) - (width / 2)) + currentWin.left) - const left = Math.round(((screen.height / 2) - (height / 2)) + currentWin.top) - const { tabs } = await browser.windows.create({ url, width, height, top, left, type: 'popup' }) + let dialogTabId + if (browser && browser.windows && browser.windows.create) { + // display modal dialog in a centered popup window + const currentWin = await browser.windows.getCurrent() + const width = opts.dialogWidth || DIALOG_WIDTH + const height = opts.dialogHeight || DIALOG_HEIGHT + const top = Math.round(((screen.width / 2) - (width / 2)) + currentWin.left) + const left = Math.round(((screen.height / 2) - (height / 2)) + currentWin.top) + + const dialogWindow = await browser.windows.create({ url, width, height, top, left, type: 'popup' }) + dialogTabId = dialogWindow.tabs[0].id + // Fix for Fx57 bug where bundled page loaded using + // browser.windows.create won't show contents unless resized. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1402110 + await browser.windows.update(dialogWindow.id, {width: dialogWindow.width + 1}) + } else { + // fallback: opening dialog as a new active tab + // (runtimes without browser.windows.create, eg. Andorid) + dialogTabId = (await browser.tabs.create({active: true, url: url})).id + } // Resolves with { allow, wildcard } - const userResponse = getUserResponse(tabs[0].id, origin, permission, opts) + const userResponse = getUserResponse(dialogTabId, scope, permission, opts) // Never resolves, might reject if user closes the tab - const userTabRemoved = getUserTabRemoved(tabs[0].id, origin, permission) + const userTabRemoved = getUserTabRemoved(dialogTabId, scope, permission) let response @@ -35,12 +47,12 @@ function createRequestAccess (browser, screen) { userResponse.destroy() } - await browser.tabs.remove(tabs[0].id) + await browser.tabs.remove(dialogTabId) return response } - function getUserResponse (tabId, origin, permission, opts) { + function getUserResponse (tabId, scope, permission, opts) { opts = opts || {} const dialogPortName = opts.dialogPortName || DIALOG_PORT_NAME @@ -52,8 +64,8 @@ function createRequestAccess (browser, screen) { browser.runtime.onConnect.removeListener(onPortConnect) - // Tell the dialog what origin/permission it is about - port.postMessage({ origin, permission }) + // Tell the dialog what scope/permission it is about + port.postMessage({ scope, permission }) // Wait for the user response const onMessage = ({ allow, wildcard }) => { @@ -75,11 +87,11 @@ 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, origin, permission) { + function getUserTabRemoved (tabId, scope, permission) { let onTabRemoved const userTabRemoved = new Promise((resolve, reject) => { - onTabRemoved = () => reject(new Error(`Failed to obtain access response for ${permission} at ${origin}`)) + onTabRemoved = () => reject(new Error(`Failed to obtain access response for ${permission} at ${scope}`)) browser.tabs.onRemoved.addListener(onTabRemoved) }) diff --git a/add-on/src/pages/proxy-access-dialog/index.html b/add-on/src/pages/proxy-access-dialog/index.html index 40855149b..9b34d8587 100644 --- a/add-on/src/pages/proxy-access-dialog/index.html +++ b/add-on/src/pages/proxy-access-dialog/index.html @@ -8,7 +8,7 @@ - +
diff --git a/add-on/src/pages/proxy-access-dialog/page.js b/add-on/src/pages/proxy-access-dialog/page.js index 12985cda6..e5bf2f693 100644 --- a/add-on/src/pages/proxy-access-dialog/page.js +++ b/add-on/src/pages/proxy-access-dialog/page.js @@ -8,20 +8,20 @@ function createProxyAccessDialogPage (i18n) { const onDeny = () => emit('deny') const onWildcardToggle = () => emit('wildcardToggle') - const { loading, origin, permission } = state + const { loading, scope, permission } = state return html`
-
+
${loading ? null : html`
-

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

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

diff --git a/add-on/src/pages/proxy-access-dialog/store.js b/add-on/src/pages/proxy-access-dialog/store.js index 78daebf84..05efc8f5a 100644 --- a/add-on/src/pages/proxy-access-dialog/store.js +++ b/add-on/src/pages/proxy-access-dialog/store.js @@ -2,7 +2,7 @@ function createProxyAccessDialogStore (i18n, runtime) { return function proxyAccessDialogStore (state, emitter) { - state.origin = null + state.scope = null state.permission = null state.loading = true state.wildcard = false @@ -10,10 +10,10 @@ function createProxyAccessDialogStore (i18n, runtime) { const port = runtime.connect({ name: 'proxy-access-dialog' }) const onMessage = (data) => { - if (!data || !data.origin || !data.permission) return + if (!data || !data.scope || !data.permission) return port.onMessage.removeListener(onMessage) - state.origin = data.origin + state.scope = data.scope state.permission = data.permission state.loading = false diff --git a/add-on/src/pages/proxy-acl/page.js b/add-on/src/pages/proxy-acl/page.js index cdeb95710..a8c3c16c7 100644 --- a/add-on/src/pages/proxy-acl/page.js +++ b/add-on/src/pages/proxy-acl/page.js @@ -9,8 +9,8 @@ function createProxyAclPage (i18n) { const onToggleAllow = (e) => emit('toggleAllow', e) const { acl } = state - const origins = Array.from(state.acl.keys()) - const hasGrants = origins.some((origin) => !!acl.get(origin).size) + const scopes = Array.from(state.acl.keys()) + const hasGrants = scopes.some((scope) => !!acl.get(scope).size) return html`
@@ -32,16 +32,16 @@ function createProxyAclPage (i18n) { ${hasGrants ? html` - ${origins.reduce((rows, origin) => { - const permissions = acl.get(origin) + ${scopes.reduce((rows, scope) => { + const permissions = acl.get(scope) if (!permissions.size) return rows return rows.concat( - originRow({ onRevoke, origin, i18n }), + scopeRow({ onRevoke, scope, i18n }), Array.from(permissions.keys()) .sort() - .map((permission) => accessRow({ origin, permission, allow: permissions.get(permission), onRevoke, onToggleAllow, i18n })) + .map((permission) => accessRow({ scope, permission, allow: permissions.get(permission), onRevoke, onToggleAllow, i18n })) ) }, [])}
@@ -56,16 +56,16 @@ function createProxyAclPage (i18n) { module.exports = createProxyAclPage -function originRow ({ origin, onRevoke, i18n }) { +function scopeRow ({ scope, onRevoke, i18n }) { return html` - ${origin} - ${revokeButton({ onRevoke, origin, i18n })} + ${scope} + ${revokeButton({ onRevoke, scope, i18n })} ` } -function accessRow ({ origin, permission, allow, onRevoke, onToggleAllow, i18n }) { +function accessRow ({ scope, permission, allow, onRevoke, onToggleAllow, i18n }) { const title = i18n.getMessage( allow ? 'page_proxyAcl_toggle_to_deny_button_title' @@ -78,7 +78,7 @@ function accessRow ({ origin, permission, allow, onRevoke, onToggleAllow, i18n } class="f5 white ph3 pv2 ${allow ? 'bg-green' : 'bg-red'} tc bb b--white-10 pointer" style="width: 75px" onclick=${onToggleAllow} - data-origin="${origin}" + data-scope="${scope}" data-permission="${permission}" data-allow=${allow} title="${title}"> @@ -89,14 +89,14 @@ function accessRow ({ origin, permission, allow, onRevoke, onToggleAllow, i18n } ${permission}
- ${revokeButton({ onRevoke, origin, permission, i18n })} + ${revokeButton({ onRevoke, scope, permission, i18n })}
` } -function revokeButton ({ onRevoke, origin, permission = null, i18n }) { +function revokeButton ({ onRevoke, scope, permission = null, i18n }) { const title = permission ? i18n.getMessage('page_proxyAcl_revoke_button_title', permission) : i18n.getMessage('page_proxyAcl_revoke_all_button_title') @@ -105,7 +105,7 @@ function revokeButton ({ onRevoke, origin, permission = null, i18n }) {