diff --git a/StikJIT/MiniToolSupport/JSSupport/IDeviceJSBridge.m b/StikJIT/MiniToolSupport/JSSupport/IDeviceJSBridge.m index 14b527b..63bdc2e 100644 --- a/StikJIT/MiniToolSupport/JSSupport/IDeviceJSBridge.m +++ b/StikJIT/MiniToolSupport/JSSupport/IDeviceJSBridge.m @@ -12,21 +12,34 @@ @end -NSDictionary *dictionaryFromPlistData(NSData *plistData, NSError **error) { - if (!plistData) { +NSDictionary *dictionaryFromPlist(plist_t plist, NSError **error) { + if (!plist) { if (error) { *error = [NSError errorWithDomain:@"PlistConversionErrorDomain" code:1001 - userInfo:@{NSLocalizedDescriptionKey: @"Input plist data is nil."}]; + userInfo:@{NSLocalizedDescriptionKey: @"Input plist is nil."}]; } return nil; } + char* buf = 0; + uint32_t plistlen = 0; + // try bin plist first, if failed, we try xml plist + plist_to_bin(plist, &buf, &plistlen); NSPropertyListFormat format; - NSDictionary *result = [NSPropertyListSerialization propertyListWithData:plistData - options:NSPropertyListImmutable - format:&format - error:error]; + NSDictionary *result = [NSPropertyListSerialization propertyListWithData:[NSData dataWithBytes:buf length:plistlen] + options:NSPropertyListImmutable format:&format error:error]; + plist_mem_free(buf); + if (![result isKindOfClass:[NSDictionary class]]) { + plist_to_xml(plist, &buf, &plistlen); + if (error) { + *error = nil; + } + result = [NSPropertyListSerialization propertyListWithData:[NSData dataWithBytes:buf length:plistlen] + options:NSPropertyListImmutable format:&format error:error]; + plist_free(buf); + } + if (![result isKindOfClass:[NSDictionary class]]) { if (error && !*error) { *error = [NSError errorWithDomain:@"PlistConversionErrorDomain" @@ -78,11 +91,12 @@ const char** cstrArrFromNSArray(NSArray* arr, int* validCount) { int maxDataId; } -- (instancetype)initWithContext:(JSContext*)context { +- (instancetype)initWithContext:(JSContext*)context allowedFunctions:(NSArray*)allowedFunctions { maxHandleId = 0; maxDataId = 0; handles = [[NSMutableDictionary alloc] init]; dataPool = [[NSMutableDictionary alloc] init]; + self->allowedFunctions = allowedFunctions; self->context = context; return self; } @@ -150,6 +164,11 @@ const char** cstrArrFromNSArray(NSArray* arr, int* validCount) { return; } + if(![self->allowedFunctions containsObject:message[@"command"]]) { + [rejectFunc callWithArguments:@[[NSString stringWithFormat:@"calling %@ is not allowed! Declare it in toolInfo.json", message[@"command"]]]]; + return; + } + NSString* handlerSelectorStr = [NSString stringWithFormat:@"%@WithBody:replyHandler:", message[@"command"]]; if(![self respondsToSelector:NSSelectorFromString(handlerSelectorStr)]) { [rejectFunc callWithArguments:@[@"Invalid idevice function!"]]; diff --git a/StikJIT/MiniToolSupport/JSSupport/IDeviceJSBridgeInstallationProxy.m b/StikJIT/MiniToolSupport/JSSupport/IDeviceJSBridgeInstallationProxy.m index 59706ca..389047a 100644 --- a/StikJIT/MiniToolSupport/JSSupport/IDeviceJSBridgeInstallationProxy.m +++ b/StikJIT/MiniToolSupport/JSSupport/IDeviceJSBridgeInstallationProxy.m @@ -69,23 +69,24 @@ void installationProxyCallback(uint64_t progress, struct InstallationProxyCallba } plist_t *app_list = (plist_t *)apps; - NSMutableArray* ans = [[NSMutableArray alloc] init]; - for(int i = 0; i < apps_len; ++i) { - char* buf = 0; - uint32_t plistlen = 0; - plist_to_bin(app_list[i], &buf, &plistlen); - NSError* err2 = 0; - NSDictionary* appDict = dictionaryFromPlistData([NSData dataWithBytes:buf length:plistlen], &err2); - plist_mem_free(buf); - if(err2) { - replyHandler(nil, @"failed to parse plist data"); - return; + do { + NSMutableArray* ans = [[NSMutableArray alloc] init]; + for(int i = 0; i < apps_len; ++i) { + NSError* err2 = 0; + NSDictionary* appDict = dictionaryFromPlist(app_list[i], &err2); + + if(err2) { + replyHandler(nil, @"failed to parse plist data"); + break; + } + [ans addObject:appDict]; } - [ans addObject:appDict]; + replyHandler(ans, nil); + } while(0); + + for(int i = 0; i < apps_len; ++i) { + plist_free(app_list[i]); } - replyHandler(ans, nil); - - } - (void)installation_proxy_installWithBody:(NSDictionary *)body replyHandler:(nonnull void (^)(id _Nullable, NSString * _Nullable))replyHandler { @@ -266,33 +267,33 @@ void installationProxyCallback(uint64_t progress, struct InstallationProxyCallba plist_from_memory((void*)[optionsNSData bytes], (uint32_t)[optionsNSData length], &optionsPlist, 0); } - plist_t *apps = NULL; + plist_t *app_list = NULL; size_t apps_len = 0; - IdeviceFfiError* err = installation_proxy_browse(client, optionsPlist, &apps, &apps_len); + IdeviceFfiError* err = installation_proxy_browse(client, optionsPlist, &app_list, &apps_len); if (err) { replyHandler(nil, [self errFreeFromIdeviceFfiError:err]); return; } - NSMutableArray* ans = [[NSMutableArray alloc] init]; + do { + NSMutableArray* ans = [[NSMutableArray alloc] init]; + for(int i = 0; i < apps_len; ++i) { + NSError* err2 = 0; + NSDictionary* appDict = dictionaryFromPlist(app_list[i], &err2); + + if(err2) { + replyHandler(nil, @"failed to parse plist data"); + break; + } + [ans addObject:appDict]; + } + replyHandler(ans, nil); + } while(0); + for(int i = 0; i < apps_len; ++i) { - char* buf = 0; - uint32_t plistlen = 0; - plist_to_bin(apps[i], &buf, &plistlen); - NSError* err2 = 0; - NSDictionary* appDict = dictionaryFromPlistData([NSData dataWithBytes:buf length:plistlen], &err2); - plist_mem_free(buf); - if(err2) { - replyHandler(nil, @"failed to parse plist data"); - return; - } - [ans addObject:appDict]; - if([ans count] >= 100) { - break; - } + plist_free(app_list[i]); } - replyHandler(ans, nil); } diff --git a/StikJIT/MiniToolSupport/JSSupport/JSSupport.h b/StikJIT/MiniToolSupport/JSSupport/JSSupport.h index 56b0223..f538fdd 100644 --- a/StikJIT/MiniToolSupport/JSSupport/JSSupport.h +++ b/StikJIT/MiniToolSupport/JSSupport/JSSupport.h @@ -23,9 +23,10 @@ @interface IDeviceJSBridge : NSObject { NSMutableDictionary* handles; NSMutableDictionary* dataPool; + NSArray* allowedFunctions; JSContext* context; } -- (instancetype)initWithContext:(JSContext*)context; +- (instancetype)initWithContext:(JSContext*)context allowedFunctions:(NSArray*)allowedFunctions; - (void)didReceiveScriptMessage:(NSDictionary *)message resolve:(JSValue*)resolveFunc reject:(JSValue*)rejectFunc; - (void)cleanUp; - (NSString*)errFreeFromIdeviceFfiError:(IdeviceFfiError*)err; @@ -36,6 +37,6 @@ @end -NSDictionary *dictionaryFromPlistData(NSData *plistData, NSError **error); +NSDictionary *dictionaryFromPlist(plist_t plist, NSError **error); NSData *plistDataFromDictionary(NSDictionary *dictionary, NSError **error); const char** cstrArrFromNSArray(NSArray* arr, int* validCount); diff --git a/StikJIT/MiniToolSupport/MiniToolListView.swift b/StikJIT/MiniToolSupport/MiniToolListView.swift index 2235cf6..80d9746 100644 --- a/StikJIT/MiniToolSupport/MiniToolListView.swift +++ b/StikJIT/MiniToolSupport/MiniToolListView.swift @@ -2,6 +2,28 @@ import SwiftUI import UniformTypeIdentifiers import UIKit +class ToolCombo : Hashable, ObservableObject { + static func == (lhs: ToolCombo, rhs: ToolCombo) -> Bool { + return lhs === rhs + } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + init(tool: MiniToolBundle, info: ToolInfo) { + self.tool = tool + self.info = info + } + + init() { + self.tool = nil + self.info = nil + } + + @Published var tool: MiniToolBundle? + @Published var info: ToolInfo? +} + struct MiniToolListView: View { @StateObject private var store = MiniToolStore() @State private var searchText = "" @@ -11,6 +33,9 @@ struct MiniToolListView: View { @State private var alertVisible = false @State private var alertTitle = "" @State private var alertMessage = "" + @State private var showInfoSheet = false + @State private var navigationToolCombo: ToolCombo? + @StateObject private var pendingToolCombo: ToolCombo = ToolCombo() @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @Environment(\.themeExpansionManager) private var themeExpansion @Environment(\.colorScheme) private var colorScheme @@ -102,8 +127,14 @@ struct MiniToolListView: View { presentError(title: "Import Failed", message: error.localizedDescription) } } + .navigationDestination(item: $navigationToolCombo) { combo in + MiniToolRunnerView(tool: combo.tool!, toolInfo: combo.info!) + } } .preferredColorScheme(preferredScheme) + .sheet(isPresented: $showInfoSheet) { + toolInfoSheet + } } // MARK: - Cards @@ -130,8 +161,8 @@ struct MiniToolListView: View { private func toolRow(_ tool: MiniToolBundle) -> some View { HStack(spacing: 12) { - NavigationLink { - MiniToolRunnerView(tool: tool) + Button { + handleRun(tool) } label: { HStack(spacing: 12) { Image(systemName: "shippingbox.fill") @@ -146,29 +177,28 @@ struct MiniToolListView: View { .foregroundColor(.secondary) .lineLimit(1) } + Spacer() } - - Spacer() - - NavigationLink { - MiniToolEditorView(tool: tool) - } label: { - Image(systemName: "pencil") - .foregroundColor(.primary) - } - .buttonStyle(.borderless) - - Button(role: .destructive) { - pendingDelete = tool - showDeleteConfirmation = true - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - .buttonStyle(.borderless) } .buttonStyle(.plain) + NavigationLink { + MiniToolEditorView(tool: tool) + } label: { + Image(systemName: "pencil") + .foregroundColor(.primary) + } + .buttonStyle(.borderless) + + Button(role: .destructive) { + pendingDelete = tool + showDeleteConfirmation = true + } label: { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + } .padding(20) .background(glassyBackground) @@ -253,4 +283,158 @@ struct MiniToolListView: View { private func copy(_ text: String) { UIPasteboard.general.string = text } + + private func handleRun(_ tool: MiniToolBundle) { + do { + guard let info = try loadToolInfo(for: tool) else { + presentError(title: "Mini Tool", message: "toolInfo.json is missing or unreadable for this tool.") + return + } + if try isTrusted(tool) { + navigationToolCombo = ToolCombo(tool: tool, info: info) + return + } + + pendingToolCombo.info = info + pendingToolCombo.tool = tool + print("set pendingToolInfo") + showInfoSheet = true + print("pendingToolInfo = true") + } catch { + presentError(title: "Mini Tool", message: error.localizedDescription) + } + } + + private func loadToolInfo(for tool: MiniToolBundle) throws -> ToolInfo? { + let infoURL = tool.url.appendingPathComponent("toolInfo.json") + guard FileManager.default.fileExists(atPath: infoURL.path) else { return nil } + let data = try Data(contentsOf: infoURL) + return try JSONDecoder().decode(ToolInfo.self, from: data) + } + + private func isTrusted(_ tool: MiniToolBundle) throws -> Bool { + guard let uuid = UIDevice.current.identifierForVendor?.uuidString else { return false } + let checkURL = tool.url.appendingPathComponent("check") + guard FileManager.default.fileExists(atPath: checkURL.path) else { return false } + let contents = try String(contentsOf: checkURL, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + return contents == uuid + } + + private func authorizeAndRun() { + guard let tool = pendingToolCombo.tool else { + return + } + + do { + try writeCheck(for: tool) + navigationToolCombo = pendingToolCombo + } catch { + presentError(title: "Mini Tool", message: error.localizedDescription) + } + resetPending() + } + + private func writeCheck(for tool: MiniToolBundle) throws { + guard let uuid = UIDevice.current.identifierForVendor?.uuidString else { + throw NSError(domain: "MiniTool", code: -1, userInfo: [NSLocalizedDescriptionKey: "identifierForVendor is unavailable."]) + } + let checkURL = tool.url.appendingPathComponent("check") + try uuid.write(to: checkURL, atomically: true, encoding: .utf8) + } + + private func resetPending() { + showInfoSheet = false +// pendingTool = MiniToolBundle() + } + + @ViewBuilder + private var toolInfoSheet: some View { + if let tool = pendingToolCombo.tool, let info = pendingToolCombo.info { + ToolInfoSheet(tool: tool, info: info) { + authorizeAndRun() + } onCancel: { + resetPending() + } + } + } } + +private struct ToolInfoSheet: View { + let tool: MiniToolBundle + let info: ToolInfo + let onAllow: () -> Void + let onCancel: () -> Void + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(tool.name) + .font(.title2.weight(.semibold)) + Text(info.desc) + .font(.body) + .foregroundColor(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Label("Author", systemImage: "person") + .font(.subheadline.weight(.semibold)) + Text(info.author) + .font(.body) + } + + if let capabilities = info.capabilities { + VStack(alignment: .leading, spacing: 8) { + Label("Capabilities", systemImage: "checkmark.shield") + .font(.subheadline.weight(.semibold)) + CapabilityRow(title: "Internet Access", enabled: capabilities.internetAccess ?? false) + } + } + + if let functions = info.requiredIDeviceFunctions, !functions.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Label("Required iDevice Functions", systemImage: "bolt.horizontal.circle") + .font(.subheadline.weight(.semibold)) + ForEach(functions, id: \.self) { function in + HStack(spacing: 8) { + Image(systemName: "bolt.fill") + .foregroundColor(.blue) + Text(function) + .font(.body) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + .navigationTitle("Allow Tool?") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { onCancel() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Allow & Run") { onAllow() } + .bold() + } + } + } + } +} + +private struct CapabilityRow: View { + let title: String + let enabled: Bool + + var body: some View { + HStack(spacing: 8) { + Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") + .foregroundColor(enabled ? .green : .red) + Text(title) + .font(.body) + } + } +} + diff --git a/StikJIT/MiniToolSupport/MiniToolModels.swift b/StikJIT/MiniToolSupport/MiniToolModels.swift index f025e08..59b03a5 100644 --- a/StikJIT/MiniToolSupport/MiniToolModels.swift +++ b/StikJIT/MiniToolSupport/MiniToolModels.swift @@ -1,8 +1,41 @@ import Foundation -struct MiniToolBundle: Identifiable, Hashable { - let url: URL +struct ToolInfo: Decodable { + struct Capabilities: Decodable { + let internetAccess: Bool? + enum CodingKeys: String, CodingKey { + case internetAccess = "internet_access" + } + } + + let author: String + let desc: String + let capabilities: Capabilities? + let requiredIDeviceFunctions: [String]? + + + + enum CodingKeys: String, CodingKey { + case author + case desc + case capabilities + case requiredIDeviceFunctions = "required_idevice_functions" + } +} + + +class MiniToolBundle: ObservableObject, Identifiable { + static func == (lhs: MiniToolBundle, rhs: MiniToolBundle) -> Bool { + return lhs.name == rhs.name + } + + @Published var url: URL + + init(url: URL) { + self.url = url + } + var id: String { url.lastPathComponent } var name: String { url.deletingPathExtension().lastPathComponent } var indexURL: URL { url.appendingPathComponent("index.html") } diff --git a/StikJIT/MiniToolSupport/MiniToolRunnerView.swift b/StikJIT/MiniToolSupport/MiniToolRunnerView.swift index b035368..674f851 100644 --- a/StikJIT/MiniToolSupport/MiniToolRunnerView.swift +++ b/StikJIT/MiniToolSupport/MiniToolRunnerView.swift @@ -3,6 +3,7 @@ import WebKit struct MiniToolRunnerView: View { let tool: MiniToolBundle + let toolInfo: ToolInfo @StateObject private var runtime: MiniToolRuntime @State private var showLogs = false @State private var initiated = false @@ -13,9 +14,10 @@ struct MiniToolRunnerView: View { private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } - init(tool: MiniToolBundle) { + init(tool: MiniToolBundle, toolInfo: ToolInfo) { self.tool = tool - _runtime = StateObject(wrappedValue: MiniToolRuntime(tool: tool)) + self.toolInfo = toolInfo + _runtime = StateObject(wrappedValue: MiniToolRuntime(tool: tool, toolInfo: toolInfo)) } var body: some View { diff --git a/StikJIT/MiniToolSupport/MiniToolRuntime.swift b/StikJIT/MiniToolSupport/MiniToolRuntime.swift index 5098795..9435c7e 100644 --- a/StikJIT/MiniToolSupport/MiniToolRuntime.swift +++ b/StikJIT/MiniToolSupport/MiniToolRuntime.swift @@ -6,6 +6,7 @@ import JavaScriptCore final class MiniToolRuntime: NSObject, ObservableObject { let tool: MiniToolBundle + let toolInfo: ToolInfo @Published var logs: [String] = [] @Published var isReady: Bool = false @@ -18,8 +19,9 @@ final class MiniToolRuntime: NSObject, ObservableObject { private var ideviceJSBridge : IDeviceJSBridge? = nil - init(tool: MiniToolBundle) { + init(tool: MiniToolBundle, toolInfo: ToolInfo) { self.tool = tool + self.toolInfo = toolInfo super.init() let configuration = WKWebViewConfiguration() let controller = WKUserContentController() @@ -64,7 +66,7 @@ final class MiniToolRuntime: NSObject, ObservableObject { private func loadBackground() { context = JSContext() - ideviceJSBridge = IDeviceJSBridge(context: context) + ideviceJSBridge = IDeviceJSBridge(context: context, allowedFunctions: toolInfo.requiredIDeviceFunctions ?? []) context?.exceptionHandler = { [weak self] _, exception in if let message = exception?.toString() { self?.appendLog("Background exception: \(message)") @@ -155,8 +157,18 @@ extension MiniToolRuntime : WKURLSchemeHandler { let path = url.path.isEmpty ? "index.html" : url.path let fileURL = tool.url - .appendingPathComponent(path) - + .appendingPathComponent(path).standardizedFileURL + + if fileURL.path().hasPrefix(tool.url.path()) { + urlSchemeTask.didFailWithError(NSError(domain: "Path traversal is not allowed.", code: -1)) + return + } + + if fileURL.path() == tool.url.appending(path: "check").path() { + urlSchemeTask.didFailWithError(NSError(domain: "Reading this file is not allowed.", code: -1)) + return + } + do { let data = try Data(contentsOf: fileURL) let mimeType : String @@ -206,6 +218,20 @@ extension MiniToolRuntime: WKScriptMessageHandler { // MARK: - WKNavigationDelegate extension MiniToolRuntime: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + // Don't allow loading other contents + guard let url = navigationAction.request.url else { + return WKNavigationActionPolicy.cancel + } + guard let scheme = url.scheme, scheme == "app" else { + return WKNavigationActionPolicy.cancel + } + guard let host = url.host(), host == tool.getHostName() else { + return WKNavigationActionPolicy.cancel + } + return WKNavigationActionPolicy.allow + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { isReady = true deliverToBackground(["type": "ui-ready", "tool": tool.name]) @@ -462,6 +488,11 @@ extension MiniToolRuntime { private extension MiniToolRuntime { func handleAppXHRMessage(_ payload: [String: Any]) { + guard let capabilities = toolInfo.capabilities, capabilities.internetAccess ?? false else { + appendLog("Internet access is disabled! To enable it, enable internetAccess in toolInfo.json") + return + } + guard let id = payload["id"] as? String else { appendLog("AppXHR missing id") return