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

window.ipfs #333

Merged
merged 25 commits into from
Jan 25, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5e9e2a1
adds window.ipfs object to pages
alanshaw Dec 12, 2017
2d13c37
uses postmsg-rpc module
alanshaw Dec 14, 2017
51c0afe
updates dependencies
alanshaw Dec 14, 2017
c1d4c98
uses ipfs-postmsg-proxy
alanshaw Dec 15, 2017
f21fcac
updates to ipfs-postmsg-proxy@0.0.4
alanshaw Jan 1, 2018
e061b6f
add POC access control for proxied IPFS functions
alanshaw Jan 11, 2018
0eeb425
adds access control list management page
alanshaw Jan 12, 2018
c0eb828
i18n for proxy ACL page
alanshaw Jan 12, 2018
ab8300f
fixes i18n getMessage call
alanshaw Jan 12, 2018
d49cf04
adds IPFS to page only when option is set
alanshaw Jan 22, 2018
521e909
wip adds access control tests
alanshaw Jan 23, 2018
87af28f
more wip access control tests
alanshaw Jan 23, 2018
de6de37
adds remaining tests and fixes for access control
Jan 23, 2018
01ae150
adds proxy-acl page tests
Jan 23, 2018
9b29486
cleanups
Jan 23, 2018
9b0009d
fixes permissions page url and logo src
Jan 23, 2018
af59463
fixes execute content script in chrome
alanshaw Jan 24, 2018
3886340
changes access control implementation to use Map and validate parameters
alanshaw Jan 24, 2018
cbeed1a
adds tests for access control param validation
alanshaw Jan 24, 2018
d02c787
pins dependencies
alanshaw Jan 24, 2018
9aa0f8e
adds comments and reorganises code for better readability
alanshaw Jan 24, 2018
4aa6408
pin all the dependencies!
alanshaw Jan 25, 2018
c5a7ee1
adds key names at the end of descriptions
alanshaw Jan 25, 2018
9237bfb
changes acl storage structure
alanshaw Jan 25, 2018
5dd1116
stringifies ACL data because chrome does not store arrays!?
alanshaw Jan 25, 2018
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ yarn-error.log
crowdin.yml
.*~
add-on/dist
coverage
.nyc_output
74 changes: 74 additions & 0 deletions add-on/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,18 @@
"message": "DNSLINK Support",
"description": "An option title on the Preferences screen (option_dnslink_title)"
},
"option_ipfsProxy_title": {
"message": "window.ipfs",
"description": "An option title for enabling/disabling the IPFS proxy (option_ipfsProxy_title)"
},
"option_ipfsProxy_description": {
"message": "Add IPFS to the window object on every page",
"description": "An option description for the IPFS proxy (option_ipfsProxy_description)"
},
"option_ipfsProxy_link_manage_permissions": {
"message": "Manage permissions",
"description": "Link text for managing permissions"
},
"option_preloadAtPublicGateway_title": {
"message": "Preload Uploads",
"description": "An option title on the Preferences screen (option_preloadAtPublicGateway_title)"
Expand Down Expand Up @@ -329,5 +341,67 @@
"quickUpload_drop_it_here": {
"message": "drop it here to share",
"description": "Partial info stats beneath the header on the share files page (quickUpload_drop_it_here)"
},
"page_proxyAcl_title": {
"message": "Manage Permissions",
"description": "Page title for the IPFS proxy ACL page (page_proxyAcl_title)"
},
"page_proxyAcl_subtitle": {
"message": "View, change and revoke granted access rights to your IPFS instance.",
"description": "Page sub title for the IPFS proxy ACL page (page_proxyAcl_subtitle)"
},
"page_proxyAcl_no_perms": {
"message": "No permissions granted.",
"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)",
"placeholders": {
"permission": {
"content": "$1"
},
"origin": {
"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)",
"placeholders": {
"origin": {
"content": "$1"
}
}
},
"page_proxyAcl_toggle_to_allow_button_title": {
"message": "Click to allow",
"description": "Button title for toggling permission from deny to allow (page_proxyAcl_toggle_to_allow_button_title)"
},
"page_proxyAcl_toggle_to_deny_button_title": {
"message": "Click to deny",
"description": "Button title for toggling permission from allow to deny (page_proxyAcl_toggle_to_deny_button_title)"
},
"page_proxyAcl_allow_button_value": {
"message": "Allow",
"description": "Button value for allow (page_proxyAcl_allow_button_value)"
},
"page_proxyAcl_deny_button_value": {
"message": "Deny",
"description": "Button value for deny"
},
"page_proxyAcl_revoke_button_title": {
"message": "Revoke $PERMISSION$",
"description": "Button title for revoking a permission (page_proxyAcl_revoke_button_title)",
"placeholders": {
"permission": {
"content": "$1"
}
}
},
"page_proxyAcl_revoke_all_button_title": {
"message": "Revoke all permissions",
"description": "Button title for revoking all permissions (page_proxyAcl_revoke_all_button_title)"
}
}
3 changes: 2 additions & 1 deletion add-on/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@

"web_accessible_resources": [
"icons/ipfs-logo-on.svg",
"icons/ipfs-logo-off.svg"
"icons/ipfs-logo-off.svg",
"dist/contentScripts/ipfs-proxy/page.js"
],

"protocol_handlers": [
Expand Down
27 changes: 27 additions & 0 deletions add-on/src/contentScripts/ipfs-proxy/content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict'

const browser = require('webextension-polyfill')
const injectScript = require('./inject-script')

function init () {
const port = browser.runtime.connect({ name: 'ipfs-proxy' })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If other extensions know this name, is it possible they can connect to it? That would be bad, as they could bypass the permissions you've setup, if I understand correctly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would! I'll check it out.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...although thinking about it, the access control is all done in the extension background process, so it doesn't matter who's connecting and asking for stuff, it still has to be authorised by the user.

I'll double check but I think it might be ok.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok so, the background process is listening to runtime.onConnect:

Fired when a connection is made with either an extension process or a content script.
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/onConnect

It would need to listen to runtime.onConnectExternal to receive connections from other extensions.

Aside from that, even if connections from other extensions were allowed, the authorization is done in the background process, so the user would still need to have approved the function to be called before the proxy server let it through to the IPFS instance.


// Forward on messages from background to the page and vice versa
port.onMessage.addListener((data) => window.postMessage(data, '*'))

window.addEventListener('message', (msg) => {
if (msg.data && msg.data.sender === 'postmsg-rpc/client') {
port.postMessage(msg.data)
}
})

injectScript(browser.extension.getURL('dist/contentScripts/ipfs-proxy/page.js'))
}

// Only run this once for this window!
// URL can change (history API) which causes this script to be executed again,
// but it only needs to be setup once per window...
if (!window.__ipfsProxyContentInitialized) {
init()
window.__ipfsProxyContentInitialized = true
}
17 changes: 17 additions & 0 deletions add-on/src/contentScripts/ipfs-proxy/inject-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use strict'

function injectScript (src, target, opts) {
opts = opts || {}
const doc = opts.document || document

const scriptTag = doc.createElement('script')
scriptTag.src = src
scriptTag.onload = function () {
this.parentNode.removeChild(this)
}

target = doc.head || doc.documentElement
target.appendChild(scriptTag)
}

module.exports = injectScript
9 changes: 9 additions & 0 deletions add-on/src/contentScripts/ipfs-proxy/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use strict'

const Ipfs = require('ipfs')
const { createProxyClient } = require('ipfs-postmsg-proxy')
const _Buffer = Buffer

window.Buffer = window.Buffer || _Buffer
window.Ipfs = window.Ipfs || Ipfs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should expose both window.Ipfs and window.ipfs.
Naming is quite unfortunate :'(

Is there a good use case for exposing Ipfs ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you want an IPFS node configured with particular swarm peers so using the one provided by the extension is not possible? ...and you don't want to download the IPFS lib because you're on a poor connection?

Maybe...for experiments?

Are they good enough?

Copy link
Member

@lidel lidel Jan 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say yes, it would make developer's life easier. 👍
But perhaps it should be renamed to window.IpfsApi or something like that?

@diasdavid, would appreciate your thoughts on this from js-ipfs-api perspective

window.ipfs = window.ipfs || createProxyClient()
15 changes: 13 additions & 2 deletions add-on/src/lib/ipfs-companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const { createIpfsUrlProtocolHandler } = require('./ipfs-protocol')
const createNotifier = require('./notifier')
const createCopier = require('./copier')
const { createContextMenus, findUrlForContext } = require('./context-menus')
const createIpfsProxy = require('./ipfs-proxy')

// init happens on addon load in background/background.js
module.exports = async function init () {
Expand All @@ -26,8 +27,10 @@ module.exports = async function init () {
var copier
var contextMenus
var apiStatusUpdateInterval
var ipfsProxy
const offlinePeerCount = -1
const idleInSecs = 5 * 60
const browserActionPortName = 'browser-action-port'

try {
const options = await browser.storage.local.get(optionDefaults)
Expand All @@ -43,9 +46,14 @@ module.exports = async function init () {
onCopyAddressAtPublicGw: () => copier.copyAddressAtPublicGw()
})
modifyRequest = createRequestModifier(getState, dnsLink, ipfsPathValidator)
ipfsProxy = createIpfsProxy(() => ipfs, getState)
registerListeners()
await setApiStatusUpdateInterval(options.ipfsApiPollMs)
await storeMissingOptions(options, optionDefaults, browser.storage.local)
await storeMissingOptions(
await browser.storage.local.get(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain this change? The code looks fine, just curious.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! Previously storeMissingOptions wasn't doing anything.

On first run, options will be the same as optionDefaults. Due to the line earlier:

const options = await browser.storage.local.get(optionDefaults)

Regarding the parameter to get:

A key (string) or keys (an array of strings or an object specifying default values) to identify the item(s) to be retrieved from storage
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/StorageArea/get#Parameters

...so, it finds {} in storage and then assigns optionDefaults to it.

So storeMissingOptions is given two identical objects and does nothing.

Similarly for subsequent runs, options will be assigned any missing defaults so there's never anything to store!

Instead, we get a fresh copy of the current options, without defaults applied.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch! I think I've made this bug while porting from legacy SDK to WebExtension APIs.
Due to the "fallback to defaults" it worked anyway, so we never noticed :):)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...probably should be in a separate PR - apologies

optionDefaults,
browser.storage.local
)
} catch (error) {
console.error('Unable to initialize addon due to error', error)
if (notify) notify('notify_addonIssueTitle', 'notify_addonIssueMsg')
Expand Down Expand Up @@ -119,7 +127,6 @@ module.exports = async function init () {
// e.g. signalling between browser action popup and background page that works
// in everywhere, even in private contexts (https://github.com/ipfs/ipfs-companion/issues/243)

const browserActionPortName = 'browser-action-port'
var browserActionPort

function onRuntimeConnect (port) {
Expand Down Expand Up @@ -518,6 +525,8 @@ module.exports = async function init () {
state.dnslink = change.newValue
} else if (key === 'preloadAtPublicGateway') {
state.preloadAtPublicGateway = change.newValue
} else if (key === 'ipfsProxy') {
state.ipfsProxy = change.newValue
}
}
}
Expand Down Expand Up @@ -554,6 +563,8 @@ module.exports = async function init () {
notify = null
copier = null
contextMenus = null
ipfsProxy.destroy()
ipfsProxy = null
await destroyIpfsClient()
}
}
Expand Down
133 changes: 133 additions & 0 deletions add-on/src/lib/ipfs-proxy/access-control.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
'use strict'
/* eslint-env browser */

const EventEmitter = require('events')
const PQueue = require('p-queue')

class AccessControl extends EventEmitter {
constructor (storage, storageKeyPrefix = 'ipfsProxyAcl') {
super()
this._storage = storage
this._storageKeyPrefix = storageKeyPrefix
this._onStorageChange = this._onStorageChange.bind(this)
storage.onChanged.addListener(this._onStorageChange)
this._writeQ = new PQueue({ concurrency: 1 })
}

async _onStorageChange (changes) {
const prefix = this._storageKeyPrefix
const aclChangeKeys = Object.keys(changes).filter((key) => key.startsWith(prefix))

if (!aclChangeKeys.length) return

// Map { origin => Map { permission => allow } }
this.emit('change', aclChangeKeys.reduce((aclChanges, key) => {
return aclChanges.set(
key.slice(prefix.length + 1),
new Map(JSON.parse(changes[key].newValue))
)
}, new Map()))
}

_getGrantsKey (origin) {
return `${this._storageKeyPrefix}.${origin}`
}

// 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(
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 getAccess (origin, permission) {
if (!isOrigin(origin)) throw new TypeError('Invalid origin')
if (!isString(permission)) throw new TypeError('Invalid permission')

const grants = await this._getGrants(origin)

return grants.has(permission)
? { origin, permission, allow: grants.get(permission) }
: null
}

async setAccess (origin, permission, allow) {
if (!isOrigin(origin)) throw new TypeError('Invalid origin')
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)

grants.set(permission, allow)
await this._setGrants(origin, grants)

return access
})
}

// Map { origin => Map { permission => allow } }
async getAcl () {
const data = await this._storage.local.get()
const prefix = this._storageKeyPrefix

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

// 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')
if (permission && !isString(permission)) throw new TypeError('Invalid permission')

return this._writeQ.add(async () => {
let grants

if (permission) {
grants = await this._getGrants(origin)
if (!grants.has(permission)) return
grants.delete(permission)
} else {
grants = new Map()
}

await this._setGrants(origin, grants)
})
}

destroy () {
this._storage.onChanged.removeListener(this._onStorageChange)
}
}

module.exports = AccessControl

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

let url

try {
url = new URL(value)
} catch (_) {
return false
}

return url.origin === value
}

const isString = (value) => Object.prototype.toString.call(value) === '[object String]'
const isBoolean = (value) => value === true || value === false
Loading