Commit 9656d31eb5

9656d31eb51edfcc83e779832b29fee51d9fd3a8

parent: 854dd9d735

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 00:23 UTC

harden README web rendering and sanitize untrusted links

Layout: unified · split

Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme +12 −2
@@ -42,7 +42,8 @@
42 debugDocumentVersioning = "YES" 42 debugDocumentVersioning = "YES"
43 debugServiceExtension = "internal" 43 debugServiceExtension = "internal"
44 allowLocationSimulation = "YES"> 44 allowLocationSimulation = "YES">
45 <MacroExpansion> 45 <BuildableProductRunnable
46 runnableDebuggingMode = "0">
46 <BuildableReference 47 <BuildableReference
47 BuildableIdentifier = "primary" 48 BuildableIdentifier = "primary"
48 BlueprintIdentifier = "8B4B28D02F6704280045FA19" 49 BlueprintIdentifier = "8B4B28D02F6704280045FA19"
@@ -50,7 +51,7 @@
50 BlueprintName = "Hutch" 51 BlueprintName = "Hutch"
51 ReferencedContainer = "container:Hutch.xcodeproj"> 52 ReferencedContainer = "container:Hutch.xcodeproj">
52 </BuildableReference> 53 </BuildableReference>
53 </MacroExpansion> 54 </BuildableProductRunnable>
54 </LaunchAction> 55 </LaunchAction>
55 <ProfileAction 56 <ProfileAction
56 buildConfiguration = "Release" 57 buildConfiguration = "Release"
@@ -58,6 +59,15 @@
58 savedToolIdentifier = "" 59 savedToolIdentifier = ""
59 useCustomWorkingDirectory = "NO" 60 useCustomWorkingDirectory = "NO"
60 debugDocumentVersioning = "YES"> 61 debugDocumentVersioning = "YES">
62 <MacroExpansion>
63 <BuildableReference
64 BuildableIdentifier = "primary"
65 BlueprintIdentifier = "8B4B28D02F6704280045FA19"
66 BuildableName = "Hutch.app"
67 BlueprintName = "Hutch"
68 ReferencedContainer = "container:Hutch.xcodeproj">
69 </BuildableReference>
70 </MacroExpansion>
61 </ProfileAction> 71 </ProfileAction>
62 <AnalyzeAction 72 <AnalyzeAction
63 buildConfiguration = "Debug"> 73 buildConfiguration = "Debug">
Hutch/Views/Repositories/ReadmeView.swift +110 −31
@@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
387 let alt = nsText.substring(with: match.range(at: 1)) 387 let alt = nsText.substring(with: match.range(at: 1))
388 let source = nsText.substring(with: match.range(at: 2)) 388 let source = nsText.substring(with: match.range(at: 2))
389 let resolvedSource = imageURLResolver?(source) ?? source 389 let resolvedSource = imageURLResolver?(source) ?? source
390 return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"# 390 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
391 return escapeHTML(alt)
392 }
393 return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
391 } 394 }
392 // Links: [text](url) 395 // Links: [text](url)
393 result = result.replacingOccurrences( 396 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
394 of: #"\[([^\]]+)\]\(([^)]+)\)"#, 397 let label = nsText.substring(with: match.range(at: 1))
395 with: #"<a href="$2">$1</a>"#, 398 let rawURL = nsText.substring(with: match.range(at: 2))
396 options: .regularExpression 399 guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
397 ) 400 return label
401 }
402 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
403 }
398 // Bold: **text** 404 // Bold: **text**
399 result = result.replacingOccurrences( 405 result = result.replacingOccurrences(
400 of: #"\*\*(.+?)\*\*"#, 406 of: #"\*\*(.+?)\*\*"#,
@@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
676 ) { 682 ) {
677 return imageHTML 683 return imageHTML
678 } 684 }
679 return #"<a href="\#(url)">\#(label)</a>"# 685 guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
686 return label
687 }
688 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
680 } 689 }
681 result = protectMatches( 690 result = protectMatches(
682 in: result, 691 in: result,
@@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
691 ) { 700 ) {
692 return imageHTML 701 return imageHTML
693 } 702 }
694 return #"<a href="\#(url)">\#(url)</a>"# 703 guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
704 return url
705 }
706 return #"<a href="\#(sanitizedURL)">\#(url)</a>"#
695 } 707 }
696 result = protectMatches( 708 result = protectMatches(
697 in: result, 709 in: result,
@@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
742 escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;") 754 escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
743} 755}
744 756
757nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
758 sanitizeReadmeURLString(
759 rawURL,
760 allowedSchemes: ["http", "https", "mailto"],
761 allowsFragmentOnly: true
762 )
763}
764
765nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
766 sanitizeReadmeURLString(
767 rawURL,
768 allowedSchemes: ["http", "https"],
769 allowsFragmentOnly: false
770 )
771}
772
773nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
774 guard let scheme = url.scheme?.lowercased() else {
775 return false
776 }
777 if scheme == "about" || scheme == "data" {
778 return true
779 }
780 guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
781 return false
782 }
783 return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
784}
785
786nonisolated private func sanitizeReadmeURLString(
787 _ rawURL: String,
788 allowedSchemes: Set<String>,
789 allowsFragmentOnly: Bool
790) -> String? {
791 let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
792 guard !trimmedURL.isEmpty else { return nil }
793
794 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
795 return escapeHTMLAttribute(trimmedURL)
796 }
797
798 guard let components = URLComponents(string: trimmedURL),
799 let scheme = components.scheme?.lowercased(),
800 allowedSchemes.contains(scheme),
801 let sanitizedURL = components.url?.absoluteString else {
802 return nil
803 }
804
805 return escapeHTMLAttribute(sanitizedURL)
806}
807
745nonisolated private func isOrgTableLine(_ line: String) -> Bool { 808nonisolated private func isOrgTableLine(_ line: String) -> Bool {
746 line.hasPrefix("|") && line.hasSuffix("|") 809 line.hasPrefix("|") && line.hasSuffix("|")
747} 810}
@@ -899,6 +962,7 @@ struct HTMLWebView: View {
899 let html: String 962 let html: String
900 let colorScheme: ColorScheme 963 let colorScheme: ColorScheme
901 var style: HTMLWebViewStyle = .readme 964 var style: HTMLWebViewStyle = .readme
965 @Environment(\.openURL) private var openURL
902 @State private var contentHeight: CGFloat = 1 966 @State private var contentHeight: CGFloat = 1
903 @State private var loadError: String? 967 @State private var loadError: String?
904 @State private var reloadToken = 0 968 @State private var reloadToken = 0
@@ -921,6 +985,7 @@ struct HTMLWebView: View {
921 html: html, 985 html: html,
922 colorScheme: colorScheme, 986 colorScheme: colorScheme,
923 style: style, 987 style: style,
988 openURL: openURL,
924 dynamicHeight: $contentHeight, 989 dynamicHeight: $contentHeight,
925 loadError: $loadError, 990 loadError: $loadError,
926 reloadToken: reloadToken 991 reloadToken: reloadToken
@@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
956 let html: String 1021 let html: String
957 let colorScheme: ColorScheme 1022 let colorScheme: ColorScheme
958 let style: HTMLWebViewStyle 1023 let style: HTMLWebViewStyle
1024 let openURL: OpenURLAction
959 @Binding var dynamicHeight: CGFloat 1025 @Binding var dynamicHeight: CGFloat
960 @Binding var loadError: String? 1026 @Binding var loadError: String?
961 let reloadToken: Int 1027 let reloadToken: Int
@@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
966 1032
967 func makeUIView(context: Context) -> WKWebView { 1033 func makeUIView(context: Context) -> WKWebView {
968 let config = WKWebViewConfiguration() 1034 let config = WKWebViewConfiguration()
969 config.defaultWebpagePreferences.allowsContentJavaScript = true 1035 config.defaultWebpagePreferences.allowsContentJavaScript = false
970 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore 1036 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
971 let webView = WKWebView(frame: .zero, configuration: config) 1037 let webView = WKWebView(frame: .zero, configuration: config)
972 webView.isOpaque = false 1038 webView.isOpaque = false
973 webView.backgroundColor = .clear 1039 webView.backgroundColor = .clear
974 webView.clipsToBounds = false 1040 webView.clipsToBounds = false
1041 webView.allowsLinkPreview = false
975 webView.scrollView.isScrollEnabled = false 1042 webView.scrollView.isScrollEnabled = false
976 webView.scrollView.contentInsetAdjustmentBehavior = .never 1043 webView.scrollView.contentInsetAdjustmentBehavior = .never
977 webView.scrollView.clipsToBounds = false 1044 webView.scrollView.clipsToBounds = false
@@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
1088 handleLoadFailure(error) 1155 handleLoadFailure(error)
1089 } 1156 }
1090 1157
1158 func webView(
1159 _ webView: WKWebView,
1160 decidePolicyFor navigationAction: WKNavigationAction,
1161 decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1162 ) {
1163 guard let requestURL = navigationAction.request.url else {
1164 decisionHandler(.allow)
1165 return
1166 }
1167
1168 if navigationAction.navigationType == .linkActivated {
1169 if isAllowedReadmeNavigationURL(requestURL) {
1170 parent.openURL(requestURL)
1171 }
1172 decisionHandler(.cancel)
1173 return
1174 }
1175
1176 if isAllowedReadmeNavigationURL(requestURL) {
1177 decisionHandler(.allow)
1178 } else {
1179 decisionHandler(.cancel)
1180 }
1181 }
1182
1091 private func handleLoadFailure(_ error: Error) { 1183 private func handleLoadFailure(_ error: Error) {
1092 let nsError = error as NSError 1184 let nsError = error as NSError
1093 guard nsError.code != NSURLErrorCancelled else { return } 1185 guard nsError.code != NSURLErrorCancelled else { return }
@@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
1097 } 1189 }
1098 1190
1099 private func updateHeight(for webView: WKWebView) { 1191 private func updateHeight(for webView: WKWebView) {
1100 let script = """ 1192 webView.layoutIfNeeded()
1101 Math.max( 1193 let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4
1102 document.body.scrollHeight, 1194 guard height > 0 else { return }
1103 document.body.offsetHeight, 1195 DispatchQueue.main.async {
1104 document.documentElement.scrollHeight, 1196 if let html = self.lastHTML {
1105 document.documentElement.offsetHeight, 1197 Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
1106 Math.ceil(document.body.getBoundingClientRect().height), 1198 }
1107 Math.ceil(document.documentElement.getBoundingClientRect().height) 1199 if abs(self.parent.dynamicHeight - height) > 0.5 {
1108 ) 1200 self.parent.dynamicHeight = height
1109 """
1110
1111 webView.evaluateJavaScript(script) { [weak self] result, _ in
1112 guard let value = result as? Double, value > 0 else { return }
1113 let height = ceil(value) + 4
1114 DispatchQueue.main.async {
1115 guard let self else { return }
1116 if let html = self.lastHTML {
1117 Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
1118 }
1119 if abs(self.parent.dynamicHeight - height) > 0.5 {
1120 self.parent.dynamicHeight = height
1121 }
1122 } 1201 }
1123 } 1202 }
1124 } 1203 }
HutchTests/ReadmeViewTests.swift added +29
@@ -0,0 +1,29 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct ReadmeViewTests {
6
7 @Test
8 func sanitizedReadmeLinkURLStringRejectsUnexpectedSchemes() {
9 #expect(sanitizedReadmeLinkURLString("javascript:alert(1)") == nil)
10 #expect(sanitizedReadmeLinkURLString("file:///tmp/readme") == nil)
11 #expect(sanitizedReadmeLinkURLString("data:text/html;base64,SGVsbG8=") == nil)
12 }
13
14 @Test
15 func processInlineDropsUnsafeMarkdownLinks() {
16 let rendered = processInline("[click me](javascript:alert)")
17
18 #expect(rendered == "click me")
19 #expect(!rendered.contains("href="))
20 #expect(!rendered.contains("javascript:"))
21 }
22
23 @Test
24 func sanitizedReadmeLinkURLStringAllowsExpectedDestinations() {
25 #expect(sanitizedReadmeLinkURLString("https://example.com/docs?q=1") == "https://example.com/docs?q=1")
26 #expect(sanitizedReadmeLinkURLString("mailto:test@example.com") == "mailto:test@example.com")
27 #expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
28 }
29}