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

prompt user for permission to connect from Firefox extension #1357

Merged
merged 9 commits into from
Jan 19, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
116 changes: 116 additions & 0 deletions app/tray/Notify/ExtensionConnect/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import styled from 'styled-components'
import { useState } from 'react'

import link from '../../../../resources/link'
import { capitalize } from '../../../../resources/utils'
import svg from '../../../../resources/svg'
import { ClusterBox, Cluster, ClusterRow, ClusterValue } from '../../../../resources/Components/Cluster'

const NotifyTop = styled.div`
padding: 24px 0px 16px 0px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
`

const NotifyMain = styled.div`
padding: 24px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 14.6px;
line-height: 22px;
font-weight: 400;
`

const NotifyPrompt = styled.div`
padding: 24px;
font-weight: 400;
text-transform: uppercase;
`

const ExtensionId = styled.div`
margin: 24px 16px;
height: 13px;
font-weight: 400;
text-transform: uppercase;
display: flex;
flex-direction: column;
justify-content: center;
letter-spacing: 0.5px;
color: var(--moon);
`

const VCR = styled.div`
font-family: 'FiraCode';
font-size: 14px;
font-weight: 300;
letter-spacing: 0px;
`

const ConfirmButton = styled.div`
padding: 24px;
font-weight: 400;
text-transform: uppercase;
font-size: 16px;
`

const ExtensionConnectNotification = ({ id, browser, onClose }) => {
const respond = (accepted) => link.rpc('respondToExtensionRequest', id, accepted, onClose)
const browserName = capitalize(browser)
const [copyId, setCopyId] = useState(false)

return (
<div className='notify cardShow'>
<div className='notifyBoxWrap' onMouseDown={(e) => e.stopPropagation()}>
<div className='notifyBoxSlide'>
<ClusterBox>
<NotifyTop>
<div style={{ color: 'var(--moon)' }}>{svg.firefox(40)}</div>
</NotifyTop>
<Cluster>
<ClusterRow>
<ClusterValue>
<NotifyMain>
<div style={{ paddingBottom: '24px' }}>
{`A new ${browserName} extension is attempting to connect as "Frame Companion"`}{' '}
</div>
<div>{`If you did not recently add Frame Companion please verify the extension origin below`}</div>
</NotifyMain>
</ClusterValue>
</ClusterRow>
<ClusterRow>
<ClusterValue
onClick={() => {
link.send('tray:clipboardData', id)
setCopyId(true)
setTimeout(() => setCopyId(false), 2000)
}}
>
<ExtensionId>{copyId ? 'extension origin copied' : <VCR>{id}</VCR>}</ExtensionId>
</ClusterValue>
</ClusterRow>
<ClusterRow>
<ClusterValue>
<NotifyPrompt>Allow this extension to connect?</NotifyPrompt>
</ClusterValue>
</ClusterRow>
<ClusterRow>
<ClusterValue onClick={() => respond(false)}>
<ConfirmButton style={{ color: 'var(--bad)' }}>Decline</ConfirmButton>
</ClusterValue>
<ClusterValue onClick={() => respond(true)}>
<ConfirmButton style={{ color: 'var(--good)' }}>Accept</ConfirmButton>
</ClusterValue>
</ClusterRow>
</Cluster>
</ClusterBox>
</div>
</div>
</div>
)
}

export default ExtensionConnectNotification
6 changes: 6 additions & 0 deletions app/tray/Notify/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { usesBaseFee } from '../../../resources/domain/transaction'
import { capitalize } from '../../../resources/utils'

import frameIcon from './FrameIcon.png'
import ExtensionConnectNotification from './ExtensionConnect'

const FEE_WARNING_THRESHOLD_USD = 50

