Soundcheck: security review findings#1
Open
github-actions[bot] wants to merge 1 commit into
Open
Conversation
Automated rewrites from the Soundcheck security review action.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security Review
Findings
Sources/Reader/EditorTextView.swift:290NSAttributedString(rtf:documentAttributes:)with no size cap — a crafted or oversized RTF payload triggers unbounded memory allocation and main-thread stall inside the AppKit RTF parser.data.count: reject RTF payloads above ~2 MB before calling the parser; fall back to plain-text path.Sources/Reader/EditorTextView.swift:286restyle()on the main thread, enabling the ReDoS chain below.Sources/Reader/MarkdownStyler.swift:379.dotMatchesLineSeparators— the.+?group spans the entire document, causing catastrophic backtracking on unclosed**markers.restyle()runs on every single keystroke with the full document range..dotMatchesLineSeparatorsfrom inline-emphasis rules (bold/italic are single-line constructs); use possessive/atomic groups or switch to a linear-time parser.Sources/Reader/MarkdownStyler.swift:23restyle()— all regex rules run synchronously on the main thread on every character edit regardless of document size.Sources/Reader/MainWindowController.swift:193String(contentsOf: url, encoding: .utf8)reads an entire file with no size limit; no sandbox. A symlink to/dev/zero, a huge sparse file, or/dev/stdincauses unbounded memory allocation or an indefinite main-thread block.FileManager.default.attributesOfItembefore reading; reject above ~50 MB; resolve symlinks and validate the resolved path is not a device node.appcast.xml:7SUFeedURLinInfo.plistpoints directly tohttps://raw.githubusercontent.com/thejefflarson/reader/main/appcast.xml. Any attacker gaining write access to the repository can push a malicious update entry; Sparkle's EdDSA check is the only barrier.appcast.xmlon separate, write-access-controlled infrastructure (dedicated GitHub Pages branch with branch protection, S3, or CDN); require PR review for any change to the feed.scripts/release.sh:76NOTESshell variable is interpolated directly into an XML CDATA section without escaping the]]>terminator sequence. A notes string containing]]>closes the CDATA block early and allows injection of arbitrary XML elements (e.g., a forged<enclosure>) intoappcast.xml, which is then served to all users.]]>in$NOTESbefore interpolation (replace with]]]]><markdown — includingfile://,app://,x-apple-*, and other non-http schemes from RTF clipboard.linkattributes. These are written to disk on save and appear in any future rendering context.MarkdownStyler.safeURL()insideMarkdownSerializer.wrap()before emitting the URL; strip the link wrapper for disallowed schemes.Sources/Reader/MarkdownStyler.swift:253setextHeading2pattern(-{2,})\s*$causes O(n²) backtracking on lines ending with many hyphens not followed by a newline (e.g., EOF). Runs against the full document on every keystroke.\s*$with[ \t]*$to eliminate newline ambiguity that drives backtracking; also apply a length cap to skip the rule on large documents.Sources/Reader/MarkdownStyler.swift:351tableRowouter regex(?m)^(\s*|.+|)\s*$uses greedy.+between|anchors; produces quadratic backtracking on long lines with many pipe characters but no trailing|..+with[^\n]+(character class cannot backtrack across|boundaries, eliminating the quadratic path).Sources/Reader/AppDelegate.swift:32application(_:openFile:)accepts a raw filename string from Launch Services / CLI with no extension allowlist and no path validation, bypassing theNSOpenPanelcontent-type filter before callingopen(url:).["md","markdown","mdown","mkd","mkdn"]; resolve symlinks and verify the resolved URL stays within expected directories before passing toopen(url:).scripts/release.sh:46$VERSIONis passed unquoted toPlistBuddyand interpolated into XML attribute values without validation. Shell metacharacters or XML special characters in the version string can inject arbitrary PlistBuddy commands or corruptappcast.xml.$VERSIONagainst^[0-9]+\.[0-9]+\.[0-9]+$at script entry and abort if it does not match; quote all variable expansions.scripts/release.sh:53codesign --force --deep --sign -produces an ad-hoc signature with no Developer ID; binary is indistinguishable from a tampered copy and Gatekeeper quarantines it on first launch.xcrun notarytool submitandxcrun stapler stapleso Gatekeeper can verify offline.scripts/reader:46/usr/bin/open -b com.jefflarson.Reader.*.md,*.markdown,*.mdown,*.mkd,*.mkdn; abort with an error message for all other types.scripts/release.sh:104<item>block intoappcast.xmlviare.substring concatenation with no XML parsing; any deviation in file structure silently no-ops the insertion, and injected content from$NOTESsurvives intact.xml.etree.ElementTree: parse, build the<item>subtree programmatically, insert, and serialise.Package.swift:8from: "2.6.4"(open upper bound); a future compromised Sparkle release within the2.xrange is automatically consumed onswift package update..package(url: "...", exact: "2.9.1")so upgrades require a deliberate, reviewablePackage.swiftchange.Sources/Reader/MainWindowController.swift:196noteNewRecentDocumentURLis called unconditionally for all opens including CLI-supplied paths; arbitrary sensitive paths (e.g.,/etc/passwd) are permanently recorded in the macOS Recent Documents list, visible to other apps.noteNewRecentDocumentURLfor user-initiated open/save panel interactions; set a flag in the CLI-argument path to suppress the call.Resources/Info.plist:70SUPublicEDKeyis baked into the bundle; if the private signing key is compromised the public key cannot be rotated without shipping a new release — itself requiring the compromised key.Resources/Info.plist:68NSAppTransportSecuritydictionary is present; any futureURLSessioncall added to the codebase will silently use the system-default policy rather than an enforced HTTPS-only policy.NSAppTransportSecurity→NSAllowsArbitraryLoads = falsewith no exceptions toInfo.plist.Attack Chains
Chain A — Critical
An attacker who gains write access to the GitHub repository — a single compromised credential, leaked GitHub Actions token, or accepted malicious pull request — can push a replacement
appcast.xmldirectly to the raw.githubusercontent.com URL that every deployed Reader client polls for updates, because that URL is hardcoded asSUFeedURL. Because release.sh signs every build with an ad-hoc identity instead of a Developer ID, there is no secondary code-signing check the user's Mac can enforce; Sparkle's EdDSA signature is the only barrier, and any update package signed with the repository's private signing key is silently accepted and installed by every auto-updating client. The open version bound on the Sparkle dependency adds a second vector: a future compromised Sparkle release above 2.6.4 is automatically pulled in on the nextswift package updatewith no repository change required. The attacker walks away with arbitrary code execution on every machine that auto-updates Reader.Chain B — High
A malicious insider or compromised CI pipeline invokes
release.shwith aVERSIONstring containing XML special characters or shell metacharacters, which propagates unescaped intoInfo.plistvia PlistBuddy and into the raw$ITEMshell variable; theNOTESargument then breaks out of the CDATA section because the release script embeds it with no]]>escaping, injecting arbitrary XML elements such as a forged<enclosure>element pointing to an attacker-controlled download URL. The Python post-processing step splices this corruptedITEMblock into the liveappcast.xmlvia a regex substitution rather than an XML parser, so malformed or injected markup survives intact and is committed to the repository. Because the resultingappcast.xmlis served verbatim to all clients via the raw GitHub URL, the attacker's forged update entry is delivered to every Reader installation at the next update check.Chain C — High
An attacker crafts a file with no
.mdextension (or a symlink to/dev/zero) and persuades a user to open it via thereaderCLI script, which passes any file path through to/usr/bin/openwithout extension validation;AppDelegate.application(_:openFile:)then accepts the raw filename with no allowlist check and callsopen(url:), which reads the entire file into memory viaString(contentsOf:)with no size cap. Once the adversarial content lands in the text storage,restyle()fires on the main thread with no document-size guard, causing the bold regex with.dotMatchesLineSeparators, the setext-heading hyphen pattern, and the table-row greedy pattern to all run catastrophic backtracking passes over the entire document simultaneously. The user's application hangs indefinitely, producing a reliable local denial-of-service requiring no privileges beyond placing a file on disk.Chain D — High
An attacker places adversarial content on the clipboard — either a multi-megabyte plain-text string or a crafted RTF payload that inflates to a large attributed string — and waits for or socially engineers the user into pasting it into Reader. The
insertTextcall fromreadSelection(from:)triggerstextStorageDidProcessEditing, which unconditionally callsrestyle()with the full enlarged document and no size gate, simultaneously engaging the bold regex, the setext-heading pattern, and the table-row outer greedy quantifier in catastrophic backtracking on the main thread. The result is a complete, persistent main-thread hang that freezes the application without any file system access or elevated privileges required from the attacker.Summary: 19 findings across 4 chains. Two chains reach Critical/High severity and threaten all users (Chain A: arbitrary code execution via update-feed takeover; Chain B: release-script injection into the live appcast). Two further chains (C and D) provide reliable denial-of-service via crafted files or clipboard content. Priority remediations: lock down
appcast.xmlhosting to write-access-controlled infrastructure, sanitise release script inputs, add a file-size cap and debounce torestyle(), and remove.dotMatchesLineSeparatorsfrom inline-emphasis regexes.To apply fixes interactively, run
/security-cleanup.Generated by Soundcheck