diff --git a/Sources/qrgo/Helpers/AndroidEmulatorHelper.swift b/Sources/qrgo/Helpers/AndroidEmulatorHelper.swift index f93df4d..c99bce7 100644 --- a/Sources/qrgo/Helpers/AndroidEmulatorHelper.swift +++ b/Sources/qrgo/Helpers/AndroidEmulatorHelper.swift @@ -107,6 +107,22 @@ class AndroidEmulatorHelper { return props } + 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 { @@ -114,6 +130,10 @@ class AndroidEmulatorHelper { return false } + guard let safeUrlString = validateAndSanitizeUrl(urlString) else { + return false + } + let targetDevice: String if let deviceId = deviceId, validated { targetDevice = deviceId @@ -131,9 +151,14 @@ class AndroidEmulatorHelper { } } + // `adb shell ` 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) diff --git a/Sources/qrgo/Helpers/URLSanitizer.swift b/Sources/qrgo/Helpers/URLSanitizer.swift new file mode 100644 index 0000000..31547eb --- /dev/null +++ b/Sources/qrgo/Helpers/URLSanitizer.swift @@ -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 = ["http", "https", "cashme"] + +/// 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 = allowedUrlSchemes +) -> Result { + 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) +}