krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

e07b4a28d6ac6086e5b652f72b9089e4d06f7e80

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T00:23:29Z

harden README web rendering and sanitize untrusted links
 .../xcshareddata/xcschemes/HutchTests.xcscheme     |  14 +-
 Hutch/Views/Repositories/ReadmeView.swift          | 141 ++++++++++++++++-----
 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: "&#39;")
 }
 
+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:test@example.com") == "mailto:test@example.com")
+        #expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
+    }
+}