Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add negative leak test for iOS #6449

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion ios/Configurations/UITests.xcconfig.template
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ 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

// An IP address which should always be reachable. Must be running a server on port 80.
SHOULD_BE_REACHABLE_IP_ADDRESS = 45.83.223.209

// 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
38 changes: 29 additions & 9 deletions ios/MullvadVPN.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

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: 1.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
Loading