diff options
author | 2019-03-16 11:05:10 +0000 | |
---|---|---|
committer | 2019-03-16 11:05:10 +0000 | |
commit | d5fc74cc5baf59a643cf58cdd9d17739212b09c1 (patch) | |
tree | 5006927e90272e49d353be7def0d04acd269a0bc | |
parent | 04e1288ad764241318ea7eaff6e7d6aad77bd96c (diff) |
Give approval for scripts via the popup and allow async script approval by queuing scripts
-rw-r--r-- | manifest.json | 3 | ||||
-rw-r--r-- | src/common/GetKeybaseUserForDomainEvent.ts | 3 | ||||
-rw-r--r-- | src/common/GetUsersAwaitingConsentEvent.ts | 33 | ||||
-rw-r--r-- | src/common/Script.ts | 5 | ||||
-rw-r--r-- | src/content/KeyRing.ts | 53 | ||||
-rw-r--r-- | src/content/PendingSignerError.ts | 1 | ||||
-rw-r--r-- | src/content/ScriptInterceptor.ts | 157 | ||||
-rw-r--r-- | src/popup/js/pages/Index.tsx | 70 |
8 files changed, 251 insertions, 74 deletions
diff --git a/manifest.json b/manifest.json index 48b633e..768d91e 100644 --- a/manifest.json +++ b/manifest.json @@ -13,9 +13,6 @@ "run_at": "document_start" } ], - "background": { - "scripts": ["dist/background.js"] - }, "browser_action": { "browser_style": true, "default_title": "CryptoScript", diff --git a/src/common/GetKeybaseUserForDomainEvent.ts b/src/common/GetKeybaseUserForDomainEvent.ts index 9bf6213..2aa79f5 100644 --- a/src/common/GetKeybaseUserForDomainEvent.ts +++ b/src/common/GetKeybaseUserForDomainEvent.ts @@ -14,6 +14,7 @@ export class GetKeybaseUserForDomainResponse { constructor( public readonly keybaseUsers: KeybaseUser[], public readonly trusted: KeybaseUser[], - public readonly barred: KeybaseUser[] + public readonly barred: KeybaseUser[], + public readonly pending: KeybaseUser[], ) {} } diff --git a/src/common/GetUsersAwaitingConsentEvent.ts b/src/common/GetUsersAwaitingConsentEvent.ts new file mode 100644 index 0000000..925038f --- /dev/null +++ b/src/common/GetUsersAwaitingConsentEvent.ts @@ -0,0 +1,33 @@ +import { IEvent } from "./IEvent"; + +export class GetUsersAwaitingConsentEvent extends IEvent { + public static readonly TYPE = "GET_USERS_AWAITING_CONSENT_EVENT"; + + constructor(public readonly domain: string) { + super(GetUsersAwaitingConsentEvent.TYPE); + } +} + +type KeybaseUser = string; + +export class GetUsersAwaitingConsentResponse { + constructor( + public readonly keybaseUsers: KeybaseUser[] + ) {} +} + +export class AllowUserEvent extends IEvent { + public static readonly TYPE = "ALLOW_USER_EVENT"; + + constructor(public readonly domain: string, public readonly user: KeybaseUser) { + super(AllowUserEvent.TYPE); + } +} + +export class DeniedUserEvent extends IEvent { + public static readonly TYPE = "DENIED_USER_EVENT"; + + constructor(public readonly domain: string, public readonly user: KeybaseUser) { + super(DeniedUserEvent.TYPE); + } +}
\ No newline at end of file diff --git a/src/common/Script.ts b/src/common/Script.ts new file mode 100644 index 0000000..7466436 --- /dev/null +++ b/src/common/Script.ts @@ -0,0 +1,5 @@ +export interface Script { + signer: string; + script: string; + href: string; +}
\ No newline at end of file diff --git a/src/content/KeyRing.ts b/src/content/KeyRing.ts index 7715269..5454f9f 100644 --- a/src/content/KeyRing.ts +++ b/src/content/KeyRing.ts @@ -1,5 +1,6 @@ import * as P from "bluebird"; import { Buffer, KeyFetcher, KeyManager } from "kbpgp"; +import { PendingSignerError } from "./PendingSignerError"; const importFromArmoredPgp = P.promisify<KeyManager, { armored: string }>(KeyManager.import_from_armored_pgp, { context: KeyManager @@ -16,6 +17,7 @@ export default class KeyRing extends KeyFetcher { private trustedUsers: string[] = []; private barredUsers: string[] = []; + private pendingApproval: string[] = []; constructor(private domain: string) { super(); @@ -47,6 +49,13 @@ export default class KeyRing extends KeyFetcher { return this.trustedUsers; } + public addTrustedUser(user: string) { + this.pendingApproval = this.pendingApproval.filter(v => v !== user); + this.barredUsers = this.barredUsers.filter(v => v !== user); + this.trustedUsers.push(user); + this.storeUsersInStorage(); + } + /** * Get users that have been previously been barred from running scripts * on the `domain` that was used to instantiate this KeyRing. @@ -57,6 +66,19 @@ export default class KeyRing extends KeyFetcher { return this.barredUsers; } + public addBarredUser(user: string) { + this.pendingApproval = this.pendingApproval.filter(v => v !== user); + this.trustedUsers = this.trustedUsers.filter(v => v !== user); + this.barredUsers.push(user); + this.storeUsersInStorage(); + } + + public async getPendingApproval(): Promise<string[]> { + await this.populateKeysForDomain(); + + return this.pendingApproval; + } + /** * Ran by KBPGP to match a key id to a public key. Throws an error if * the signer isn't a "trusted" Keybase user @@ -86,6 +108,12 @@ export default class KeyRing extends KeyFetcher { k.keyManager, i ); + } else if (this.pendingApproval.includes(k.keybaseUser)) { + return cb( + new PendingSignerError(`Keybase user ${k.keybaseUser} is not yet approved for script signing on ${this.domain}`), + k.keyManager, + i + ); } else if (this.trustedUsers.includes(k.keybaseUser) && k.key.key.can_perform(ops)) { console.debug(`Allowing script from ${this.domain} to run as it was signed by ${k.keybaseUser}`); return k.keyManager.fetch(ids, ops, cb); @@ -115,11 +143,7 @@ export default class KeyRing extends KeyFetcher { const username = user.basics.username; if (!this.hasPreviouslySeenKeybaseUser(username)) { - if (this.getTreatmentForKeybaseUser(username)) { - this.trustedUsers.push(username); - } else { - this.barredUsers.push(username); - } + this.pendingApproval.push(username); } // @ts-ignore @@ -193,23 +217,4 @@ export default class KeyRing extends KeyFetcher { private hasPreviouslySeenKeybaseUser(user: string): boolean { return [...this.trustedUsers, ...this.barredUsers].includes(user); } - - /** - * Ask the user whether or not they'd like to run scripts from the given Keybase user. - * - * @param keybaseUser keybaseUser to ask permission for - */ - private getTreatmentForKeybaseUser(keybaseUser: string): boolean { - if ( - window.confirm( - `Since you last ran JavaScript from the domain ${ - this.domain - }, ${keybaseUser} has claimed ownership on Keybase. Would you like to allow JavaScript from this domain to be signed by this user?` - ) - ) { - return true; - } else { - return false; - } - } } diff --git a/src/content/PendingSignerError.ts b/src/content/PendingSignerError.ts new file mode 100644 index 0000000..ddf99be --- /dev/null +++ b/src/content/PendingSignerError.ts @@ -0,0 +1 @@ +export class PendingSignerError extends Error {}
\ No newline at end of file diff --git a/src/content/ScriptInterceptor.ts b/src/content/ScriptInterceptor.ts index bba5d0d..4614f5c 100644 --- a/src/content/ScriptInterceptor.ts +++ b/src/content/ScriptInterceptor.ts @@ -5,27 +5,42 @@ import { GetKeybaseUserForDomainEvent, GetKeybaseUserForDomainResponse } from ". import { IEvent } from "../common/IEvent"; import KeyRing from "./KeyRing"; import { fetch } from "./util"; +import { GetUsersAwaitingConsentEvent, GetUsersAwaitingConsentResponse, AllowUserEvent, DeniedUserEvent } from "../common/GetUsersAwaitingConsentEvent"; +import { Script } from "../common/Script"; +import { PendingSignerError } from "./PendingSignerError"; const unbox = P.promisify<any, any>(unboxSync); export default new class ScriptInterceptor implements EventListenerObject { private keyRingCache: { [domain: string]: KeyRing } = {}; + private lock: boolean = false; + private scriptQueue: HTMLScriptElement[] = []; + constructor() { browser.runtime.onMessage.addListener(async (message: IEvent) => { if (message.TYPE === GetKeybaseUserForDomainEvent.TYPE) { const event = message as GetKeybaseUserForDomainEvent; - const keyRing = this.keyRingCache[event.domain]; - - if (keyRing) { - return new GetKeybaseUserForDomainResponse( - await keyRing.getKeybaseUsers(), - await keyRing.getTrustedUsers(), - await keyRing.getBarredUsers() - ); - } else { - return new GetKeybaseUserForDomainResponse([], [], []); - } + const keyRing = this.getKeyRingForDomain(event.domain); + + return new GetKeybaseUserForDomainResponse( + await keyRing.getKeybaseUsers(), + await keyRing.getTrustedUsers(), + await keyRing.getBarredUsers(), + await keyRing.getPendingApproval() + ); + } else if (message.TYPE === GetUsersAwaitingConsentEvent.TYPE) { + const event = message as GetUsersAwaitingConsentEvent; + const keyRing = this.getKeyRingForDomain(event.domain); + return new GetUsersAwaitingConsentResponse(await keyRing.getPendingApproval()); + } else if (message.TYPE === AllowUserEvent.TYPE) { + const event = message as AllowUserEvent; + this.getKeyRingForDomain(event.domain).addTrustedUser(event.user); + await this.drainScriptQueue(); + } else if (message.TYPE === DeniedUserEvent.TYPE) { + const event = message as DeniedUserEvent; + this.getKeyRingForDomain(event.domain).addBarredUser(event.user); + await this.drainScriptQueue(); } }); } @@ -44,18 +59,98 @@ export default new class ScriptInterceptor implements EventListenerObject { // the script tag that we stopped from running const script = e.target as HTMLScriptElement; - try { - const scriptContent = await this.getScriptContent(script); + const monitor = setInterval(async () => { + if (this.lock) return; + + try { + this.lock = true; - const domain = new URL((window as any).location.href).hostname; + await this.checkPermissionMaybeExecute(script); + } catch (e) { + console.error(`Execution of script (${script.src || "inline"}) failed. ${e.name}: ${e.message}`, e.stack); + throw e; + } finally { + this.lock = false; + clearInterval(monitor); + } + }, 10); + } + + private async checkPermissionMaybeExecute(script: HTMLScriptElement) { + const scriptContent = await this.getScriptContent(script); + + const domain = new URL((window as any).location.href).hostname; + + if (this.scriptQueue.length > 0) { + this.scriptQueue.push(script); + script.parentNode.removeChild(script); + return; + } + try { if (await this.verifySignature(script, scriptContent, domain)) { // in firefox calling eval on "window" executes in the context of the page thankfully (window as any).eval(scriptContent); + } else { + script.parentNode.removeChild(script); } } catch (e) { - console.error(`Execution of script (${script.src || "inline"}) failed. ${e.name}: ${e.message}`, e.stack); - throw e; + if (e instanceof PendingSignerError) { + this.scriptQueue.push(script); + script.parentNode.removeChild(script); + } else { + throw e; + } + } + } + + private async drainScriptQueue() { + const domain = new URL((window as any).location.href).hostname; + + while (this.scriptQueue.length > 0) { + const script = this.scriptQueue.shift(); + + try { + const scriptContent = await this.getScriptContent(script); + + if (await this.verifySignature(script, scriptContent, domain)) { + (window as any).eval(scriptContent); + } else { + console.log(`Script depends on a blocked signer, there's still ${this.scriptQueue.length} elements left in the script queue.`); + this.scriptQueue.unshift(script); + return; + } + } catch (e) { + if (e instanceof PendingSignerError) { + this.scriptQueue.unshift(script); + console.log(`Script depends on a pending signer, there's still ${this.scriptQueue.length} elements left in the script queue.`); + return; + } else { + this.scriptQueue.unshift(script); + throw e; + } + } + } + + for (const script of this.scriptQueue) { + try { + const scriptContent = await this.getScriptContent(script); + + if (await this.verifySignature(script, scriptContent, domain)) { + (window as any).eval(scriptContent); + this.scriptQueue.shift(); + } else { + console.log(`Script depends on a blocked signer, there's still ${this.scriptQueue.length} elements left in the script queue.`); + return; + } + } catch (e) { + if (e instanceof PendingSignerError) { + console.log(`Script depends on a pending signer, there's still ${this.scriptQueue.length} elements left in the script queue.`); + return; + } else { + throw e; + } + } } } @@ -82,6 +177,12 @@ export default new class ScriptInterceptor implements EventListenerObject { * @throws Error if signature verification fails */ private async verifySignature(script: HTMLScriptElement, scriptContent: string, domain: string): Promise<boolean> { + const signaturePath = script.dataset.signature; + + if (!signaturePath) { + return await this.getTreatmentForUnsignedScripts(domain); + } + const keyRing = this.getKeyRingForDomain(domain); if (!(await keyRing.getKeybaseUsers()).length) { @@ -90,7 +191,7 @@ export default new class ScriptInterceptor implements EventListenerObject { return true; } - const signatureContent = await this.getSignatureFromScript(script); + const signatureContent = await (await fetch(signaturePath)).text(); const literals = await unbox({ armored: new KbpgpBuffer(signatureContent), @@ -125,19 +226,19 @@ export default new class ScriptInterceptor implements EventListenerObject { } /** - * Get signature of the script from the `data-signature` attribute. - * - * @param script script to get signature of - * @throws Error if script is not signed + * Ask the user whether or not they'd like to run unsigned scripts. */ - private async getSignatureFromScript(script: HTMLScriptElement): Promise<string> { - const signaturePath = script.dataset.signature; + private async getTreatmentForUnsignedScripts(domain: string): Promise<boolean> { + const storageKey = `kpj_unsigned_scripts_${domain}`; - if (!signaturePath) { - throw new Error(`Script not signed (no data-signature attribute)`); - } + const storedAnswer = (await browser.storage.local.get(storageKey))[storageKey]; - const signatureRequest = await Promise.resolve(fetch(signaturePath)); - return await signatureRequest.text(); + if (storedAnswer !== undefined) { + return !!storedAnswer; + } else { + const confirmation = !!window.confirm('There are unsigned scripts on this website, would you like to run them?'); + await browser.storage.local.set({ [storageKey]: confirmation }); + return confirmation; + } } }(); diff --git a/src/popup/js/pages/Index.tsx b/src/popup/js/pages/Index.tsx index c0da741..fa874cc 100644 --- a/src/popup/js/pages/Index.tsx +++ b/src/popup/js/pages/Index.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { GetKeybaseUserForDomainEvent, GetKeybaseUserForDomainResponse } from "../../../common/GetKeybaseUserForDomainEvent"; import { Row } from "../components/Row"; +import { GetUsersAwaitingConsentEvent, GetUsersAwaitingConsentResponse, AllowUserEvent, DeniedUserEvent } from '../../../common/GetUsersAwaitingConsentEvent'; interface ToolbarProps { } @@ -10,21 +11,10 @@ interface ToolbarState { domain: string, keybaseUsers: string[], trustedUsers: string[], - barredUsers: string[] + barredUsers: string[], + needsApproval: string[] } -const WriteUsersList = (props: { header: string, users: string[] }) => { - if (props.users.length) { - return <Row header={ props.header }> - <ul> - { props.users.map((u) => <li>{ u }</li>) } - </ul> - </Row>; - } else { - return <div />; - } -}; - export class Index extends React.Component<ToolbarProps, ToolbarState> { constructor(props: any) { super(props); @@ -32,7 +22,8 @@ export class Index extends React.Component<ToolbarProps, ToolbarState> { domain: "", keybaseUsers: [], trustedUsers: [], - barredUsers: [] + barredUsers: [], + needsApproval: [] }; } @@ -44,20 +35,63 @@ export class Index extends React.Component<ToolbarProps, ToolbarState> { this.setState({ domain }); - await browser.tabs.sendMessage(activeTab.id, new GetKeybaseUserForDomainEvent(domain)) + await this.updateUsers(); + } + + async updateUsers() { + const [ activeTab ] = await browser.tabs.query({ active: true }); + + await browser.tabs.sendMessage(activeTab.id, new GetKeybaseUserForDomainEvent(this.state.domain)) .then((res: GetKeybaseUserForDomainResponse) => { this.setState({ keybaseUsers: res.keybaseUsers, trustedUsers: res.trusted, - barredUsers: res.barred + barredUsers: res.barred, + needsApproval: res.pending }); }); } + approve = async (user: string) => { + const [ activeTab ] = await browser.tabs.query({ active: true }); + browser.tabs.sendMessage(activeTab.id, new AllowUserEvent(this.state.domain, user)) + .then(this.updateUsers.bind(this)); + } + + deny = async (user: string) => { + const [ activeTab ] = await browser.tabs.query({ active: true }); + browser.tabs.sendMessage(activeTab.id, new DeniedUserEvent(this.state.domain, user)) + .then(this.updateUsers.bind(this)); + } + render() { return <div className="container"> - <WriteUsersList header="Trusted Keybase Users" users={ this.state.trustedUsers } /> - <WriteUsersList header="Barred Keybase Users" users={ this.state.barredUsers } /> + <Row header="Trusted Keybase Users"> + <ul> + { this.state.trustedUsers.map((u) => <li> + { u } + <a href="#" onClick={ e => this.deny(u) } style={{ color: '#F44336' }}>deny</a> + </li>) } + </ul> + </Row> + <Row header="Barred Keybase Users"> + <ul> + { this.state.barredUsers.map((u) => <li> + { u } + <a href="#" onClick={ e => this.approve(u) } style={{ color: '#4CAF50' }}>approve</a> + </li>) } + </ul> + </Row> + <Row header="Needs Approval"> + <ul> + { this.state.needsApproval.map((u) => <li> + { u } + <a href="#" onClick={ e => this.approve(u) } style={{ color: '#4CAF50' }}>approve</a> + <a href="#" onClick={ e => this.deny(u) } style={{ color: '#F44336' }}>deny</a> + </li>) } + </ul> + </Row> + </div>; } } |