Skip to content
Merged
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
27 changes: 26 additions & 1 deletion Sources/qrgo/Helpers/AndroidEmulatorHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,33 @@ class AndroidEmulatorHelper {
return props
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When sanitizeUrlForAndroidShell returns nil, this method re-parses the URL with URL(string:) to determine which error to show. Would it be cleaner for the sanitizer to return an error enum (e.g., .malformed, .badScheme(String), .dangerousCharacters) instead of a bare nil? That would avoid duplicated parsing and keep error messages in sync with rejection reasons.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call - switched.


private static func validateAndSanitizeUrl(_ urlString: String) -> String? {
switch sanitizeUrlForAndroidShell(urlString) {
case .success(let safe):
return safe
case .failure(.malformed):
printError("Malformed or unsupported URL, cannot open on Android device.")
return nil
case .failure(.disallowedScheme(let scheme)):
printError("URL scheme '\(scheme.isEmpty ? "(none)" : scheme)' is not allowed. Only http, https, and cashme are permitted.")
return nil
case .failure(.dangerousCharacters):
printError("URL contains characters that are not permitted for Android shell.")
return nil
}
}

@discardableResult
static func openUrl(_ urlString: String, deviceId: String? = nil, validated: Bool = false) -> Bool {
guard let adbPath = findAdbPath() else {
printError("ADB not found. Please install Android SDK or ensure ADB is in your PATH.")
return false
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The old code passed arguments as separate array elements to adb. The new code concatenates everything into a single shell command string: "am start ... -d '\(safeUrlString)'". While the single-quoting + sanitizer approach is correct, the security now depends entirely on sanitizeUrlForAndroidShell never having a bug. Could this keep the separate-argument style and add shell-escaping just for the URL argument? Or at minimum, could a comment be added here explaining the security invariant — that the sanitizer guarantees no single-quote breakout, and that this string must always be constructed with single quotes around the URL?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Worth a brief inline comment noting that when adb shell receives a single argument, it passes it to sh -c on the device — which is why single-quoting the URL is the correct mitigation. This context would help future maintainers understand the security model.

guard let safeUrlString = validateAndSanitizeUrl(urlString) else {
return false
}

let targetDevice: String
if let deviceId = deviceId, validated {
targetDevice = deviceId
Expand All @@ -131,9 +151,14 @@ class AndroidEmulatorHelper {
}
}

// `adb shell <string>` passes the string to `/bin/sh -c` on the Android device.
// The URL is wrapped in single quotes so the shell treats &, ;, |, etc. as literal
// URL data rather than shell operators. The sanitizer guarantees the URL contains no
// single quotes, making breakout from the single-quoted string impossible.
let shellCommand = "am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d '\(safeUrlString)'"
let result = Shell.runCommand(
adbPath,
arguments: ["-s", targetDevice, "shell", "am", "start", "-a", "android.intent.action.VIEW", "-c", "android.intent.category.BROWSABLE", "-d", urlString],
arguments: ["-s", targetDevice, "shell", shellCommand],
mergeStderr: true
)
let deviceName = getDeviceFriendlyName(targetDevice)
Expand Down
51 changes: 51 additions & 0 deletions Sources/qrgo/Helpers/URLSanitizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

/// The canonical allowlist of URL schemes permitted for opening on Android devices.
/// This is the single source of truth used by both the sanitizer and error reporting.
let allowedUrlSchemes: Set<String> = ["http", "https", "cashme"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: allowedUrlSchemes is a module-level let with internal access. Since it's shared between URLSanitizer.swift and AndroidEmulatorHelper.swift this works, but is it intentional that any file in the module can read this set? If this should be the canonical allowlist, consider documenting that intent or noting it's the single source of truth for scheme validation.


/// Reasons a URL can be rejected by `sanitizeUrlForAndroidShell`.
enum URLSanitizationError: Error {
case malformed
case disallowedScheme(String)
case dangerousCharacters
}

/// Validates and sanitizes a URL string for safe use inside a single-quoted POSIX shell argument.
///
/// This function does NOT reject shell metacharacters like `&`, `;`, or `|` — those are safe
/// when the URL is wrapped in single quotes by the caller (e.g. `"am start -d '\(safe)'"` passed
/// as a single command string to `adb shell`). It only rejects characters that can break out of
/// single-quoted strings themselves.
///
/// This function:
/// 1. Rejects malformed URLs
/// 2. Enforces an allowlist of URL schemes
/// 3. Re-serializes via `URL` (which normalizes percent-encoding)
/// 4. Rejects characters that can break out of a single-quoted POSIX string: `'`, `\0`, `\r`, `\n`
///
/// - Parameters:
/// - urlString: The raw URL string to validate.
/// - allowedSchemes: Set of lowercase scheme strings to permit. Defaults to `allowedUrlSchemes`.
/// - Returns: The normalized URL string on success, or a `URLSanitizationError` describing the failure.
func sanitizeUrlForAndroidShell(
_ urlString: String,
allowedSchemes: Set<String> = allowedUrlSchemes
) -> Result<String, URLSanitizationError> {
guard let url = URL(string: urlString) else { return .failure(.malformed) }

let scheme = url.scheme?.lowercased() ?? ""
guard allowedSchemes.contains(scheme) else { return .failure(.disallowedScheme(scheme)) }

// Re-serialize to normalize percent-encoding
let sanitized = url.absoluteString

// Reject characters that can break out of a single-quoted POSIX shell string.
// The caller is responsible for wrapping the result in single quotes.
let dangerous = CharacterSet(charactersIn: "'\r\n\0")
guard !sanitized.unicodeScalars.contains(where: { dangerous.contains($0) }) else {
return .failure(.dangerousCharacters)
}

return .success(sanitized)
}
Loading