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 @@
4242 debugDocumentVersioning = "YES"
4343 debugServiceExtension = "internal"
4444 allowLocationSimulation = "YES">
45 <MacroExpansion>
45 <BuildableProductRunnable
46 runnableDebuggingMode = "0">
4647 <BuildableReference
4748 BuildableIdentifier = "primary"
4849 BlueprintIdentifier = "8B4B28D02F6704280045FA19"
@@ -50,7 +51,7 @@
5051 BlueprintName = "Hutch"
5152 ReferencedContainer = "container:Hutch.xcodeproj">
5253 </BuildableReference>
53 </MacroExpansion>
54 </BuildableProductRunnable>
5455 </LaunchAction>
5556 <ProfileAction
5657 buildConfiguration = "Release"
@@ -58,6 +59,15 @@
5859 savedToolIdentifier = ""
5960 useCustomWorkingDirectory = "NO"
6061 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>
6171 </ProfileAction>
6272 <AnalyzeAction
6373 buildConfiguration = "Debug">
Hutch/Views/Repositories/ReadmeView.swift +110 −31
@@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
387387 let alt = nsText.substring(with: match.range(at: 1))
388388 let source = nsText.substring(with: match.range(at: 2))
389389 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))">"#
391394 }
392395 // Links: [text](url)
393 result = result.replacingOccurrences(
394 of: #"\[([^\]]+)\]\(([^)]+)\)"#,
395 with: #"<a href="$2">$1</a>"#,
396 options: .regularExpression
397 )
396 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
397 let label = nsText.substring(with: match.range(at: 1))
398 let rawURL = nsText.substring(with: match.range(at: 2))
399 guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
400 return label
401 }
402 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
403 }
398404 // Bold: **text**
399405 result = result.replacingOccurrences(
400406 of: #"\*\*(.+?)\*\*"#,
@@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
676682 ) {
677683 return imageHTML
678684 }
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>"#
680689 }
681690 result = protectMatches(
682691 in: result,
@@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
691700 ) {
692701 return imageHTML
693702 }
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>"#
695707 }
696708 result = protectMatches(
697709 in: result,
@@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
742754 escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
743755}
744756
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
745808nonisolated private func isOrgTableLine(_ line: String) -> Bool {
746809 line.hasPrefix("|") && line.hasSuffix("|")
747810}
@@ -899,6 +962,7 @@ struct HTMLWebView: View {
899962 let html: String
900963 let colorScheme: ColorScheme
901964 var style: HTMLWebViewStyle = .readme
965 @Environment(\.openURL) private var openURL
902966 @State private var contentHeight: CGFloat = 1
903967 @State private var loadError: String?
904968 @State private var reloadToken = 0
@@ -921,6 +985,7 @@ struct HTMLWebView: View {
921985 html: html,
922986 colorScheme: colorScheme,
923987 style: style,
988 openURL: openURL,
924989 dynamicHeight: $contentHeight,
925990 loadError: $loadError,
926991 reloadToken: reloadToken
@@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
9561021 let html: String
9571022 let colorScheme: ColorScheme
9581023 let style: HTMLWebViewStyle
1024 let openURL: OpenURLAction
9591025 @Binding var dynamicHeight: CGFloat
9601026 @Binding var loadError: String?
9611027 let reloadToken: Int
@@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
9661032
9671033 func makeUIView(context: Context) -> WKWebView {
9681034 let config = WKWebViewConfiguration()
969 config.defaultWebpagePreferences.allowsContentJavaScript = true
1035 config.defaultWebpagePreferences.allowsContentJavaScript = false
9701036 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
9711037 let webView = WKWebView(frame: .zero, configuration: config)
9721038 webView.isOpaque = false
9731039 webView.backgroundColor = .clear
9741040 webView.clipsToBounds = false
1041 webView.allowsLinkPreview = false
9751042 webView.scrollView.isScrollEnabled = false
9761043 webView.scrollView.contentInsetAdjustmentBehavior = .never
9771044 webView.scrollView.clipsToBounds = false
@@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
10881155 handleLoadFailure(error)
10891156 }
10901157
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
10911183 private func handleLoadFailure(_ error: Error) {
10921184 let nsError = error as NSError
10931185 guard nsError.code != NSURLErrorCancelled else { return }
@@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
10971189 }
10981190
10991191 private func updateHeight(for webView: WKWebView) {
1100 let script = """
1101 Math.max(
1102 document.body.scrollHeight,
1103 document.body.offsetHeight,
1104 document.documentElement.scrollHeight,
1105 document.documentElement.offsetHeight,
1106 Math.ceil(document.body.getBoundingClientRect().height),
1107 Math.ceil(document.documentElement.getBoundingClientRect().height)
1108 )
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 }
1192 webView.layoutIfNeeded()
1193 let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4
1194 guard height > 0 else { return }
1195 DispatchQueue.main.async {
1196 if let html = self.lastHTML {
1197 Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
1198 }
1199 if abs(self.parent.dynamicHeight - height) > 0.5 {
1200 self.parent.dynamicHeight = height
11221201 }
11231202 }
11241203 }
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}