Skip to content

Commit bb0356a

Browse files
committed
Build out a complete set of automated Android tests
1 parent 1e9aa02 commit bb0356a

File tree

2 files changed

+241
-19
lines changed

2 files changed

+241
-19
lines changed

test/android/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
node_modules
1+
node_modules/
2+
tmp/

test/android/test.ts

Lines changed: 239 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,268 @@
1+
import * as fs from 'fs/promises';
2+
import * as mockttp from 'mockttp';
13
import * as appium from 'appium';
24
import { remote } from 'webdriverio';
35
import { expect } from 'chai';
4-
import { delay } from '@httptoolkit/util';
6+
import * as ChildProcess from 'child_process';
7+
8+
const IGNORED_BUTTONS = [
9+
'RAW CUSTOM-PINNED REQUEST',
10+
];
11+
12+
const waitForContentDescription = async (button: WebdriverIO.Element): Promise<string> =>
13+
button.waitUntil(
14+
() => button.getAttribute('content-desc'),
15+
{ timeout: 30_000 } // Some buttons (AppMattus webview) can take a while
16+
);
517

618
describe('Test Android unpinning', function () {
719

820
this.timeout(60_000);
921

1022
let appiumServer: any;
1123
let driver: WebdriverIO.Browser;
24+
let fridaSession: ChildProcess.ChildProcess;
25+
let proxyServer: mockttp.Mockttp;
26+
27+
let seenRequests: mockttp.CompletedRequest[] = [];
28+
let tlsFailures: mockttp.TlsConnectionEvent[] = [];
29+
30+
before(async () => {
31+
const [cert, key] = await Promise.all([
32+
fs.readFile('./tmp/ca.crt', 'utf8'),
33+
fs.readFile('./tmp/ca.key', 'utf8')
34+
]).catch(async () => {
35+
// If the files don't exist, generate a new CA cert
36+
const ca = await mockttp.generateCACertificate();
37+
await fs.mkdir('./tmp');
38+
await fs.writeFile('./tmp/ca.crt', ca.cert);
39+
await fs.writeFile('./tmp/ca.key', ca.key);
40+
return [ca.cert, ca.key];
41+
});
42+
43+
proxyServer = mockttp.getLocal({
44+
recordTraffic: false,
45+
https: {
46+
cert,
47+
key
48+
},
49+
socks: true,
50+
passthrough: ['unknown-protocol'],
51+
http2: true
52+
});
53+
54+
await proxyServer.start();
55+
56+
const configBase = await fs.readFile('../../config.js', 'utf8');
57+
const config = configBase
58+
.replace(/(?<=const DEBUG = `)false/s, 'true')
59+
.replace(/(?<=const CERT_PEM = `)[^`]+(?=`)/s, cert.trim())
60+
.replace(/(?<=const PROXY_HOST = ')[^']+(?=')/, '10.0.2.2') // Android emulator localhost IP
61+
.replace(/(?<=const PROXY_PORT = )\d+(?=;)/, proxyServer.port.toString());
62+
await fs.writeFile('./tmp/config.js', config);
63+
});
1264

1365
before(async () => {
1466
appiumServer = await appium.main({
1567
loglevel: 'warn'
1668
});
69+
});
70+
71+
after(async () => {
72+
if (appiumServer) {
73+
await appiumServer.closeAllConnections();
74+
await appiumServer.close();
75+
await appiumServer.unref();
76+
}
77+
78+
if (proxyServer) {
79+
await proxyServer.stop();
80+
}
81+
});
82+
83+
beforeEach(async () => {
84+
proxyServer.reset();
85+
86+
let reqCount = 0;
87+
proxyServer.forAnyRequest().thenCallback((req) => {
88+
console.log(`Intercepted request ${reqCount++}: ${req.method} ${req.url}`);
89+
return { statusCode: 200, body: 'Mocked response' };
90+
});
91+
92+
seenRequests = [];
93+
tlsFailures = [];
94+
proxyServer.on('request', (req) => seenRequests.push(req));
95+
proxyServer.on('tls-client-error', (event) => tlsFailures.push(event));
96+
});
97+
98+
async function launchFrida(scripts: string[]) {
99+
fridaSession = ChildProcess.spawn('frida', [
100+
'-U',
101+
...(
102+
scripts.map((script) => ['-l', script]).flat()
103+
),
104+
'-f', 'tech.httptoolkit.pinning_demo'
105+
], {
106+
cwd: '../..',
107+
stdio: 'pipe'
108+
});
109+
110+
fridaSession.stdout?.pipe(process.stdout);
111+
fridaSession.stderr?.pipe(process.stderr);
112+
113+
// Wait for Frida to start the app successfully
114+
await new Promise<void>((resolve, reject) => {
115+
fridaSession!.on('error', reject);
116+
fridaSession!.stdout?.on('data', (d) => {
117+
if (d.toString().includes('Spawned `tech.httptoolkit.pinning_demo`')) {
118+
resolve();
119+
}
120+
if (d.toString().includes('Error: ')) {
121+
reject(new Error(`Frida error: ${d.toString()}`));
122+
}
123+
})
124+
});
17125

18126
driver = await remote({
19127
port: 4723,
128+
logLevel: 'warn',
20129
capabilities: {
21130
platformName: 'android',
22-
'appium:appPackage': 'tech.httptoolkit.pinning_demo',
23131
'appium:automationName': 'UiAutomator2',
24-
'appium:appActivity': '.MainActivity'
132+
'appium:noReset': true,
133+
'appium:fullReset': false,
25134
}
26135
});
27-
});
136+
}
28137

29-
after(async () => {
30-
if (driver) await driver.deleteSession();
31-
if (appiumServer) await appiumServer.close();
32-
})
33-
34-
it('should run a test', async () => {
35-
const button = driver.$('android=new UiSelector().text("UNPINNED REQUEST")');
36-
await button.click();
37-
38-
let contentDescription: string | undefined;
39-
while (!contentDescription) {
40-
await delay(500);
41-
contentDescription = await button.getAttribute('content-desc');
138+
afterEach(async () => {
139+
if (driver) {
140+
await driver.deleteSession();
42141
}
43142

44-
expect(contentDescription).to.include('Success');
143+
if (fridaSession) {
144+
fridaSession.kill('SIGUSR1');
145+
await new Promise(resolve => fridaSession!.on('exit', resolve));
146+
}
147+
});
148+
149+
// We run this 100% failure test first, to warm everything up
150+
describe("without proxy config but no certificate trust", () => {
151+
152+
beforeEach(async () => {
153+
await launchFrida([
154+
'./test/android/tmp/config.js', // Our custom config
155+
// Redirect traffic but don't configure the cert - everything should fail:
156+
'./android/android-proxy-override.js'
157+
]);
158+
});
159+
160+
it("all requests should fail", async () => {
161+
const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")');
162+
expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present');
163+
164+
buttons.map(button => button.click());
165+
await Promise.all(await buttons.map(async (button) => {
166+
const buttonText = await button.getText();
167+
if (!IGNORED_BUTTONS.includes(buttonText.toUpperCase())) {
168+
expect(await waitForContentDescription(button)).to.include('Failed');
169+
}
170+
}));
171+
172+
expect(seenRequests).to.have.lengthOf(0, 'Expected all requests to fail');
173+
expect(tlsFailures).to.have.lengthOf(13, 'Expected TLS failures for all requests');
174+
});
175+
176+
});
177+
178+
describe("given no interception", () => {
179+
180+
beforeEach(async () => {
181+
await launchFrida([]);
182+
});
183+
184+
it('all buttons should succeed initially', async () => {
185+
const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")');
186+
expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present');
187+
188+
buttons.map(button => button.click());
189+
await Promise.all(await buttons.map(async (button) => {
190+
const buttonText = await button.getText();
191+
if (!IGNORED_BUTTONS.includes(buttonText.toUpperCase())) {
192+
expect(await waitForContentDescription(button)).to.include('Success');
193+
}
194+
}));
195+
196+
expect(seenRequests).to.have.lengthOf(0, 'Expected no requests to be intercepted');
197+
expect(tlsFailures).to.have.lengthOf(0, 'Expected no TLS failures');
198+
});
199+
200+
});
201+
202+
describe("given basic interception", () => {
203+
204+
beforeEach(async () => {
205+
await launchFrida([
206+
'./test/android/tmp/config.js', // Our custom config
207+
// Otherwise just the basic Android settings injection scripts to set the
208+
// system cert & system proxy:
209+
'./android/android-proxy-override.js',
210+
'./android/android-system-certificate-injection.js'
211+
]);
212+
});
213+
214+
it("all unpinned requests should succeed, all others should fail", async () => {
215+
const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")');
216+
expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present');
217+
218+
buttons.map(button => button.click());
219+
await Promise.all(await buttons.map(async (button) => {
220+
const buttonText = await button.getText();
221+
if (buttonText.toUpperCase().startsWith('UNPINNED')) {
222+
expect(await waitForContentDescription(button)).to.include('Success');
223+
}
224+
// Some pinnned requests will still pass because the basic cert
225+
// injection is just *that* good.
226+
}));
227+
228+
expect(seenRequests).to.have.lengthOf.at.least(3, 'Expected unpinned requests to be intercepted');
229+
expect(tlsFailures).to.have.lengthOf.at.least(5, 'Expected most pinned requests to fail');
230+
});
231+
232+
});
233+
234+
describe("given full unpinned interception", () => {
235+
236+
beforeEach(async () => {
237+
await launchFrida([
238+
'./test/android/tmp/config.js', // Our custom config
239+
// Otherwise the standard scripts, as in the README:
240+
'./native-connect-hook.js',
241+
'./native-tls-hook.js',
242+
'./android/android-proxy-override.js',
243+
'./android/android-system-certificate-injection.js',
244+
'./android/android-certificate-unpinning.js',
245+
'./android/android-certificate-unpinning-fallback.js',
246+
'./android/android-disable-root-detection.js',
247+
]);
248+
});
249+
250+
it("all buttons except 'Raw custom-pinned request' should succeed", async () => {
251+
const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")');
252+
expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present');
253+
254+
buttons.map(button => button.click());
255+
await Promise.all(await buttons.map(async (button) => {
256+
const buttonText = await button.getText();
257+
if (!IGNORED_BUTTONS.includes(buttonText.toUpperCase())) {
258+
expect(await waitForContentDescription(button)).to.include('Success');
259+
}
260+
}));
261+
262+
expect(seenRequests).to.have.lengthOf(14, 'Expected all requests to be intercepted');
263+
expect(tlsFailures).to.have.lengthOf(1, 'Expected only raw request to fail');
264+
});
265+
45266
});
46267

47268
});

0 commit comments

Comments
 (0)