Skip to content

Commit

Permalink
Implement basic leak tests
Browse files Browse the repository at this point in the history
  • Loading branch information
niklasberglund committed Dec 19, 2024
1 parent 936ab8f commit 7559def
Show file tree
Hide file tree
Showing 16 changed files with 743 additions and 108 deletions.
5 changes: 4 additions & 1 deletion ios/Configurations/UITests.xcconfig.template
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ AD_SERVING_DOMAIN = vpnlist.to
// A domain which should be reachable. Used to verify Internet connectivity. Must be running a server on port 80.
SHOULD_BE_REACHABLE_DOMAIN = mullvad.net

// Base URL for the firewall API, Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8
// Base URL for the firewall API. Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8
FIREWALL_API_BASE_URL = http:/${}/8.8.8.8

// URL for Mullvad provided JSON data with information about the connection. https://am.i.mullvad.net/json for production, https://am.i.stagemole.eu/json for staging.
AM_I_JSON_URL = https:/${}/am.i.stagemole.eu/json

// Specify whether app logs should be extracted and attached to test report for failing tests
ATTACH_APP_LOGS_ON_FAILURE = 0

// Base URL for the packet capture API. Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8
PACKET_CAPTURE_BASE_URL = http:/${}/8.8.8.8
47 changes: 33 additions & 14 deletions ios/MullvadVPN.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

This file was deleted.

