-
Notifications
You must be signed in to change notification settings - Fork 1
Fix shell injection risk via malicious QR code URL passed to adb #4
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
Changes from all commits
9033d79
06bf07c
f7bed58
4e1eb9e
7745ce8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,13 +107,33 @@ 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 { | ||
| printError("ADB not found. Please install Android SDK or ensure ADB is in your PATH.") | ||
| return false | ||
| } | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The old code passed arguments as separate array elements to
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Worth a brief inline comment noting that when |
||
| 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 <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) | ||
|
|
||
| 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"] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: |
||
|
|
||
| /// 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` | ||
jackfreem marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /// | ||
| /// - 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) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: When
sanitizeUrlForAndroidShellreturnsnil, this method re-parses the URL withURL(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 barenil? That would avoid duplicated parsing and keep error messages in sync with rejection reasons.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call - switched.