Skip to content

Commit 764e8fd

Browse files
committed
wip
1 parent 8b65d67 commit 764e8fd

9 files changed

Lines changed: 130 additions & 37 deletions

File tree

TablePro/Core/ChangeTracking/SQLStatementGenerator.swift

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -397,14 +397,8 @@ struct SQLStatementGenerator {
397397
}
398398

399399
/// Escape characters that can break SQL strings
400+
/// Delegates to shared SQLEscaping utility for consistent escaping across the codebase
400401
private func escapeSQLString(_ str: String) -> String {
401-
var result = str
402-
result = result.replacingOccurrences(of: "\\", with: "\\\\") // Backslash first
403-
result = result.replacingOccurrences(of: "'", with: "''") // Single quote
404-
result = result.replacingOccurrences(of: "\n", with: "\\n") // Newline
405-
result = result.replacingOccurrences(of: "\r", with: "\\r") // Carriage return
406-
result = result.replacingOccurrences(of: "\t", with: "\\t") // Tab
407-
result = result.replacingOccurrences(of: "\0", with: "\\0") // Null byte
408-
return result
402+
SQLEscaping.escapeStringLiteral(str)
409403
}
410404
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// SQLEscaping.swift
3+
// TablePro
4+
//
5+
// Shared utilities for SQL string escaping to prevent SQL injection.
6+
// Used across ExportService, SQLStatementGenerator, and other SQL-generating code.
7+
//
8+
9+
import Foundation
10+
11+
/// Centralized SQL escaping utilities to prevent SQL injection vulnerabilities
12+
enum SQLEscaping {
13+
14+
/// Escape a string value for use in SQL string literals (VALUES, WHERE clauses, etc.)
15+
///
16+
/// Handles the following special characters:
17+
/// - Backslashes (must be escaped first to avoid double-escaping)
18+
/// - Single quotes (SQL standard: doubled)
19+
/// - Newlines, carriage returns, tabs, null bytes
20+
///
21+
/// Example:
22+
/// ```swift
23+
/// let safe = SQLEscaping.escapeStringLiteral("O'Brien\\test")
24+
/// // Result: "O''Brien\\\\test"
25+
/// let sql = "INSERT INTO users (name) VALUES ('\(safe)')"
26+
/// ```
27+
///
28+
/// - Parameter str: The raw string to escape
29+
/// - Returns: The escaped string safe for use in SQL string literals
30+
static func escapeStringLiteral(_ str: String) -> String {
31+
var result = str
32+
// IMPORTANT: Escape backslashes FIRST to avoid double-escaping
33+
result = result.replacingOccurrences(of: "\\", with: "\\\\")
34+
// Single quote: SQL standard escaping (double the quote)
35+
result = result.replacingOccurrences(of: "'", with: "''")
36+
// Control characters
37+
result = result.replacingOccurrences(of: "\n", with: "\\n")
38+
result = result.replacingOccurrences(of: "\r", with: "\\r")
39+
result = result.replacingOccurrences(of: "\t", with: "\\t")
40+
result = result.replacingOccurrences(of: "\0", with: "\\0")
41+
return result
42+
}
43+
44+
/// Escape wildcards in LIKE patterns while preserving intentional wildcards
45+
///
46+
/// This is useful when building LIKE clauses where the search term should be treated literally.
47+
///
48+
/// - Parameter value: The value to escape
49+
/// - Returns: The escaped value with %, _, and \ escaped
50+
static func escapeLikeWildcards(_ value: String) -> String {
51+
var result = value
52+
result = result.replacingOccurrences(of: "\\", with: "\\\\")
53+
result = result.replacingOccurrences(of: "%", with: "\\%")
54+
result = result.replacingOccurrences(of: "_", with: "\\_")
55+
return result
56+
}
57+
}

TablePro/Core/Services/CreateTableService.swift

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -545,15 +545,9 @@ struct CreateTableService {
545545
}
546546

547547
/// Escape characters that can break SQL strings
548+
/// Delegates to shared SQLEscaping utility for consistent escaping across the codebase
548549
private func escapeSQLString(_ str: String) -> String {
549-
var result = str
550-
result = result.replacingOccurrences(of: "\\", with: "\\\\") // Backslash first
551-
result = result.replacingOccurrences(of: "'", with: "''") // Single quote (SQL standard)
552-
result = result.replacingOccurrences(of: "\n", with: "\\n") // Newline
553-
result = result.replacingOccurrences(of: "\r", with: "\\r") // Carriage return
554-
result = result.replacingOccurrences(of: "\t", with: "\\t") // Tab
555-
result = result.replacingOccurrences(of: "\0", with: "\\0") // Null byte
556-
return result
550+
SQLEscaping.escapeStringLiteral(str)
557551
}
558552
}
559553

