diff --git a/src/components/intercept/config/emudhra-config.tsx b/src/components/intercept/config/emudhra-config.tsx new file mode 100644 index 000000000..325726458 --- /dev/null +++ b/src/components/intercept/config/emudhra-config.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { observable, flow } from 'mobx'; +import { inject, observer } from 'mobx-react'; + +import { ProxyStore } from '../../../model/proxy-store'; +import { Interceptor } from '../../../model/interception/interceptors'; +import { asError } from '../../../util/error'; +import { Content, CopyableMonoValue } from '../../common/text-content'; +import { Button } from '../../common/inputs'; + +@inject('proxyStore') +@observer +export class EmudhraConfig extends React.Component<{ + proxyStore?: ProxyStore; + interceptor: Interceptor; + closeSelf: () => void; + activateInterceptor: () => Promise<{ port: number }>; + reportStarted: () => void; + reportSuccess: (options?: { showRequests?: boolean }) => void; +}> { + @observable port: number | undefined; + @observable error: string | undefined; + + componentDidMount() { + this.enable(); + } + + enable = flow(function * (this: EmudhraConfig) { + this.error = undefined; + this.props.reportStarted(); + try { + this.port = yield this.props.proxyStore!.enableExtraHttps('emudhra'); + this.props.reportSuccess({ showRequests: false }); + } catch (error) { + this.error = asError(error).message; + } + }).bind(this); + + render() { + if (this.error) return +

Could not enable eMudhra interception: {this.error}

+ +
; + + if (!this.port) return

Opening the eMudhra interception port…

; + + return +

+ Configure your client's HTTP/HTTPS proxy using one of these addresses. + From another device, use this machine's network IP address. +

+ +

+ For devices without proxy settings, route their traffic through this machine + and forward HTTP/TLS connections to this port, preserving the original hostname. + This may require DNS overrides, ARP spoofing, or a custom Wi-Fi hotspot with port forwarding. +

+

+ Uses the compromised eMudhra CA signed by AAA Certificate Services. + The client must already trust this CA. Do not install additional certificates. +

+

+ Your normal proxy keeps using the local CA. This extra port stays open until + the proxy session ends. +

+
; + } +} + +export const EmudhraCustomUi = { + rowHeight: 1, + columnWidth: 4, + configComponent: EmudhraConfig +}; diff --git a/src/model/interception/interceptor-store.ts b/src/model/interception/interceptor-store.ts index e09c875aa..deab02009 100644 --- a/src/model/interception/interceptor-store.ts +++ b/src/model/interception/interceptor-store.ts @@ -47,7 +47,8 @@ export class InterceptorStore { const supportedInterceptors = getInterceptOptions( serverInterceptors, this.accountStore, - serverVersion + serverVersion, + this.proxyStore.extraHttpsNames ); // Quick patch for a bug in existing-chrome for server <= 1.1.2 which incorrectly diff --git a/src/model/interception/interceptors.ts b/src/model/interception/interceptors.ts index 92f66f241..c746da216 100644 --- a/src/model/interception/interceptors.ts +++ b/src/model/interception/interceptors.ts @@ -20,6 +20,7 @@ import { onActivateExistingBrowser } from "../../components/intercept/config/exi import { JvmCustomUi } from "../../components/intercept/config/jvm-config"; import { DockerAttachCustomUi } from "../../components/intercept/config/docker-attach-config"; import { ManualIOSCustomUi } from "../../components/intercept/config/manual-ios-config"; +import { EmudhraCustomUi } from '../../components/intercept/config/emudhra-config'; interface InterceptorConfig { name: string; @@ -31,7 +32,8 @@ interface InterceptorConfig { checkRequirements?: (options: { interceptorVersion: string, accountStore: AccountStore, - serverVersion?: string + serverVersion?: string, + extraHttpsNames: string[] }) => boolean; uiConfig?: InterceptorCustomUiConfig; customActivation?: CustomActivationFunction, @@ -355,6 +357,16 @@ const INTERCEPT_OPTIONS: _.Dictionary = { uiConfig: ElectronCustomUi, tags: ['electron', 'desktop', 'postman'] }, + 'emudhra': { + name: 'eMudhra Interception', + description: ['Intercept clients that already trust the legacy compromised eMudhra certificates'], + iconProps: { icon: ['fas', 'unlock'], color: '#d98b32' }, + uiConfig: EmudhraCustomUi, + clientOnly: true, + checkRequirements: ({ extraHttpsNames }) => extraHttpsNames.includes('emudhra'), + tags: ['emudhra', 'legacy', 'iot', 'network', 'devices'], + experimental: true + }, [MANUAL_INTERCEPT_ID]: { name: 'Anything', description: ["Manually configure any source using the proxy settings and CA certificate"], @@ -368,7 +380,8 @@ const INTERCEPT_OPTIONS: _.Dictionary = { export function getInterceptOptions( serverInterceptorArray: ServerInterceptor[], accountStore: AccountStore, - serverVersion?: string + serverVersion?: string, + extraHttpsNames: string[] = [] ) { const serverInterceptors = _.keyBy(serverInterceptorArray, 'id'); @@ -381,7 +394,8 @@ export function getInterceptOptions( (option.checkRequirements && !option.checkRequirements({ interceptorVersion: (serverInterceptors[id] || {}).version, accountStore, - serverVersion + serverVersion, + extraHttpsNames })) ) { // The UI knows about this interceptor, but we can't use it for some reason. diff --git a/src/model/proxy-store.ts b/src/model/proxy-store.ts index d8f01998c..91aa60fcf 100644 --- a/src/model/proxy-store.ts +++ b/src/model/proxy-store.ts @@ -232,6 +232,8 @@ export class ProxyStore { { messageBodyDecoding: 'none' } ); + this.extraHttpsNames = this.adminClient.metadata.http.extraHttpsNames ?? []; + announceServerReady(); console.log('Server started'); @@ -313,6 +315,13 @@ export class ProxyStore { return this.adminClient.metadata.http.port; } + @observable + extraHttpsNames: string[] = []; + + enableExtraHttps(name: string): Promise { + return this.adminClient.sendQuery(this.mockttpRequestBuilder.buildEnableExtraHttpsQuery(name)); + } + @persist @observable http2Enabled: true | false | 'fallback' = 'fallback'; private _http2CurrentlyEnabled = this.http2Enabled; diff --git a/test/unit/components/intercept/emudhra-config.spec.tsx b/test/unit/components/intercept/emudhra-config.spec.tsx new file mode 100644 index 000000000..114f32003 --- /dev/null +++ b/test/unit/components/intercept/emudhra-config.spec.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { expect } from '../../../test-setup'; +import { EmudhraConfig } from '../../../../src/components/intercept/config/emudhra-config'; +import { CopyableMonoValue } from '../../../../src/components/common/text-content'; +import { ProxyStore } from '../../../../src/model/proxy-store'; + +const Config = (EmudhraConfig as any).wrappedComponent; + +describe('eMudhra interception instructions', () => { + const props = () => ({ + interceptor: {} as any, + proxyStore: { + externalNetworkAddresses: ['192.0.2.1', '::1'], + enableExtraHttps: async (_name: string) => 8001 + } as ProxyStore, + activateInterceptor: async () => { throw new Error('Unexpected server interceptor activation'); }, + reportStarted: () => {}, + reportSuccess: (_options?: { showRequests?: boolean }) => {}, + closeSelf: () => {} + }); + + it('shows the allocated port and manual instructions after activation', async () => { + const config = props(); + let started = false; + let showRequests: boolean | undefined; + config.reportStarted = () => { started = true; }; + config.reportSuccess = options => { showRequests = options?.showRequests; }; + const view = shallow(, { disableLifecycleMethods: true }); + expect(view.text()).to.contain('Opening'); + await (view.instance() as any).enable(); + view.update(); + expect(started).to.equal(true); + expect(showRequests).to.equal(false); + const addresses = view.find(CopyableMonoValue).map(node => node.children().map(child => child.text()).join('')); + expect(addresses).to.deep.equal([ + 'http://localhost:8001', 'http://192.0.2.1:8001', 'http://[::1]:8001' + ]); + expect(view.find('p').map(node => node.text()).join(' ')).to.contain('Do not install additional certificates'); + view.unmount(); + }); + + it('shows server errors and allows activation to be retried', async () => { + const config = props(); + let attempts = 0; + config.proxyStore.enableExtraHttps = async () => { + if (++attempts === 1) throw new Error('No free port available'); + return 8002; + }; + const view = shallow(, { disableLifecycleMethods: true }); + await (view.instance() as any).enable(); + view.update(); + expect(view.find({ role: 'alert' }).text()).to.contain('No free port available'); + await (view.instance() as any).enable(); + view.update(); + expect(view.find({ role: 'alert' }).exists()).to.equal(false); + expect(view.find(CopyableMonoValue).first().children().map(child => child.text()).join('')).to.equal('http://localhost:8002'); + view.unmount(); + }); +});