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

Adds scoped permissions #386

Merged
merged 8 commits into from
Feb 20, 2018
Merged
Show file tree
Hide file tree
Changes from 7 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
25 changes: 10 additions & 15 deletions add-on/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
150 changes: 99 additions & 51 deletions add-on/src/lib/ipfs-proxy/access-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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)) })
})
}

Expand All @@ -136,7 +184,7 @@ class AccessControl extends EventEmitter {

module.exports = AccessControl

const isOrigin = (value) => {
const isScope = (value) => {
if (!isString(value)) return false

let url
Expand All @@ -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]'
Expand Down
8 changes: 6 additions & 2 deletions add-on/src/lib/ipfs-proxy/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
9 changes: 5 additions & 4 deletions add-on/src/lib/ipfs-proxy/pre-acl.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@ 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')

// 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}`)
Expand Down
Loading