Skip to content

Commit

Permalink
feat: cross-domain local storage (#43)
Browse files Browse the repository at this point in the history
* refactor: rename appendSwarmSessionId function

* style: vscode settings

* refactor: move web2-helper.content to its folder

* feat(background): add session validation

* chore: add todo comment to web2helper serveEvents workflow

* feat: localStorage message interface

* feat(inpage): localStorage init

* chore: add localStorage object to the window.swarm

* chore: optional sessionId property for inpage and interceptor requests

* feat(inpage): define localStorage under window.swarm

* feat(background): local storage feeder

* feat(contentscript): forward sessionId and check message is undefined before deserialization

* build: add webextension-polyfill for async onMessage responses

* refactor: payload of messages are default in any type

* feat: get and set local storage

* test: update sample html

* test: init tests

* chore: remove unrelated jsdoc

* refactor: move swarmSessionId to swarm.sessionId

* refactor(background): local storage feeder logic extraction

* docs: readme

* docs: readme

* fix: prevent hack from parent window

* test: move localStorage handler to a new page

* test: refactor + wire the new localStorage page

* fix(background): promise rejection handling

* test: add iframe check for localstorage
  • Loading branch information
nugaon authored Jul 6, 2021
1 parent 29c2125 commit b2eacc0
Show file tree
Hide file tree
Showing 26 changed files with 638 additions and 134 deletions.
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
// For Stylelint
"source.fixAll.stylelint": true
},
"eslint.validate": ["javascript", "typescript"],
"eslint.validate": [
"javascript",
"typescript"
],
"editor.formatOnSave": true,
"[javascript]": {
"editor.formatOnSave": false
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ If the user changes their Bee API address, these callings still remain the same

## Custom Protocol

Only supported Swarm protocol currently is `bzz` (in HTML files `web+bzz`).
Only supported Swarm protocol currently is `bzz`.
It makes a redirect to the BZZ endpoint of the Bee node by calling its corresponding Fake URL.
There will be need for other Swarm specific protocols (or extend the current one), which handles different type of feeds and mutable content.

Expand All @@ -49,10 +49,28 @@ It is injected to every page basically, because in [manifest.json](manifest.json
Unfortunately, [Chrome does not have exposed function to register custom protocol](https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/master/types/chrome/index.d.ts) in the [background script](src/background/index.ts)(, although it turned out some functionalities are not defined in this interface to keep them hidden, so analyzing the properties of `chrome` object is justified later).

Chrome lets you to register custom protocol in the context of the webpage, but only with prefix `web+`.
It means you can only refer to external P2P content by `web+bzz://{content-address}`
Nevertheless you can refer to any `bzz` resource in html if you add attribute `is=swarm-X` to your html element like `<img is="swarm-img" src="bzz://{content-address}" />`.
Current supported elements:
* `a` -> `<a is="swarm-a" (...)`
* `img` -> `<img is="swarm-image" (...)`
* `iframe` -> `<iframe is="swarm-frame" (...)`

In search bar the `bzz://{content-address}` will be redirected to `http(s)://{localgateway}/bzz/{content-address}`, but now it only reacts like this if the default search engine of the browser is set to Google. It also works the same on simple google search.

## Cross-Domain Local Storage
In Web3, several different and distinct webpages can be rendered under one particular host.
It is a problem, because if a dApp wants to store something in the browser of the user, then other dApps will be able to read that.

This unintended behaviour can be solved by the `dApp Security Context` of the extension:
the separation of `localStorage` method between dApps happens by the `sessionId` of the dApp.

Thereby even if the user changes its P2P client host, the state and their session will remain - unlike at subdomain content address URLs.

Of course, it is not necessary to set any ID manually, just call the ordinary `localStorage` methods as so far:
instead of `window.localStorage.setItem('swarm', 'bzz')` you can call `swarm.localStorage.setItem('swarm', 'bzz')` in order to persist data in the browser, that only the dApp can recall later.

The `setItem` and `getItem` methods here are `async` methods, so these return with `Promise`.

## Test

There are some illustrative tests which show how these PoC ideas work.
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"style-loader": "^2.0.0",
"ts-node": "^9.1.1",
"typescript": "^4.1.3",
"webextension-polyfill": "^0.8.0",
"webpack": "^5.11.0",
"webpack-bundle-analyzer": "^4.3.0",
"webpack-cli": "^4.3.0",
Expand Down
125 changes: 114 additions & 11 deletions src/background/dapp-session.manager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,66 @@
import { senderContentOrigin, senderFrameId, senderFrameOrigin, senderTabId } from './utils'

type LocalStorage = {
set: (storeValues: { [key: string]: unknown }, callback?: () => void) => void
get: (storeValues: string[], callback: (result: { [key: string]: unknown }) => void) => void
}

class DappSecurityContext {
private readonly storage: LocalStorage
private readonly storePrefix: string
public constructor(
private tabId: number,
private frameId: number,
private frameContentRoot: string,
private originContentRoot: string,
) {}
) {
this.storage = chrome.storage.local
this.storePrefix = this.frameContentRoot
? `${this.originContentRoot}:${this.frameContentRoot}`
: this.originContentRoot
}

public isValidTabId(tabId: number): boolean {
return tabId === this.tabId
}
public isValidFrameId(frameId: number): boolean {
return frameId === this.frameId
}

public isFrameContentRoot(frameContentRoot: string): boolean {
return frameContentRoot === this.frameContentRoot
}

public isValidOriginContentRoot(originContentRoot: string): boolean {
return originContentRoot === this.originContentRoot
}

/** STORAGE FUNCTIONS */

public setStorageItem(keyName: string, keyValue: unknown): Promise<void> {
const key = this.enrichStorageKey(keyName)

return new Promise(resolve => this.storage.set({ [key]: keyValue, resolve }))
}

public getStorageItem(keyName: string): Promise<unknown> {
const key = this.enrichStorageKey(keyName)

return new Promise((resolve, reject) =>
this.storage.get([key], result => {
console.log('result', result)

if (!result[key]) {
reject(`LocalStorage: ${key} does not exist`)
}
resolve(result[key])
}),
)
}

private enrichStorageKey(keyName: string): string {
return `${this.storePrefix}-${keyName}`
}
}

export class DappSessionManager {
Expand All @@ -13,17 +69,64 @@ export class DappSessionManager {
public constructor() {
this.securityContexts = {}
}
public register(
sessionId: string,
sender: {
tabId: number
frameId: number
frameContentRoot: string
originContentRoot: string
},
): void {
const { tabId, frameId, frameContentRoot, originContentRoot } = sender
public register(sessionId: string, sender: chrome.runtime.MessageSender): void {
const tabId = senderTabId(sender)
const frameId = senderFrameId(sender)
const frameContentRoot = senderFrameOrigin(sender)
const originContentRoot = senderContentOrigin(sender)

this.securityContexts[sessionId] = new DappSecurityContext(tabId, frameId, frameContentRoot, originContentRoot)
console.log(`dApp session "${sessionId}" has been initialized`, this.securityContexts[sessionId])
}

/**
* Checks the given sessionId and sender's data match with the DappSessionManager's records
*/
public isValidSession(sessionId: string, sender: chrome.runtime.MessageSender): boolean {
const context = this.securityContexts[sessionId]

if (!context) return false

const tabId = senderTabId(sender)
const frameId = senderFrameId(sender)
const frameContentRoot = senderFrameOrigin(sender)
const originContentRoot = senderContentOrigin(sender)

console.log(`tabid ${tabId} frameId ${frameId} frameconte ${frameContentRoot} originCon ${originContentRoot}`)

return (
context.isValidTabId(tabId) &&
context.isValidFrameId(frameId) &&
context.isFrameContentRoot(frameContentRoot) &&
context.isValidOriginContentRoot(originContentRoot)
)
}

public getStorageItem(
sessionId: string,
...getStorageParams: Parameters<DappSecurityContext['getStorageItem']>
): ReturnType<DappSecurityContext['getStorageItem']> {
const context = this.getSecurityContext(sessionId)

return context.getStorageItem(...getStorageParams)
}

public async setStorageItem(
sessionId: string,
...setStorageParams: Parameters<DappSecurityContext['setStorageItem']>
): Promise<void> {
const context = this.getSecurityContext(sessionId)

await context.setStorageItem(...setStorageParams)
}

private getSecurityContext(sessionId: string): DappSecurityContext {
const securityContext = this.securityContexts[sessionId]

if (!securityContext) {
throw new Error(`DappSessionManager: There is no registered security context for session: ${sessionId}`)
}

return securityContext
}
}
74 changes: 5 additions & 69 deletions src/background/feeder/dapp-session.feeder.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ErrorWithConsoleLog } from '../../utils/error-with-console-log'
import { IDappSessionMessage } from '../../utils/message/dapp-session/dapp-session.message'
import { DirectMessageReq } from '../../utils/message/message-handler'
import { DappSessionManager } from '../dapp-session.manager'
import { isInternalMessage, isTypedMessage } from '../utils'

export class DappSessionFeeder {
constructor(private manager: DappSessionManager) {
Expand All @@ -12,74 +9,13 @@ export class DappSessionFeeder {
serveEvents(): void {
console.log('Register DappSessionFeeder event listeners...')

// register dapp session id
chrome.runtime.onMessage.addListener((message, sender) => {
if (!this.isInternalMessage(sender)) return
if (!isInternalMessage(sender)) return

if (this.isDappSessionMessage(message, 'registerDappSession')) {
this.manager.register(message.payload[0], {
tabId: this.senderTabId(sender),
frameId: this.senderFrameId(sender),
originContentRoot: this.senderContentOrigin(sender),
frameContentRoot: this.senderFrameOrigin(sender),
})
if (isTypedMessage<IDappSessionMessage>(message, 'registerDappSession')) {
this.manager.register(message.payload[0], sender)
}
})
}

private senderTabId(sender: chrome.runtime.MessageSender) {
if (!sender.tab?.id) {
throw new ErrorWithConsoleLog(`DappSessionFeeder: sender does not have "tab.id" property`, sender)
}

return sender.tab.id
}

/** If context is not in iframe it returns back -1 */
private senderFrameId(sender: chrome.runtime.MessageSender) {
if (!sender.frameId) {
return -1
}

return sender.frameId
}

/** Gives back the original content reference of the sender */
private senderContentOrigin(sender: chrome.runtime.MessageSender) {
if (!sender.tab?.url) {
throw new ErrorWithConsoleLog(`DappSessionFeeder: sender does not have "tab.url" property`, sender)
}

return this.extractContentRoot(sender.tab.url)
}

/** Gives back the frame content reference of the sender */
private senderFrameOrigin(sender: chrome.runtime.MessageSender) {
if (!sender.url) throw new ErrorWithConsoleLog(`DappSessionFeeder: sender does not have "url" property`, sender)

return this.extractContentRoot(sender.url)
}

private extractContentRoot(url: string): string {
const urlParts = url.split('/') // http(s)://{bee-client-host}/bzz/{>content-root<}(/whatever)

if (urlParts.length < 4 || urlParts[2] === 'bzz') {
throw new Error(`DappSessionFeeder: source URL is not a valid content refence ${url}`)
}

// TODO: check configurated bee api/debug address
return urlParts[4]
}

private isDappSessionMessage<K extends keyof IDappSessionMessage>(
message: DirectMessageReq<IDappSessionMessage, any>,
method: K,
): message is DirectMessageReq<IDappSessionMessage, K> {
if (message.key === method && message.trusted) return true

return false
}

private isInternalMessage(sender: chrome.runtime.MessageSender): boolean {
return sender.id === chrome.runtime.id
}
}
Loading

0 comments on commit b2eacc0

Please sign in to comment.