From bb0356ac3421aab1727fe65455fbfbb69391afc1 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 3 Jul 2025 20:25:55 +0200 Subject: [PATCH] Build out a complete set of automated Android tests --- test/android/.gitignore | 3 +- test/android/test.ts | 269 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 247 insertions(+), 25 deletions(-) diff --git a/test/android/.gitignore b/test/android/.gitignore index 3c3629e..104d999 100644 --- a/test/android/.gitignore +++ b/test/android/.gitignore @@ -1 +1,2 @@ -node_modules +node_modules/ +tmp/ \ No newline at end of file diff --git a/test/android/test.ts b/test/android/test.ts index 1745530..c0405cf 100644 --- a/test/android/test.ts +++ b/test/android/test.ts @@ -1,7 +1,19 @@ +import * as fs from 'fs/promises'; +import * as mockttp from 'mockttp'; import * as appium from 'appium'; import { remote } from 'webdriverio'; import { expect } from 'chai'; -import { delay } from '@httptoolkit/util'; +import * as ChildProcess from 'child_process'; + +const IGNORED_BUTTONS = [ + 'RAW CUSTOM-PINNED REQUEST', +]; + +const waitForContentDescription = async (button: WebdriverIO.Element): Promise => + button.waitUntil( + () => button.getAttribute('content-desc'), + { timeout: 30_000 } // Some buttons (AppMattus webview) can take a while + ); describe('Test Android unpinning', function () { @@ -9,39 +21,248 @@ describe('Test Android unpinning', function () { let appiumServer: any; let driver: WebdriverIO.Browser; + let fridaSession: ChildProcess.ChildProcess; + let proxyServer: mockttp.Mockttp; + + let seenRequests: mockttp.CompletedRequest[] = []; + let tlsFailures: mockttp.TlsConnectionEvent[] = []; + + before(async () => { + const [cert, key] = await Promise.all([ + fs.readFile('./tmp/ca.crt', 'utf8'), + fs.readFile('./tmp/ca.key', 'utf8') + ]).catch(async () => { + // If the files don't exist, generate a new CA cert + const ca = await mockttp.generateCACertificate(); + await fs.mkdir('./tmp'); + await fs.writeFile('./tmp/ca.crt', ca.cert); + await fs.writeFile('./tmp/ca.key', ca.key); + return [ca.cert, ca.key]; + }); + + proxyServer = mockttp.getLocal({ + recordTraffic: false, + https: { + cert, + key + }, + socks: true, + passthrough: ['unknown-protocol'], + http2: true + }); + + await proxyServer.start(); + + const configBase = await fs.readFile('../../config.js', 'utf8'); + const config = configBase + .replace(/(?<=const DEBUG = `)false/s, 'true') + .replace(/(?<=const CERT_PEM = `)[^`]+(?=`)/s, cert.trim()) + .replace(/(?<=const PROXY_HOST = ')[^']+(?=')/, '10.0.2.2') // Android emulator localhost IP + .replace(/(?<=const PROXY_PORT = )\d+(?=;)/, proxyServer.port.toString()); + await fs.writeFile('./tmp/config.js', config); + }); before(async () => { appiumServer = await appium.main({ loglevel: 'warn' }); - - driver = await remote({ - port: 4723, - capabilities: { - platformName: 'android', - 'appium:appPackage': 'tech.httptoolkit.pinning_demo', - 'appium:automationName': 'UiAutomator2', - 'appium:appActivity': '.MainActivity' - } - }); }); after(async () => { - if (driver) await driver.deleteSession(); - if (appiumServer) await appiumServer.close(); - }) - - it('should run a test', async () => { - const button = driver.$('android=new UiSelector().text("UNPINNED REQUEST")'); - await button.click(); - - let contentDescription: string | undefined; - while (!contentDescription) { - await delay(500); - contentDescription = await button.getAttribute('content-desc'); + if (appiumServer) { + await appiumServer.closeAllConnections(); + await appiumServer.close(); + await appiumServer.unref(); } - expect(contentDescription).to.include('Success'); + if (proxyServer) { + await proxyServer.stop(); + } + }); + + beforeEach(async () => { + proxyServer.reset(); + + let reqCount = 0; + proxyServer.forAnyRequest().thenCallback((req) => { + console.log(`Intercepted request ${reqCount++}: ${req.method} ${req.url}`); + return { statusCode: 200, body: 'Mocked response' }; + }); + + seenRequests = []; + tlsFailures = []; + proxyServer.on('request', (req) => seenRequests.push(req)); + proxyServer.on('tls-client-error', (event) => tlsFailures.push(event)); + }); + + async function launchFrida(scripts: string[]) { + fridaSession = ChildProcess.spawn('frida', [ + '-U', + ...( + scripts.map((script) => ['-l', script]).flat() + ), + '-f', 'tech.httptoolkit.pinning_demo' + ], { + cwd: '../..', + stdio: 'pipe' + }); + + fridaSession.stdout?.pipe(process.stdout); + fridaSession.stderr?.pipe(process.stderr); + + // Wait for Frida to start the app successfully + await new Promise((resolve, reject) => { + fridaSession!.on('error', reject); + fridaSession!.stdout?.on('data', (d) => { + if (d.toString().includes('Spawned `tech.httptoolkit.pinning_demo`')) { + resolve(); + } + if (d.toString().includes('Error: ')) { + reject(new Error(`Frida error: ${d.toString()}`)); + } + }) + }); + + driver = await remote({ + port: 4723, + logLevel: 'warn', + capabilities: { + platformName: 'android', + 'appium:automationName': 'UiAutomator2', + 'appium:noReset': true, + 'appium:fullReset': false, + } + }); + } + + afterEach(async () => { + if (driver) { + await driver.deleteSession(); + } + + if (fridaSession) { + fridaSession.kill('SIGUSR1'); + await new Promise(resolve => fridaSession!.on('exit', resolve)); + } + }); + + // We run this 100% failure test first, to warm everything up + describe("without proxy config but no certificate trust", () => { + + beforeEach(async () => { + await launchFrida([ + './test/android/tmp/config.js', // Our custom config + // Redirect traffic but don't configure the cert - everything should fail: + './android/android-proxy-override.js' + ]); + }); + + it("all requests should fail", async () => { + const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")'); + expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present'); + + buttons.map(button => button.click()); + await Promise.all(await buttons.map(async (button) => { + const buttonText = await button.getText(); + if (!IGNORED_BUTTONS.includes(buttonText.toUpperCase())) { + expect(await waitForContentDescription(button)).to.include('Failed'); + } + })); + + expect(seenRequests).to.have.lengthOf(0, 'Expected all requests to fail'); + expect(tlsFailures).to.have.lengthOf(13, 'Expected TLS failures for all requests'); + }); + + }); + + describe("given no interception", () => { + + beforeEach(async () => { + await launchFrida([]); + }); + + it('all buttons should succeed initially', async () => { + const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")'); + expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present'); + + buttons.map(button => button.click()); + await Promise.all(await buttons.map(async (button) => { + const buttonText = await button.getText(); + if (!IGNORED_BUTTONS.includes(buttonText.toUpperCase())) { + expect(await waitForContentDescription(button)).to.include('Success'); + } + })); + + expect(seenRequests).to.have.lengthOf(0, 'Expected no requests to be intercepted'); + expect(tlsFailures).to.have.lengthOf(0, 'Expected no TLS failures'); + }); + + }); + + describe("given basic interception", () => { + + beforeEach(async () => { + await launchFrida([ + './test/android/tmp/config.js', // Our custom config + // Otherwise just the basic Android settings injection scripts to set the + // system cert & system proxy: + './android/android-proxy-override.js', + './android/android-system-certificate-injection.js' + ]); + }); + + it("all unpinned requests should succeed, all others should fail", async () => { + const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")'); + expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present'); + + buttons.map(button => button.click()); + await Promise.all(await buttons.map(async (button) => { + const buttonText = await button.getText(); + if (buttonText.toUpperCase().startsWith('UNPINNED')) { + expect(await waitForContentDescription(button)).to.include('Success'); + } + // Some pinnned requests will still pass because the basic cert + // injection is just *that* good. + })); + + expect(seenRequests).to.have.lengthOf.at.least(3, 'Expected unpinned requests to be intercepted'); + expect(tlsFailures).to.have.lengthOf.at.least(5, 'Expected most pinned requests to fail'); + }); + + }); + + describe("given full unpinned interception", () => { + + beforeEach(async () => { + await launchFrida([ + './test/android/tmp/config.js', // Our custom config + // Otherwise the standard scripts, as in the README: + './native-connect-hook.js', + './native-tls-hook.js', + './android/android-proxy-override.js', + './android/android-system-certificate-injection.js', + './android/android-certificate-unpinning.js', + './android/android-certificate-unpinning-fallback.js', + './android/android-disable-root-detection.js', + ]); + }); + + it("all buttons except 'Raw custom-pinned request' should succeed", async () => { + const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")'); + expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present'); + + buttons.map(button => button.click()); + await Promise.all(await buttons.map(async (button) => { + const buttonText = await button.getText(); + if (!IGNORED_BUTTONS.includes(buttonText.toUpperCase())) { + expect(await waitForContentDescription(button)).to.include('Success'); + } + })); + + expect(seenRequests).to.have.lengthOf(14, 'Expected all requests to be intercepted'); + expect(tlsFailures).to.have.lengthOf(1, 'Expected only raw request to fail'); + }); + }); }); \ No newline at end of file