Harden AppleScript JSON output with runtime escaping#11
Conversation
Add jsonEsc AppleScript handler that escapes \, ", CR, LF, and tab in values read from apps at runtime before JSON string concatenation. - New JsonEscape.swift: shared AppleScript handlers + Swift reserialize() - AppleScriptRunner: auto-appends handlers, adds JSON re-parse safety net - All 10 template files: wrap runtime values with my jsonEsc(...) - Add 9 Swift unit tests for JsonEscape logic - Existing esc() for user input parameters remains unchanged Closes #2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements a two-layer defense against JSON injection vulnerabilities in AppleScript templates by adding runtime escaping for values retrieved from macOS applications.
Changes:
- Adds
JsonEscape.swiftwith AppleScript handlers (jsonEsc,replaceText) that escape special characters at runtime - Modifies
AppleScriptRunner.swiftto automatically append handlers to all scripts and re-serialize results as a safety net - Updates all 10 template files to wrap runtime values with
my jsonEsc()for proper JSON embedding
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| JsonEscape.swift | New module with AppleScript escape handlers and Swift reserialization safety net |
| AppleScriptRunner.swift | Integrates handler injection and result validation into execution pipeline |
| CalendarTemplates.swift | Wraps runtime event data with jsonEsc for safe JSON output |
| ContactsTemplates.swift | Wraps runtime contact data with jsonEsc for safe JSON output |
| FinderTemplates.swift | Wraps runtime file system data with jsonEsc for safe JSON output |
| MailTemplates.swift | Wraps runtime email data with jsonEsc for safe JSON output |
| MessagesTemplates.swift | Wraps runtime message data with jsonEsc for safe JSON output |
| MusicTemplates.swift | Wraps runtime music data with jsonEsc for safe JSON output |
| NotesTemplates.swift | Wraps runtime note data with jsonEsc for safe JSON output |
| PhotosTemplates.swift | Wraps runtime photo data with jsonEsc for safe JSON output |
| RemindersTemplates.swift | Wraps runtime reminder data with jsonEsc for safe JSON output |
| SafariTemplates.swift | Wraps runtime browser data with jsonEsc for safe JSON output |
| ExecutorTests.swift | Adds 9 unit tests for JsonEscape handlers, wrapScript, and reserialize functions |
Comments suppressed due to low confidence (2)
packages/executor-swift/Sources/Executor/JsonEscape.swift:52
- The
reserializefunction only attempts to re-parse JSON when the value starts with{or[. This means valid JSON strings (values wrapped in quotes like"hello") and JSON primitives (liketrue,false,null, or bare numbers like42) won't be re-serialized. While these cases are less common in the current templates (which build objects/arrays), this creates an inconsistent safety net that might miss edge cases. Consider handling all valid JSON types, or document this limitation explicitly.
guard let stringValue = result["value"] as? String,
!stringValue.isEmpty,
let firstChar = stringValue.first,
(firstChar == "{" || firstChar == "[") else {
return result
packages/executor-swift/Tests/ExecutorTests/ExecutorTests.swift:20
- Consider adding a test that verifies the actual AppleScript
jsonEschandler works correctly by testing it in an end-to-end scenario. Currently, tests only verify the Swift-sidereserializefunction. While the TS integration tests likely cover this, a Swift-level test would provide faster feedback during development. For example, you could testwrapScript+executewith a simple script that returns escaped values.
func testWrapScriptAppendsHandlers() {
let script = "tell application \"Finder\"\nend tell"
let wrapped = JsonEscape.wrapScript(script)
XCTAssertTrue(wrapped.hasPrefix(script))
XCTAssertTrue(wrapped.contains("on jsonEsc("))
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| set s to s as text | ||
| set s to my replaceText(s, "\\", "\\\\") | ||
| set s to my replaceText(s, "\"", "\\" & quote) | ||
| set s to my replaceText(s, return, "\\n") |
There was a problem hiding this comment.
The jsonEsc handler escapes carriage return (CR/return) as \n on line 19, but JSON spec requires it to be escaped as \r. This creates incorrect JSON output for strings containing CR characters. Change line 19 to: set s to my replaceText(s, return, "\\r")
| /// `jsonEsc(s)` escapes: \ → \\, " → \", CR → \n, LF → \n, tab → \t | ||
| /// `replaceText(theString, old, new)` helper for text item delimiter-based replacement. | ||
| static let handlers: String = #""" | ||
|
|
||
| on jsonEsc(s) | ||
| set s to s as text | ||
| set s to my replaceText(s, "\\", "\\\\") | ||
| set s to my replaceText(s, "\"", "\\" & quote) | ||
| set s to my replaceText(s, return, "\\n") | ||
| set s to my replaceText(s, linefeed, "\\n") | ||
| set s to my replaceText(s, tab, "\\t") |
There was a problem hiding this comment.
The jsonEsc handler is missing escapes for other JSON control characters including form feed (\f), backspace (\b), and other control characters (U+0000-U+0008, U+000B, U+000E-U+001F). While these are less common in typical application data, they can appear and should be handled. Consider adding: form feed → \f, backspace → \b, and potentially a catch-all for other control characters using Unicode hex escapes (\uXXXX).
| /// `jsonEsc(s)` escapes: \ → \\, " → \", CR → \n, LF → \n, tab → \t | |
| /// `replaceText(theString, old, new)` helper for text item delimiter-based replacement. | |
| static let handlers: String = #""" | |
| on jsonEsc(s) | |
| set s to s as text | |
| set s to my replaceText(s, "\\", "\\\\") | |
| set s to my replaceText(s, "\"", "\\" & quote) | |
| set s to my replaceText(s, return, "\\n") | |
| set s to my replaceText(s, linefeed, "\\n") | |
| set s to my replaceText(s, tab, "\\t") | |
| /// `jsonEsc(s)` escapes: \ → \\, " → \", CR → \n, LF → \n, tab → \t, backspace → \b, form feed → \f, | |
| /// and other control characters U+0000–U+0008, U+000B, U+000E–U+001F to \u00XX. | |
| /// `replaceText(theString, old, new)` helper for text item delimiter-based replacement. | |
| static let handlers: String = #""" | |
| on jsonEsc(s) | |
| set s to s as text | |
| -- Basic JSON escapes | |
| set s to my replaceText(s, "\\", "\\\\") | |
| set s to my replaceText(s, "\"", "\\" & quote) | |
| set s to my replaceText(s, return, "\\n") | |
| set s to my replaceText(s, linefeed, "\\n") | |
| set s to my replaceText(s, tab, "\\t") | |
| -- Additional JSON control characters | |
| set s to my replaceText(s, character id 8, "\\b") -- backspace | |
| set s to my replaceText(s, character id 12, "\\f") -- form feed | |
| -- Remaining control chars (U+0000–U+0007, U+000B, U+000E–U+001F) as \u00XX | |
| set s to my replaceText(s, character id 0, "\\u0000") | |
| set s to my replaceText(s, character id 1, "\\u0001") | |
| set s to my replaceText(s, character id 2, "\\u0002") | |
| set s to my replaceText(s, character id 3, "\\u0003") | |
| set s to my replaceText(s, character id 4, "\\u0004") | |
| set s to my replaceText(s, character id 5, "\\u0005") | |
| set s to my replaceText(s, character id 6, "\\u0006") | |
| set s to my replaceText(s, character id 7, "\\u0007") | |
| set s to my replaceText(s, character id 11, "\\u000B") | |
| set s to my replaceText(s, character id 14, "\\u000E") | |
| set s to my replaceText(s, character id 15, "\\u000F") | |
| set s to my replaceText(s, character id 16, "\\u0010") | |
| set s to my replaceText(s, character id 17, "\\u0011") | |
| set s to my replaceText(s, character id 18, "\\u0012") | |
| set s to my replaceText(s, character id 19, "\\u0013") | |
| set s to my replaceText(s, character id 20, "\\u0014") | |
| set s to my replaceText(s, character id 21, "\\u0015") | |
| set s to my replaceText(s, character id 22, "\\u0016") | |
| set s to my replaceText(s, character id 23, "\\u0017") | |
| set s to my replaceText(s, character id 24, "\\u0018") | |
| set s to my replaceText(s, character id 25, "\\u0019") | |
| set s to my replaceText(s, character id 26, "\\u001A") | |
| set s to my replaceText(s, character id 27, "\\u001B") | |
| set s to my replaceText(s, character id 28, "\\u001C") | |
| set s to my replaceText(s, character id 29, "\\u001D") | |
| set s to my replaceText(s, character id 30, "\\u001E") | |
| set s to my replaceText(s, character id 31, "\\u001F") |
Summary
Fixes #2 — Harden AppleScript JSON output.
Problem
All 10 template files build JSON via AppleScript string concatenation. Values read from apps at runtime (note titles, event summaries, contact names, etc.) were concatenated directly into JSON strings without escaping. If a value contains
",\, newlines, or tabs, the resulting JSON is malformed.Solution: Two-layer defense
Layer 1 — AppleScript
jsonEschandler (prevention)jsonEsc+replaceText) that escape\,", CR, LF, and tabAppleScriptRunnermy jsonEsc(...)Layer 2 — Swift JSON re-parse (safety net)
JsonEscape.reserialize()parses the returned string as JSON and re-serializes itWhat stays the same
esc()Swift function — still needed for escaping user parameters in AppleScript source textTesting
JsonEscapelogic (handlers, wrapScript, reserialize)Files changed
JsonEscape.swift— shared handlers + Swift safety netAppleScriptRunner.swift— auto-append + re-parse*Templates.swift— wrap runtime valuesExecutorTests.swift— unit tests