TablePro/Core/Services/ExportService.swift

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -343,22 +343,34 @@ final class ExportService: ObservableObject {
343343
}
344344

345345
private func escapeCSVField(_ field: String, options: CSVExportOptions) -> String {
346+
var processed = field
347+
348+
// Sanitize formula-like prefixes to prevent CSV formula injection
349+
// Values starting with these characters can be executed as formulas in Excel/LibreOffice
350+
if options.sanitizeFormulas {
351+
let dangerousPrefixes: [Character] = ["=", "+", "-", "@", "\t", "\r"]
352+
if let first = processed.first, dangerousPrefixes.contains(first) {
353+
// Prefix with single quote - Excel/LibreOffice treats this as text
354+
processed = "'" + processed
355+
}
356+
}
357+
346358
switch options.quoteHandling {
347359
case .always:
348-
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
360+
let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"")
349361
return "\"\(escaped)\""
350362
case .never:
351-
return field
363+
return processed
352364
case .asNeeded:
353-
let needsQuotes = field.contains(options.delimiter.actualValue) ||
354-
field.contains("\"") ||
355-
field.contains("\n") ||
356-
field.contains("\r")
365+
let needsQuotes = processed.contains(options.delimiter.actualValue) ||
366+
processed.contains("\"") ||
367+
processed.contains("\n") ||
368+
processed.contains("\r")
357369
if needsQuotes {
358-
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
370+
let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"")
359371
return "\"\(escaped)\""
360372
}
361-
return field
373+
return processed
362374
}
363375
}
364376

@@ -416,7 +428,7 @@ final class ExportService: ObservableObject {
416428
isFirstField = false
417429

418430
let escapedKey = escapeJSONString(column)
419-
let jsonValue = formatJSONValue(value)
431+
let jsonValue = formatJSONValue(value, preserveAsString: config.jsonOptions.preserveAllAsStrings)
420432
try fileHandle.write(contentsOf: "\"\(escapedKey)\": \(jsonValue)".toUTF8Data())
421433
}
422434
}
@@ -462,9 +474,18 @@ final class ExportService: ObservableObject {
462474
}
463475

