From bc05143e4e3c6e5b7ef36e28d01dcd54f9d24804 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sat, 14 Feb 2026 14:45:04 +0000 Subject: [PATCH] Onboarding/Media: harden QR scanner and data URL parsing --- .../Sources/Onboarding/QRScannerView.swift | 41 +++++++++++-- apps/ios/Tests/DeepLinkParserTests.swift | 30 ++++++++++ src/web/media.test.ts | 28 +++++++++ src/web/media.ts | 58 ++++++++++++++++--- 4 files changed, 143 insertions(+), 14 deletions(-) diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift index 30d2da9f47e..d326c09c42b 100644 --- a/apps/ios/Sources/Onboarding/QRScannerView.swift +++ b/apps/ios/Sources/Onboarding/QRScannerView.swift @@ -7,16 +7,35 @@ struct QRScannerView: UIViewControllerRepresentable { let onError: (String) -> Void let onDismiss: () -> Void - func makeUIViewController(context: Context) -> DataScannerViewController { + func makeUIViewController(context: Context) -> UIViewController { + guard DataScannerViewController.isSupported else { + context.coordinator.reportError("QR scanning is not supported on this device.") + return UIViewController() + } + guard DataScannerViewController.isAvailable else { + context.coordinator.reportError("Camera scanning is currently unavailable.") + return UIViewController() + } let scanner = DataScannerViewController( recognizedDataTypes: [.barcode(symbologies: [.qr])], isHighlightingEnabled: true) scanner.delegate = context.coordinator - try? scanner.startScanning() + do { + try scanner.startScanning() + } catch { + context.coordinator.reportError("Could not start QR scanner.") + } return scanner } - func updateUIViewController(_: DataScannerViewController, context _: Context) {} + func updateUIViewController(_: UIViewController, context _: Context) {} + + static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) { + if let scanner = uiViewController as? DataScannerViewController { + scanner.stopScanning() + } + coordinator.parent.onDismiss() + } func makeCoordinator() -> Coordinator { Coordinator(parent: self) @@ -25,11 +44,20 @@ struct QRScannerView: UIViewControllerRepresentable { final class Coordinator: NSObject, DataScannerViewControllerDelegate { let parent: QRScannerView private var handled = false + private var reportedError = false init(parent: QRScannerView) { self.parent = parent } + func reportError(_ message: String) { + guard !self.reportedError else { return } + self.reportedError = true + Task { @MainActor in + self.parent.onError(message) + } + } + func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) { guard !self.handled else { return } for item in items { @@ -58,8 +86,11 @@ struct QRScannerView: UIViewControllerRepresentable { func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {} - func dataScanner(_: DataScannerViewController, becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable) { - self.parent.onError("Camera is not available on this device.") + func dataScanner( + _: DataScannerViewController, + becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable) + { + self.reportError("Camera is not available on this device.") } } } diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index 9a3d8618738..23b21c394c4 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -76,4 +76,34 @@ import Testing timeoutSeconds: nil, key: nil))) } + + @Test func parseGatewayLinkParsesCommonFields() { + let url = URL( + string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")! + #expect( + DeepLinkParser.parse(url) == .gateway( + .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def"))) + } + + @Test func parseGatewaySetupCodeParsesBase64UrlPayload() { + let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "gateway.example.com", + port: 443, + tls: true, + token: "tok", + password: "pw")) + } + + @Test func parseGatewaySetupCodeRejectsInvalidInput() { + #expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil) + } } diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 298e819d650..4b72e8ec7ca 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -413,4 +413,32 @@ describe("local media root guard", () => { }), ); }); + + it("loads base64 data URLs", async () => { + const pngBuffer = await sharp({ + create: { width: 2, height: 2, channels: 3, background: "#00aaff" }, + }) + .png() + .toBuffer(); + const dataUrl = `data:image/png;base64,${pngBuffer.toString("base64")}`; + + const result = await loadWebMedia(dataUrl, 1024 * 1024); + + expect(result.kind).toBe("image"); + expect(result.contentType).toBe("image/jpeg"); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it("rejects non-base64 data URLs", async () => { + await expect(loadWebMedia("data:image/png,hello", 1024)).rejects.toThrow( + /Only base64 data: URLs are supported/i, + ); + }); + + it("rejects oversized base64 data URLs before decode", async () => { + const body = Buffer.alloc(4096, 1).toString("base64"); + const dataUrl = `data:application/octet-stream;base64,${body}`; + + await expect(loadWebMediaRaw(dataUrl, 128)).rejects.toThrow(/exceeds .*limit/i); + }); }); diff --git a/src/web/media.ts b/src/web/media.ts index 07a26cb61c5..57031963bb1 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -91,6 +91,8 @@ async function assertLocalMediaAllowed( const HEIC_MIME_RE = /^image\/hei[cf]$/i; const HEIC_EXT_RE = /\.(heic|heif)$/i; +const DATA_URL_RE = /^data:([^;,]+)?(;base64)?,(.*)$/i; +const BASE64_BODY_RE = /^[A-Za-z0-9+/]*={0,2}$/; const MB = 1024 * 1024; function formatMb(bytes: number, digits = 2): string { @@ -130,6 +132,38 @@ function toJpegFileName(fileName?: string): string | undefined { return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" }); } +function parseBase64DataUrl(mediaUrl: string): { contentType: string; base64Body: string } | null { + const match = mediaUrl.match(DATA_URL_RE); + if (!match) { + return null; + } + if (!match[2]) { + throw new Error("Only base64 data: URLs are supported"); + } + const contentType = match[1] || "application/octet-stream"; + const base64Body = (match[3] || "").replaceAll(/\s+/g, ""); + if (base64Body.length === 0) { + return { contentType, base64Body }; + } + if (!BASE64_BODY_RE.test(base64Body) || base64Body.length % 4 === 1) { + throw new Error("Invalid base64 data in data: URL"); + } + return { contentType, base64Body }; +} + +function estimateDecodedBase64Bytes(base64Body: string): number { + if (base64Body.length === 0) { + return 0; + } + let padding = 0; + if (base64Body.endsWith("==")) { + padding = 2; + } else if (base64Body.endsWith("=")) { + padding = 1; + } + return Math.floor((base64Body.length * 3) / 4) - padding; +} + type OptimizedImage = { buffer: Buffer; optimizedSize: number; @@ -273,16 +307,22 @@ async function loadWebMediaInternal( }; }; - // Handle data: URLs (base64-encoded inline data) - if (mediaUrl.startsWith("data:")) { - const match = mediaUrl.match(/^data:([^;,]+)?(?:;base64)?,(.*)$/); - if (!match) { - throw new Error("Invalid data: URL format"); - } - const contentType = match[1] || "application/octet-stream"; - const base64Data = match[2]; - const buffer = Buffer.from(base64Data, "base64"); + const parsedDataUrl = parseBase64DataUrl(mediaUrl); + if (parsedDataUrl) { + const { contentType, base64Body } = parsedDataUrl; const kind = mediaKindFromMime(contentType); + const defaultFetchCap = maxBytesForKind("unknown"); + const decodeCap = + maxBytes === undefined + ? defaultFetchCap + : optimizeImages && kind === "image" + ? Math.max(maxBytes, defaultFetchCap) + : maxBytes; + const estimatedBytes = estimateDecodedBase64Bytes(base64Body); + if (estimatedBytes > decodeCap) { + throw new Error(formatCapLimit("Media", decodeCap, estimatedBytes)); + } + const buffer = Buffer.from(base64Body, "base64"); return await clampAndFinalize({ buffer, contentType, kind }); }