62 changes: 61 additions & 1 deletion ios/MullvadVPNUITests/Base/BaseUITestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ class BaseUITestCase: XCTestCase {
/// Default relay to use in tests
static let testsDefaultRelayName = "se-got-wg-001"

/// True when the current test case is capturing packets
private var currentTestCaseShouldCapturePackets = false

/// True when a packet capture session is active
private var packetCaptureSessionIsActive = false
private var packetCaptureSession: PacketCaptureSession?

// swiftlint:disable force_cast
let displayName = Bundle(for: BaseUITestCase.self)
.infoDictionary?["DisplayName"] as! String
Expand Down Expand Up @@ -136,7 +143,7 @@ class BaseUITestCase: XCTestCase {
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")

if springboard.buttons["Allow"].waitForExistence(timeout: Self.shortTimeout) {
let alertAllowButton = springboard.buttons.element(boundBy: 0)
let alertAllowButton = springboard.buttons["Allow"]
if alertAllowButton.waitForExistence(timeout: Self.defaultTimeout) {
alertAllowButton.tap()
}
Expand All @@ -160,6 +167,29 @@ class BaseUITestCase: XCTestCase {
}
}

/// Start packet capture for this test case
func startPacketCapture() {
currentTestCaseShouldCapturePackets = true
packetCaptureSessionIsActive = true
let packetCaptureClient = PacketCaptureClient()
packetCaptureSession = packetCaptureClient.startCapture()
}

/// Stop the current packet capture and return captured traffic
func stopPacketCapture() -> [Stream] {
packetCaptureSessionIsActive = false
guard let packetCaptureSession else {
XCTFail("Trying to stop capture when there is no active capture")
return []
}

let packetCaptureAPIClient = PacketCaptureClient()
packetCaptureAPIClient.stopCapture(session: packetCaptureSession)
let capturedData = packetCaptureAPIClient.getParsedCaptureObjects(session: packetCaptureSession)

return capturedData
}

// MARK: - Setup & teardown

/// Override this class function to change the uninstall behaviour in suite level teardown
Expand All @@ -176,12 +206,42 @@ class BaseUITestCase: XCTestCase {

/// Test level setup
override func setUp() {
currentTestCaseShouldCapturePackets = false // Reset for each test case run
continueAfterFailure = false
app.launch()
}

/// Test level teardown
override func tearDown() {
if currentTestCaseShouldCapturePackets {
guard let packetCaptureSession = packetCaptureSession else {
XCTFail("Packet capture session unexpectedly not set up")
return
}

let packetCaptureClient = PacketCaptureClient()

// If there's a an active session due to cancelled/failed test run make sure to end it
if packetCaptureSessionIsActive {
packetCaptureSessionIsActive = false
packetCaptureClient.stopCapture(session: packetCaptureSession)
}

let pcap = packetCaptureClient.getPCAP(session: packetCaptureSession)
let parsedCapture = packetCaptureClient.getParsedCapture(session: packetCaptureSession)
self.packetCaptureSession = nil

let pcapAttachment = XCTAttachment(data: pcap)
pcapAttachment.name = self.name + ".pcap"
pcapAttachment.lifetime = .keepAlways
self.add(pcapAttachment)

let jsonAttachment = XCTAttachment(data: parsedCapture)
jsonAttachment.name = self.name + ".json"
jsonAttachment.lifetime = .keepAlways
self.add(jsonAttachment)
}

app.terminate()

if let testRun = self.testRun, testRun.failureCount > 0, attachAppLogsOnFailure == true {
Expand Down
2 changes: 1 addition & 1 deletion ios/MullvadVPNUITests/ConnectivityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Network
import XCTest

class ConnectivityTests: LoggedOutUITestCase {
let firewallAPIClient = FirewallAPIClient()
let firewallAPIClient = FirewallClient()

/// Verifies that the app still functions when API has been blocked
func testAPIConnectionViaBridges() throws {
Expand Down
4 changes: 4 additions & 0 deletions ios/MullvadVPNUITests/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@
<string>$(IOS_DEVICE_PIN_CODE)</string>
<key>NoTimeAccountNumber</key>
<string>$(NO_TIME_ACCOUNT_NUMBER)</string>
<key>PacketCaptureAPIBaseURL</key>
<string>$(PACKET_CAPTURE_BASE_URL)</string>
<key>PartnerApiToken</key>
<string>$(PARTNER_API_TOKEN)</string>
<key>ShouldBeReachableDomain</key>
<string>$(SHOULD_BE_REACHABLE_DOMAIN)</string>
<key>ShouldBeReachableIPAddress</key>
<string>$(SHOULD_BE_REACHABLE_IP_ADDRESS)</string>
<key>TestDeviceIdentifier</key>
<string>$(TEST_DEVICE_IDENTIFIER_UUID)</string>
<key>TestDeviceIsIPad</key>
Expand Down
88 changes: 88 additions & 0 deletions ios/MullvadVPNUITests/LeakTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// LeakTests.swift
// MullvadVPNUITests
//
// Created by Niklas Berglund on 2024-05-31.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import XCTest

class LeakTests: LoggedInWithTimeUITestCase {
override func tearDown() {
FirewallClient().removeRules()
super.tearDown()
}

/// Send UDP traffic to a host, connect to relay and make sure while connected to relay no traffic leaked went directly to the host
func testNoLeak() throws {
let targetIPAddress = Networking.getAlwaysReachableIPAddress()
startPacketCapture()
let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80)
trafficGenerator.startGeneratingUDPTraffic(interval: 30.0)

TunnelControlPage(app)
.tapSecureConnectionButton()

allowAddVPNConfigurationsIfAsked()

TunnelControlPage(app)
.waitForSecureConnectionLabel()

// Keep the tunnel connection for a while
Thread.sleep(forTimeInterval: 30.0)

TunnelControlPage(app)
.tapDisconnectButton()

trafficGenerator.stopGeneratingUDPTraffic()

var capturedStreams = stopPacketCapture()
// For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up
capturedStreams = PacketCaptureClient.trimPackets(streams: capturedStreams, secondsStart: 8, secondsEnd: 3)
LeakCheck.assertNoLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)])
}

/// Send UDP traffic to a host, connect to relay and then disconnect to intentionally leak traffic and make sure that the test catches the leak
func testShouldLeak() throws {
let targetIPAddress = Networking.getAlwaysReachableIPAddress()
startPacketCapture()
let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80)
trafficGenerator.startGeneratingUDPTraffic(interval: 1.0)

TunnelControlPage(app)
.tapSecureConnectionButton()

allowAddVPNConfigurationsIfAsked()

TunnelControlPage(app)
.waitForSecureConnectionLabel()

Thread.sleep(forTimeInterval: 2.0)

TunnelControlPage(app)
.tapDisconnectButton()

// Give it some time to generate traffic outside of tunnel
Thread.sleep(forTimeInterval: 5.0)

TunnelControlPage(app)
.tapSecureConnectionButton()

// Keep the tunnel connection for a while
Thread.sleep(forTimeInterval: 5.0)

app.launch()
TunnelControlPage(app)
.tapDisconnectButton()

// Keep the capture open for a while
Thread.sleep(forTimeInterval: 15.0)
trafficGenerator.stopGeneratingUDPTraffic()

var capturedStreams = stopPacketCapture()
// For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up
capturedStreams = PacketCaptureClient.trimPackets(streams: capturedStreams, secondsStart: 8, secondsEnd: 3)
LeakCheck.assertLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,16 @@ import SystemConfiguration
import UIKit
import XCTest

class FirewallAPIClient {
class FirewallClient: TestRouterAPIClient {
// swiftlint:disable force_cast
let baseURL = URL(
string:
Bundle(for: FirewallAPIClient.self).infoDictionary?["FirewallApiBaseURL"] as! String
)!
let testDeviceIdentifier = Bundle(for: FirewallAPIClient.self).infoDictionary?["TestDeviceIdentifier"] as! String
let testDeviceIdentifier = Bundle(for: FirewallClient.self).infoDictionary?["TestDeviceIdentifier"] as! String
// swiftlint:enable force_cast

lazy var sessionIdentifier = "urn:uuid:" + testDeviceIdentifier

/// Create a new rule associated to the device under test
public func createRule(_ firewallRule: FirewallRule) {
let createRuleURL = baseURL.appendingPathComponent("rule")
let createRuleURL = TestRouterAPIClient.baseURL.appendingPathComponent("rule")

var request = URLRequest(url: createRuleURL)
request.httpMethod = "POST"
Expand Down Expand Up @@ -64,7 +60,9 @@ class FirewallAPIClient {
} else {
if let response = requestResponse as? HTTPURLResponse {
if response.statusCode != 201 {
XCTFail("Failed to create firewall rule - unexpected server response")
XCTFail(
"Failed to create firewall rule - unexpected response status code \(response.statusCode)"
)
}
}

Expand All @@ -77,43 +75,9 @@ class FirewallAPIClient {
}
}

/// Gets the IP address of the device under test
public func getDeviceIPAddress() throws -> String {
let deviceIPURL = baseURL.appendingPathComponent("own-ip")
let request = URLRequest(url: deviceIPURL)
let completionHandlerInvokedExpectation = XCTestExpectation(
description: "Completion handler for the request is invoked"
)
var deviceIPAddress = ""
var requestError: Error?

let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in
defer { completionHandlerInvokedExpectation.fulfill() }
guard let data else {
requestError = NetworkingError.internalError(reason: "Could not get device IP")
return
}

deviceIPAddress = String(data: data, encoding: .utf8)!
}

dataTask.resume()

let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30)
if waitResult != .completed {
XCTFail("Failed to get device IP address - timeout")
}

if let requestError {
throw requestError
}

return deviceIPAddress
}

/// Remove all firewall rules associated to this device under test
public func removeRules() {
let removeRulesURL = baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)")
let removeRulesURL = TestRouterAPIClient.baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)")

var request = URLRequest(url: removeRulesURL)
request.httpMethod = "DELETE"
Expand Down
16 changes: 5 additions & 11 deletions ios/MullvadVPNUITests/Networking/FirewallRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,16 @@
import Foundation
import XCTest

enum NetworkingProtocol: String {
case TCP = "tcp"
case UDP = "udp"
case ICMP = "icmp"
}

struct FirewallRule {
let fromIPAddress: String
let toIPAddress: String
let protocols: [NetworkingProtocol]
let protocols: [NetworkTransportProtocol]

/// - Parameters:
/// - fromIPAddress: Block traffic originating from this source IP address.
/// - toIPAddress: Block traffic to this destination IP address.
/// - protocols: Protocols which should be blocked. If none is specified all will be blocked.
private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkingProtocol]) {
private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkTransportProtocol]) {
self.fromIPAddress = fromIPAddress
self.toIPAddress = toIPAddress
self.protocols = protocols
Expand All @@ -36,7 +30,7 @@ struct FirewallRule {

/// Make a firewall rule blocking API access for the current device under test
public static func makeBlockAPIAccessFirewallRule() throws -> FirewallRule {
let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
let deviceIPAddress = try FirewallClient().getDeviceIPAddress()
let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress()
return FirewallRule(
fromIPAddress: deviceIPAddress,
Expand All @@ -46,7 +40,7 @@ struct FirewallRule {
}

public static func makeBlockAllTrafficRule(toIPAddress: String) throws -> FirewallRule {
let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
let deviceIPAddress = try FirewallClient().getDeviceIPAddress()

return FirewallRule(
fromIPAddress: deviceIPAddress,
Expand All @@ -56,7 +50,7 @@ struct FirewallRule {
}

public static func makeBlockUDPTrafficRule(toIPAddress: String) throws -> FirewallRule {
let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
let deviceIPAddress = try FirewallClient().getDeviceIPAddress()

return FirewallRule(
fromIPAddress: deviceIPAddress,
Expand Down
Loading

0 comments on commit 7559def

Please sign in to comment.