464476
/// Format a value for JSON output
465-
private func formatJSONValue(_ value: String?) -> String {
477+
/// - Parameters:
478+
/// - value: The value to format
479+
/// - preserveAsString: If true, always output as string without type detection
480+
/// (preserves leading zeros in ZIP codes, phone numbers, etc.)
481+
private func formatJSONValue(_ value: String?, preserveAsString: Bool) -> String {
466482
guard let val = value else { return "null" }
467483

484+
// If preserving all as strings, skip type detection
485+
if preserveAsString {
486+
return "\"\(escapeJSONString(val))\""
487+
}
488+
468489
// Try to detect numbers and booleans
469490
if let intVal = Int(val) {
470491
return String(intVal)
@@ -614,8 +635,8 @@ final class ExportService: ObservableObject {
614635

615636
let values = row.map { value -> String in
616637
guard let val = value else { return "NULL" }
617-
// Escape single quotes by doubling them
618-
let escaped = val.replacingOccurrences(of: "'", with: "''")
638+
// Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.)
639+
let escaped = SQLEscaping.escapeStringLiteral(val)
619640
return "'\(escaped)'"
620641
}.joined(separator: ", ")
621642

@@ -646,8 +667,6 @@ final class ExportService: ObservableObject {
646667
process.arguments = ["-c", source.path]
647668

648669
let outputFile = try FileHandle(forWritingTo: destination)
649-
defer {
650-
try? outputFile.close()
651670
defer {
652671
try? outputFile.close()
653672
}

TablePro/Models/ExportModels.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ struct CSVExportOptions: Equatable {
100100
var quoteHandling: CSVQuoteHandling = .asNeeded
101101
var lineBreak: CSVLineBreak = .lf
102102
var decimalFormat: CSVDecimalFormat = .period
103+
/// Sanitize formula-like values to prevent CSV formula injection attacks.
104+
/// When enabled, values starting with =, +, -, @, tab, or carriage return
105+
/// are prefixed with a single quote to prevent execution in spreadsheet applications.
106+
var sanitizeFormulas: Bool = true
103107
}
104108

105109
// MARK: - JSON Options
@@ -108,6 +112,9 @@ struct CSVExportOptions: Equatable {
108112
struct JSONExportOptions: Equatable {
109113
var prettyPrint: Bool = true
110114
var includeNullValues: Bool = true
115+
/// When enabled, all values are exported as strings without type detection.
116+
/// This preserves leading zeros in ZIP codes, phone numbers, and similar data.
117+
var preserveAllAsStrings: Bool = false
111118
}
112119

113120
// MARK: - SQL Options

TablePro/Views/Export/ExportCSVOptionsView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ struct ExportCSVOptionsView: View {
2424

2525
Toggle("Put field names in the first row", isOn: $options.includeFieldNames)
2626
.toggleStyle(.checkbox)
27+
28+
Toggle("Sanitize formula-like values", isOn: $options.sanitizeFormulas)
29+
.toggleStyle(.checkbox)
30+
.help("Prevent CSV formula injection by prefixing values starting with =, +, -, @ with a single quote")
2731
}
2832

2933
Divider()

TablePro/Views/Export/ExportDialog.swift

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ struct ExportDialog: View {
278278
}
279279
.buttonStyle(.borderedProminent)
280280
.keyboardShortcut(.return, modifiers: [])
281-
.disabled(selectedCount == 0 || isExporting)
281+
.disabled(selectedCount == 0 || isExporting || !isFileNameValid)
282282
}
283283
.padding(.horizontal, 16)
284284
.padding(.vertical, 12)
@@ -301,6 +301,21 @@ struct ExportDialog: View {
301301
return config.format.fileExtension
302302
}
303303

304+
/// Validates that the filename is not empty and contains no invalid filesystem characters
305+
private var isFileNameValid: Bool {
306+
let name = config.fileName.trimmingCharacters(in: .whitespaces)
307+
guard !name.isEmpty else { return false }
308+
309+
// Invalid filesystem characters (covers macOS, Windows, and Linux)
310+
let invalidChars = CharacterSet(charactersIn: "/\\:*?\"<>|")
311+
guard name.rangeOfCharacter(from: invalidChars) == nil else { return false }
312+
313+
// Prevent path traversal attempts
314+
guard !name.contains("..") else { return false }
315+
316+
return true
317+
}
318+
304319
// MARK: - Actions
305320

306321
@MainActor
@@ -421,8 +436,8 @@ struct ExportDialog: View {
421436
}
422437

423438
private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] {
424-
// Escape single quotes to prevent SQL injection
425-
let escapedSchema = schema.replacingOccurrences(of: "'", with: "''")
439+
// Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.)
440+
let escapedSchema = SQLEscaping.escapeStringLiteral(schema)
426441
let query = """
427442
SELECT table_name, table_type
428443
FROM information_schema.tables
@@ -439,8 +454,8 @@ struct ExportDialog: View {
439454
}
440455

441456
private func fetchTablesForDatabase(_ database: String, driver: DatabaseDriver) async throws -> [TableInfo] {
442-
// Escape single quotes to prevent SQL injection
443-
let escapedDatabase = database.replacingOccurrences(of: "'", with: "''")
457+
// Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.)
458+
let escapedDatabase = SQLEscaping.escapeStringLiteral(database)
444459
// MySQL/MariaDB: query information_schema for tables in specific database
445460
let query = """
446461
SELECT TABLE_NAME, TABLE_TYPE

TablePro/Views/Export/ExportJSONOptionsView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ struct ExportJSONOptionsView: View {
1919

2020
Toggle("Include NULL values", isOn: $options.includeNullValues)
2121
.toggleStyle(.checkbox)
22+
23+
Toggle("Preserve all values as strings", isOn: $options.preserveAllAsStrings)
24+
.toggleStyle(.checkbox)
25+
.help("Keep leading zeros in ZIP codes, phone numbers, and IDs by outputting all values as strings")
2226
}
2327
.font(.system(size: DesignConstants.FontSize.body))
2428
}

TablePro/Views/Export/ExportSuccessView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,11 @@ struct ExportSuccessView: View {
1414
let onClose: () -> Void
1515

1616
@AppStorage("hideExportSuccessDialog") private var dontShowAgain = false
17-
@State private var localDontShowAgain: Bool
17+
@State private var localDontShowAgain = false
1818

1919
init(onOpenFolder: @escaping () -> Void, onClose: @escaping () -> Void) {
2020
self.onOpenFolder = onOpenFolder
2121
self.onClose = onClose
22-
_localDontShowAgain = State(initialValue: dontShowAgain)
2322
}
2423
var body: some View {
2524
VStack(spacing: 20) {

0 commit comments

Comments
 (0)