diff options
| -rw-r--r-- | Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme | 14 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 141 | ||||
| -rw-r--r-- | HutchTests/ReadmeViewTests.swift | 29 |
3 files changed, 151 insertions, 33 deletions
diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme index 909ebd2..2cbf63c 100644 --- a/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme @@ -42,7 +42,8 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - <MacroExpansion> + <BuildableProductRunnable + runnableDebuggingMode = "0"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "8B4B28D02F6704280045FA19" @@ -50,7 +51,7 @@ BlueprintName = "Hutch" ReferencedContainer = "container:Hutch.xcodeproj"> </BuildableReference> - </MacroExpansion> + </BuildableProductRunnable> </LaunchAction> <ProfileAction buildConfiguration = "Release" @@ -58,6 +59,15 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "8B4B28D02F6704280045FA19" + BuildableName = "Hutch.app" + BlueprintName = "Hutch" + ReferencedContainer = "container:Hutch.xcodeproj"> + </BuildableReference> + </MacroExpansion> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index b53885e..576adfc 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St let alt = nsText.substring(with: match.range(at: 1)) let source = nsText.substring(with: match.range(at: 2)) let resolvedSource = imageURLResolver?(source) ?? source - return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"# + guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { + return escapeHTML(alt) + } + return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"# } // Links: [text](url) - result = result.replacingOccurrences( - of: #"\[([^\]]+)\]\(([^)]+)\)"#, - with: #"<a href="$2">$1</a>"#, - options: .regularExpression - ) + result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in + let label = nsText.substring(with: match.range(at: 1)) + let rawURL = nsText.substring(with: match.range(at: 2)) + guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else { + return label + } + return #"<a href="\#(sanitizedURL)">\#(label)</a>"# + } // Bold: **text** result = result.replacingOccurrences( of: #"\*\*(.+?)\*\*"#, @@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St ) { return imageHTML } - return #"<a href="\#(url)">\#(label)</a>"# + guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { + return label + } + return #"<a href="\#(sanitizedURL)">\#(label)</a>"# } result = protectMatches( in: result, @@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St ) { return imageHTML } - return #"<a href="\#(url)">\#(url)</a>"# + guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { + return url + } + return #"<a href="\#(sanitizedURL)">\#(url)</a>"# } result = protectMatches( in: result, @@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String { escapeHTML(text).replacingOccurrences(of: "'", with: "'") } +nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { + sanitizeReadmeURLString( + rawURL, + allowedSchemes: ["http", "https", "mailto"], + allowsFragmentOnly: true + ) +} + +nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { + sanitizeReadmeURLString( + rawURL, + allowedSchemes: ["http", "https"], + allowsFragmentOnly: false + ) +} + +nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { + return false + } + if scheme == "about" || scheme == "data" { + return true + } + guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else { + return false + } + return sanitizedURL == escapeHTMLAttribute(url.absoluteString) +} + +nonisolated private func sanitizeReadmeURLString( + _ rawURL: String, + allowedSchemes: Set<String>, + allowsFragmentOnly: Bool +) -> String? { + let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { return nil } + + if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { + return escapeHTMLAttribute(trimmedURL) + } + + guard let components = URLComponents(string: trimmedURL), + let scheme = components.scheme?.lowercased(), + allowedSchemes.contains(scheme), + let sanitizedURL = components.url?.absoluteString else { + return nil + } + + return escapeHTMLAttribute(sanitizedURL) +} + nonisolated private func isOrgTableLine(_ line: String) -> Bool { line.hasPrefix("|") && line.hasSuffix("|") } @@ -899,6 +962,7 @@ struct HTMLWebView: View { let html: String let colorScheme: ColorScheme var style: HTMLWebViewStyle = .readme + @Environment(\.openURL) private var openURL @State private var contentHeight: CGFloat = 1 @State private var loadError: String? @State private var reloadToken = 0 @@ -921,6 +985,7 @@ struct HTMLWebView: View { html: html, colorScheme: colorScheme, style: style, + openURL: openURL, dynamicHeight: $contentHeight, loadError: $loadError, reloadToken: reloadToken @@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { let html: String let colorScheme: ColorScheme let style: HTMLWebViewStyle + let openURL: OpenURLAction @Binding var dynamicHeight: CGFloat @Binding var loadError: String? let reloadToken: Int @@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() - config.defaultWebpagePreferences.allowsContentJavaScript = true + config.defaultWebpagePreferences.allowsContentJavaScript = false config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore let webView = WKWebView(frame: .zero, configuration: config) webView.isOpaque = false webView.backgroundColor = .clear webView.clipsToBounds = false + webView.allowsLinkPreview = false webView.scrollView.isScrollEnabled = false webView.scrollView.contentInsetAdjustmentBehavior = .never webView.scrollView.clipsToBounds = false @@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc handleLoadFailure(error) } + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void + ) { + guard let requestURL = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + if navigationAction.navigationType == .linkActivated { + if isAllowedReadmeNavigationURL(requestURL) { + parent.openURL(requestURL) + } + decisionHandler(.cancel) + return + } + + if isAllowedReadmeNavigationURL(requestURL) { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + } + } + private func handleLoadFailure(_ error: Error) { let nsError = error as NSError guard nsError.code != NSURLErrorCancelled else { return } @@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc } private func updateHeight(for webView: WKWebView) { - let script = """ - Math.max( - document.body.scrollHeight, - document.body.offsetHeight, - document.documentElement.scrollHeight, - document.documentElement.offsetHeight, - Math.ceil(document.body.getBoundingClientRect().height), - Math.ceil(document.documentElement.getBoundingClientRect().height) - ) - """ - - webView.evaluateJavaScript(script) { [weak self] result, _ in - guard let value = result as? Double, value > 0 else { return } - let height = ceil(value) + 4 - DispatchQueue.main.async { - guard let self else { return } - if let html = self.lastHTML { - Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) - } - if abs(self.parent.dynamicHeight - height) > 0.5 { - self.parent.dynamicHeight = height - } + webView.layoutIfNeeded() + let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4 + guard height > 0 else { return } + DispatchQueue.main.async { + if let html = self.lastHTML { + Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) + } + if abs(self.parent.dynamicHeight - height) > 0.5 { + self.parent.dynamicHeight = height } } } diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift new file mode 100644 index 0000000..9c3c019 --- /dev/null +++ b/HutchTests/ReadmeViewTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import Hutch + +struct ReadmeViewTests { + + @Test + func sanitizedReadmeLinkURLStringRejectsUnexpectedSchemes() { + #expect(sanitizedReadmeLinkURLString("javascript:alert(1)") == nil) + #expect(sanitizedReadmeLinkURLString("file:///tmp/readme") == nil) + #expect(sanitizedReadmeLinkURLString("data:text/html;base64,SGVsbG8=") == nil) + } + + @Test + func processInlineDropsUnsafeMarkdownLinks() { + let rendered = processInline("[click me](javascript:alert)") + + #expect(rendered == "click me") + #expect(!rendered.contains("href=")) + #expect(!rendered.contains("javascript:")) + } + + @Test + func sanitizedReadmeLinkURLStringAllowsExpectedDestinations() { + #expect(sanitizedReadmeLinkURLString("https://example.com/docs?q=1") == "https://example.com/docs?q=1") + #expect(sanitizedReadmeLinkURLString("mailto:[email protected]") == "mailto:[email protected]") + #expect(sanitizedReadmeLinkURLString("#readme") == "#readme") + } +} |