Expand Down Expand Up @@ -515,6 +516,7 @@ class Notify extends React.Component {

render() {
const notify = this.store('view.notify')

if (notify === 'mainnet') {
return (
<div className='notify cardShow' onMouseDown={() => this.store.notify()}>
Expand Down Expand Up @@ -625,6 +627,10 @@ class Notify extends React.Component {
{this.openExplorer(this.store('view.notifyData'))}
</div>
)
} else if (notify === 'extensionConnect') {
const { browser, id } = this.store('view.notifyData')

return <ExtensionConnectNotification browser={browser} id={id} onClose={() => this.store.notify()} />
} else {
return null
}
Expand Down
7 changes: 5 additions & 2 deletions main/accounts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ type RequestType =
| 'switchChain'
| 'addToken'

export interface AccountRequest {
interface Request {
type: RequestType
handlerId: string
}

export interface AccountRequest extends Request {
origin: string
payload: JSONRPCRequestPayload
handlerId: string
account: string
status?: RequestStatus
mode?: RequestMode
Expand Down
76 changes: 60 additions & 16 deletions main/api/origins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,36 @@ import accounts, { AccessRequest } from '../accounts'
import store from '../store'

const dev = process.env.NODE_ENV === 'development'

const activeExtensionChecks: Record<string, Promise<boolean>> = {}
const extensionPrefixes = {
firefox: 'moz-extension',
safari: 'safari-web-extension'
}

const protocolRegex = /^(?:ws|http)s?:\/\//

interface OriginUpdateResult {
payload: RPCRequestPayload
hasSession: boolean
}

type Browser = 'chrome' | 'firefox' | 'safari'

export interface FrameExtension {
browser: Browser
id: string
}

// allows the Frame extension to request specific methods
const trustedExtensionMethods = ['wallet_getEthereumChains']

const storeApi = {
getPermission: (address: Address, origin: string) => {
const permissions: Record<string, Permission> = store('main.permissions', address) || {}
return Object.values(permissions).find((p) => p.origin === origin)
}
},
getKnownExtension: (id: string) => store('main.knownExtensions', id) as boolean
}

export function parseOrigin(origin?: string) {
Expand All @@ -39,6 +54,31 @@ async function getPermission(address: Address, origin: string, payload: RPCReque
return permission || requestPermission(address, payload)
}

async function requestExtensionPermission(extension: FrameExtension) {
if (extension.id in activeExtensionChecks) {
return activeExtensionChecks[extension.id]
}

const result = new Promise<boolean>((resolve) => {
const obs = store.observer(() => {
const isActive = extension.id in activeExtensionChecks
const isAllowed = store('main.knownExtensions', extension.id)

// wait for a response
if (isActive && typeof isAllowed !== 'undefined') {
delete activeExtensionChecks[extension.id]
obs.remove()
resolve(isAllowed)
}
}, 'origins:requestExtension')
})

activeExtensionChecks[extension.id] = result
store.notify('extensionConnect', extension)

return result
}

async function requestPermission(address: Address, fullPayload: RPCRequestPayload) {
const { _origin: originId, ...payload } = fullPayload

Expand Down Expand Up @@ -99,29 +139,33 @@ export function updateOrigin(
}
}

export function isFrameExtension(req: IncomingMessage) {
const origin = req.headers.origin
if (!origin) return false
export function parseFrameExtension(req: IncomingMessage): FrameExtension | undefined {
const origin = req.headers.origin || ''

const query = queryString.parse((req.url || '').replace('/', ''))
const mozOrigin = origin.startsWith('moz-extension://')
const extOrigin =
origin.startsWith('chrome-extension://') ||
origin.startsWith('moz-extension://') ||
origin.startsWith('safari-web-extension://')
const hasExtensionIdentity = query.identity === 'frame-extension'

if (origin === 'chrome-extension://ldcoohedfbjoobcadoglnnmmfbdlmmhf') {
// Match production chrome
return true
} else if (mozOrigin || (dev && extOrigin)) {
// In production, match any Firefox extension origin where query.identity === 'frame-extension'
// In dev, match any extension where query.identity === 'frame-extension'
return query.identity === 'frame-extension'
} else {
return false
return { browser: 'chrome', id: 'ldcoohedfbjoobcadoglnnmmfbdlmmhf' }
} else if (origin.startsWith(`${extensionPrefixes.firefox}://`) && hasExtensionIdentity) {
// Match production Firefox
const extensionId = origin.substring(extensionPrefixes.firefox.length + 3)
return { browser: 'firefox', id: extensionId }
} else if (origin.startsWith(`${extensionPrefixes.safari}://`) && dev && hasExtensionIdentity) {
// Match Safari in dev only
return { browser: 'safari', id: 'frame-dev' }
}
}

export async function isKnownExtension(extension: FrameExtension) {
if (extension.browser === 'chrome' || extension.browser === 'safari') return true

const extensionPermission = storeApi.getKnownExtension(extension.id)

return extensionPermission ?? requestExtensionPermission(extension)
}

export async function isTrusted(payload: RPCRequestPayload) {
// Permission granted to unknown origins only persist until the Frame is closed, they are not permanent
const { name: originName } = store('main.origins', payload._origin) as { name: string }
Expand Down
28 changes: 22 additions & 6 deletions main/api/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import provider from '../provider'
import accounts from '../accounts'
import windows from '../windows'

import { updateOrigin, isTrusted, isFrameExtension, parseOrigin } from './origins'
import {
updateOrigin,
isTrusted,
parseOrigin,
isKnownExtension,
FrameExtension,
parseFrameExtension
} from './origins'
import validPayload from './validPayload'
import protectedMethods from './protectedMethods'
import { IncomingMessage, Server } from 'http'
Expand All @@ -25,7 +32,7 @@ interface Subscription {
interface FrameWebSocket extends WebSocket {
id: string
origin?: string
isFrameExtension: boolean
frameExtension?: FrameExtension
}

interface ExtensionPayload extends JSONRPCRequestPayload {
Expand All @@ -46,7 +53,7 @@ function extendSession(originId: string) {
const handler = (socket: FrameWebSocket, req: IncomingMessage) => {
socket.id = uuid()
socket.origin = req.headers.origin
socket.isFrameExtension = isFrameExtension(req)
socket.frameExtension = parseFrameExtension(req)

const res = (payload: RPCResponsePayload) => {
if (socket.readyState === WebSocket.OPEN) {
Expand All @@ -61,7 +68,16 @@ const handler = (socket: FrameWebSocket, req: IncomingMessage) => {
if (!rawPayload) return console.warn('Invalid Payload', data)

let requestOrigin = socket.origin
if (socket.isFrameExtension) {
if (socket.frameExtension) {
if (!(await isKnownExtension(socket.frameExtension))) {
const error = {
message: `Permission denied, approve connection from Frame Companion with id ${socket.frameExtension.id} in Frame to continue`,
code: 4001
}

return res({ id: rawPayload.id, jsonrpc: rawPayload.jsonrpc, error })
}

// Request from extension, swap origin
if (rawPayload.__frameOrigin) {
requestOrigin = rawPayload.__frameOrigin
Expand All @@ -75,7 +91,7 @@ const handler = (socket: FrameWebSocket, req: IncomingMessage) => {

if (logTraffic)
log.info(
`req -> | ${socket.isFrameExtension ? 'ext' : 'ws'} | ${origin} | ${rawPayload.method} | -> | ${
`req -> | ${socket.frameExtension ? 'ext' : 'ws'} | ${origin} | ${rawPayload.method} | -> | ${
rawPayload.params
}`
)
Expand Down Expand Up @@ -114,7 +130,7 @@ const handler = (socket: FrameWebSocket, req: IncomingMessage) => {
}
if (logTraffic)
log.info(
`<- res | ${socket.isFrameExtension ? 'ext' : 'ws'} | ${origin} | ${
`<- res | ${socket.frameExtension ? 'ext' : 'ws'} | ${origin} | ${
payload.method
} | <- | ${JSON.stringify(response.result || response.error)}`
)
Expand Down
3 changes: 3 additions & 0 deletions main/rpc/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ const rpc = {
confirmRequestApproval(req, approvalType, approvalData, cb) {
accounts.confirmRequestApproval(req.handlerId, approvalType, approvalData)
},
respondToExtensionRequest(id, approved, cb) {
callbackWhenDone(() => store.trustExtension(id, approved), cb)
},
updateRequest(reqId, actionId, data, cb = () => {}) {
accounts.updateRequest(reqId, actionId, data)
},
Expand Down
3 changes: 3 additions & 0 deletions main/store/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,9 @@ module.exports = {
return origins
})
},
trustExtension: (u, extensionId, trusted) => {
u('main.knownExtensions', (extensions = {}) => ({ ...extensions, [extensionId]: trusted }))
},
setBlockHeight: (u, chainId, blockHeight) => {
u('main.networksMeta.ethereum', (chainsMeta) => {
if (chainsMeta[chainId]) {
Expand Down
5 changes: 5 additions & 0 deletions main/store/state/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ const initial = {
derivation: main('trezor.derivation', 'standard')
},
origins: main('origins', {}),
knownExtensions: main('knownExtensions', {}),
privacy: {
errorReporting: main('privacy.errorReporting', true)
},
Expand Down Expand Up @@ -729,6 +730,10 @@ initial.main.origins = Object.entries(initial.main.origins).reduce((origins, [id
return origins
}, {})

initial.main.knownExtensions = Object.fromEntries(
Object.entries(initial.main.knownExtensions).filter(([id, allowed]) => allowed)
)

// ---

module.exports = () => migrations.apply(initial)
1 change: 0 additions & 1 deletion resources/Components/Cluster/style/index.styl
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
cursor pointer
margin-bottom 0px
position relative
z-index 3

.clusterValueInteractable
*
Expand Down
Loading