Skip to main content

fraiseql_db/
path_escape.rs

1//! Escape utilities for JSON path SQL injection prevention.
2//!
3//! Different databases have different escaping requirements for JSON paths:
4//! - PostgreSQL: Single quote in JSONB operators -> double it
5//! - MySQL: Single quote in JSON_EXTRACT -> escape with backslash
6//! - SQLite: Single quote in json_extract -> escape with backslash
7//! - SQL Server: Single quote in JSON_VALUE -> double it
8
9/// Escape a single path segment for use in PostgreSQL JSONB operators.
10///
11/// PostgreSQL JSONB operators (->,'->>',->) are literal string operators
12/// where the right operand is interpreted as a JSON key string.
13/// Single quotes within the string must be doubled for SQL escaping.
14///
15/// # Example
16/// ```
17/// use fraiseql_db::path_escape::escape_postgres_jsonb_segment;
18/// assert_eq!(escape_postgres_jsonb_segment("user'name"), "user''name");
19/// assert_eq!(escape_postgres_jsonb_segment("normal"), "normal");
20/// ```
21pub fn escape_postgres_jsonb_segment(segment: &str) -> String {
22    segment.replace('\'', "''")
23}
24
25/// Escape a full JSON path for use in PostgreSQL JSONB operators.
26///
27/// # Example
28/// ```
29/// use fraiseql_db::path_escape::escape_postgres_jsonb_path;
30/// let path = vec!["user".to_string(), "name".to_string()];
31/// let result = escape_postgres_jsonb_path(&path);
32/// // Ensures each segment is properly escaped
33/// ```
34pub fn escape_postgres_jsonb_path(path: &[String]) -> Vec<String> {
35    path.iter().map(|segment| escape_postgres_jsonb_segment(segment)).collect()
36}
37
38/// Escape a JSON path for MySQL JSON_EXTRACT/JSON_UNQUOTE.
39///
40/// MySQL JSON paths use dot notation: '$.field.subfield'
41/// Single quotes are doubled (`''`) rather than backslash-escaped so that the
42/// path is safe even when the server runs with `NO_BACKSLASH_ESCAPES` mode.
43///
44/// # Example
45/// ```
46/// use fraiseql_db::path_escape::escape_mysql_json_path;
47/// let path = vec!["user".to_string(), "name".to_string()];
48/// let result = escape_mysql_json_path(&path);
49/// assert_eq!(result, "$.user.name");
50/// ```
51pub fn escape_mysql_json_path(path: &[String]) -> String {
52    let json_path = path.join(".");
53    // Double single quotes for SQL string literal; safe under NO_BACKSLASH_ESCAPES.
54    format!("$.{}", json_path.replace('\'', "''"))
55}
56
57/// Escape a JSON path for SQLite json_extract.
58///
59/// SQLite JSON paths use dot notation: '$.field.subfield'
60/// Single quotes are doubled (`''`) rather than backslash-escaped so that the
61/// path is safe regardless of SQLite compile-time escape settings.
62pub fn escape_sqlite_json_path(path: &[String]) -> String {
63    let json_path = path.join(".");
64    // Double single quotes for SQL string literal; backslash escaping is not
65    // a reliable cross-mode choice for SQLite.
66    format!("$.{}", json_path.replace('\'', "''"))
67}
68
69/// Escape a JSON path for SQL Server JSON_VALUE.
70///
71/// SQL Server JSON paths use dot notation: '$.field.subfield'
72/// Single quotes must be escaped for SQL string literals.
73pub fn escape_sqlserver_json_path(path: &[String]) -> String {
74    let json_path = path.join(".");
75    format!("$.{}", json_path.replace('\'', "''"))
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn test_postgres_single_quote() {
84        assert_eq!(escape_postgres_jsonb_segment("user'admin"), "user''admin");
85    }
86
87    #[test]
88    fn test_postgres_multiple_quotes() {
89        assert_eq!(escape_postgres_jsonb_segment("it's"), "it''s");
90    }
91
92    #[test]
93    fn test_postgres_no_quote() {
94        assert_eq!(escape_postgres_jsonb_segment("username"), "username");
95    }
96
97    #[test]
98    fn test_postgres_path_vector() {
99        let path = vec!["user'name".to_string(), "id".to_string()];
100        let result = escape_postgres_jsonb_path(&path);
101        assert_eq!(result[0], "user''name");
102        assert_eq!(result[1], "id");
103    }
104
105    #[test]
106    fn test_mysql_single_quote() {
107        let result = escape_mysql_json_path(&["user'admin".to_string()]);
108        assert_eq!(result, "$.user''admin");
109    }
110
111    #[test]
112    fn test_sqlite_single_quote() {
113        let result = escape_sqlite_json_path(&["user'admin".to_string()]);
114        assert_eq!(result, "$.user''admin");
115    }
116
117    #[test]
118    fn test_sqlserver_single_quote() {
119        let result = escape_sqlserver_json_path(&["user'admin".to_string()]);
120        assert_eq!(result, "$.user''admin");
121    }
122
123    #[test]
124    fn test_all_databases_empty_path() {
125        let empty_path: Vec<String> = vec![];
126        let pg_result = escape_postgres_jsonb_path(&empty_path);
127        let mysql_result = escape_mysql_json_path(&empty_path);
128        let sqlite_result = escape_sqlite_json_path(&empty_path);
129        let sqlserver_result = escape_sqlserver_json_path(&empty_path);
130
131        assert_eq!(pg_result.len(), 0);
132        assert_eq!(mysql_result, "$.");
133        assert_eq!(sqlite_result, "$.");
134        assert_eq!(sqlserver_result, "$.");
135    }
136
137    // =========================================================================
138    // Injection payload tests — 4 dialects × 10 payloads = 40 tests
139    // =========================================================================
140
141    // --- PostgreSQL segment escape ---
142
143    #[test]
144    fn test_postgres_injection_drop_table() {
145        let payload = "'; DROP TABLE users; --";
146        let escaped = escape_postgres_jsonb_segment(payload);
147        // Single quotes must be doubled so they cannot break out of a SQL string literal
148        // Payload starts with ' → becomes '' in the output
149        assert!(escaped.starts_with("''"), "Opening single quote must be doubled for PostgreSQL");
150        assert!(!escaped.starts_with("'\""), "Must not produce an unescaped sequence");
151    }
152
153    #[test]
154    fn test_postgres_injection_or_1_eq_1() {
155        let payload = "' OR '1'='1";
156        let escaped = escape_postgres_jsonb_segment(payload);
157        // All single quotes must be doubled — count of '' should match original ' count
158        let original_quote_count = payload.chars().filter(|&c| c == '\'').count();
159        let doubled_count = escaped.matches("''").count();
160        assert_eq!(doubled_count, original_quote_count, "Every single quote must be doubled");
161    }
162
163    #[test]
164    fn test_postgres_injection_double_quote_or() {
165        let payload = r#"" OR "1"="1"#;
166        let escaped = escape_postgres_jsonb_segment(payload);
167        // No single quotes in payload — output must be identical (double quotes are not special in
168        // PG segment)
169        assert_eq!(escaped, payload);
170    }
171
172    #[test]
173    fn test_postgres_injection_backslash() {
174        let payload = r"\";
175        let escaped = escape_postgres_jsonb_segment(payload);
176        // PostgreSQL does not treat backslash specially in dollar-quoted / JSONB operators; output
177        // is unchanged
178        assert_eq!(escaped, payload);
179    }
180
181    #[test]
182    fn test_postgres_injection_like_percent() {
183        let payload = "%";
184        let escaped = escape_postgres_jsonb_segment(payload);
185        // No single quotes — output unchanged
186        assert_eq!(escaped, payload);
187    }
188
189    #[test]
190    fn test_postgres_injection_like_underscore() {
191        let payload = "_";
192        let escaped = escape_postgres_jsonb_segment(payload);
193        assert_eq!(escaped, payload);
194    }
195
196    #[test]
197    fn test_postgres_injection_xss_script_tag() {
198        let payload = "<script>alert(1)</script>";
199        let escaped = escape_postgres_jsonb_segment(payload);
200        // No single quotes in XSS payload — output identical
201        assert_eq!(escaped, payload);
202    }
203
204    #[test]
205    fn test_postgres_injection_null_literal() {
206        let payload = "NULL";
207        let escaped = escape_postgres_jsonb_segment(payload);
208        assert_eq!(escaped, "NULL");
209    }
210
211    #[test]
212    fn test_postgres_injection_empty_string() {
213        let payload = "";
214        let escaped = escape_postgres_jsonb_segment(payload);
215        assert_eq!(escaped, "");
216    }
217
218    #[test]
219    fn test_postgres_injection_unicode_accents() {
220        let payload = "François";
221        let escaped = escape_postgres_jsonb_segment(payload);
222        // No single quotes — output unchanged
223        assert_eq!(escaped, "François");
224    }
225
226    // --- MySQL JSON path escape ---
227
228    #[test]
229    fn test_mysql_injection_drop_table() {
230        let payload = "'; DROP TABLE users; --";
231        let result = escape_mysql_json_path(&[payload.to_string()]);
232        // MySQL single quotes are doubled (not backslash-escaped) so the path
233        // is safe even under NO_BACKSLASH_ESCAPES.
234        assert!(result.contains("''"), "Single quote must be doubled for MySQL");
235        assert!(!result.contains("\\'"), "Must not use backslash escaping");
236        // Path must start with $.
237        assert!(result.starts_with("$."), "MySQL path must start with $.");
238    }
239
240    #[test]
241    fn test_mysql_injection_or_1_eq_1() {
242        let payload = "' OR '1'='1";
243        let result = escape_mysql_json_path(&[payload.to_string()]);
244        // All 4 single quotes in "' OR '1'='1" must be doubled.
245        let original_quote_count = payload.chars().filter(|&c| c == '\'').count();
246        let doubled_count = result.matches("''").count();
247        assert_eq!(
248            doubled_count, original_quote_count,
249            "Every single quote must be doubled in MySQL"
250        );
251    }
252
253    #[test]
254    fn test_mysql_injection_double_quote_or() {
255        let payload = r#"" OR "1"="1"#;
256        let result = escape_mysql_json_path(&[payload.to_string()]);
257        // No single quotes — path contains original (double quotes are not special in MySQL JSON
258        // path string)
259        assert!(result.starts_with("$."), "MySQL path must start with '$.'");
260    }
261
262    #[test]
263    fn test_mysql_injection_backslash() {
264        let payload = r"\";
265        let result = escape_mysql_json_path(&[payload.to_string()]);
266        assert!(result.starts_with("$."), "MySQL path must start with '$.'");
267    }
268
269    #[test]
270    fn test_mysql_injection_like_percent() {
271        let payload = "%";
272        let result = escape_mysql_json_path(&[payload.to_string()]);
273        assert_eq!(result, "$.%");
274    }
275
276    #[test]
277    fn test_mysql_injection_like_underscore() {
278        let payload = "_";
279        let result = escape_mysql_json_path(&[payload.to_string()]);
280        assert_eq!(result, "$._");
281    }
282
283    #[test]
284    fn test_mysql_injection_xss_script_tag() {
285        let payload = "<script>alert(1)</script>";
286        let result = escape_mysql_json_path(&[payload.to_string()]);
287        assert!(result.starts_with("$."), "MySQL path must start with '$.'");
288        assert!(!result.contains("'; "), "Should not contain unescaped quotes");
289    }
290
291    #[test]
292    fn test_mysql_injection_null_literal() {
293        let payload = "NULL";
294        let result = escape_mysql_json_path(&[payload.to_string()]);
295        assert_eq!(result, "$.NULL");
296    }
297
298    #[test]
299    fn test_mysql_injection_empty_segment() {
300        // Single empty segment in path
301        let result = escape_mysql_json_path(&[String::new()]);
302        assert_eq!(result, "$.");
303    }
304
305    #[test]
306    fn test_mysql_injection_unicode_accents() {
307        let payload = "François";
308        let result = escape_mysql_json_path(&[payload.to_string()]);
309        assert_eq!(result, "$.François");
310    }
311
312    // --- SQLite JSON path escape ---
313
314    #[test]
315    fn test_sqlite_injection_drop_table() {
316        let payload = "'; DROP TABLE users; --";
317        let result = escape_sqlite_json_path(&[payload.to_string()]);
318        // SQLite single quotes are doubled (not backslash-escaped) for
319        // consistent behaviour across SQLite builds.
320        assert!(result.contains("''"), "Single quote must be doubled for SQLite");
321        assert!(!result.contains("\\'"), "Must not use backslash escaping");
322        assert!(result.starts_with("$."), "SQLite path must start with $.");
323    }
324
325    #[test]
326    fn test_sqlite_injection_or_1_eq_1() {
327        let payload = "' OR '1'='1";
328        let result = escape_sqlite_json_path(&[payload.to_string()]);
329        let original_quote_count = payload.chars().filter(|&c| c == '\'').count();
330        let doubled_count = result.matches("''").count();
331        assert_eq!(
332            doubled_count, original_quote_count,
333            "Every single quote must be doubled in SQLite"
334        );
335    }
336
337    #[test]
338    fn test_sqlite_injection_double_quote_or() {
339        let payload = r#"" OR "1"="1"#;
340        let result = escape_sqlite_json_path(&[payload.to_string()]);
341        assert!(result.starts_with("$."), "SQLite path must start with '$.'");
342    }
343
344    #[test]
345    fn test_sqlite_injection_backslash() {
346        let payload = r"\";
347        let result = escape_sqlite_json_path(&[payload.to_string()]);
348        assert!(result.starts_with("$."), "SQLite path must start with '$.'");
349    }
350
351    #[test]
352    fn test_sqlite_injection_like_percent() {
353        let payload = "%";
354        let result = escape_sqlite_json_path(&[payload.to_string()]);
355        assert_eq!(result, "$.%");
356    }
357
358    #[test]
359    fn test_sqlite_injection_like_underscore() {
360        let payload = "_";
361        let result = escape_sqlite_json_path(&[payload.to_string()]);
362        assert_eq!(result, "$._");
363    }
364
365    #[test]
366    fn test_sqlite_injection_xss_script_tag() {
367        let payload = "<script>alert(1)</script>";
368        let result = escape_sqlite_json_path(&[payload.to_string()]);
369        assert!(result.starts_with("$."), "SQLite path must start with '$.'");
370    }
371
372    #[test]
373    fn test_sqlite_injection_null_literal() {
374        let payload = "NULL";
375        let result = escape_sqlite_json_path(&[payload.to_string()]);
376        assert_eq!(result, "$.NULL");
377    }
378
379    #[test]
380    fn test_sqlite_injection_empty_segment() {
381        let result = escape_sqlite_json_path(&[String::new()]);
382        assert_eq!(result, "$.");
383    }
384
385    #[test]
386    fn test_sqlite_injection_unicode_accents() {
387        let payload = "François";
388        let result = escape_sqlite_json_path(&[payload.to_string()]);
389        assert_eq!(result, "$.François");
390    }
391
392    // --- SQL Server JSON path escape ---
393
394    #[test]
395    fn test_sqlserver_injection_drop_table() {
396        let payload = "'; DROP TABLE users; --";
397        let result = escape_sqlserver_json_path(&[payload.to_string()]);
398        // SQL Server uses doubling: ' → '' (same as PostgreSQL)
399        assert!(result.contains("''"), "Single quote must be doubled in SQL Server");
400        assert!(result.starts_with("$."), "SQL Server path must start with $.");
401    }
402
403    #[test]
404    fn test_sqlserver_injection_or_1_eq_1() {
405        let payload = "' OR '1'='1";
406        let result = escape_sqlserver_json_path(&[payload.to_string()]);
407        let original_quote_count = payload.chars().filter(|&c| c == '\'').count();
408        let doubled_count = result.matches("''").count();
409        assert_eq!(
410            doubled_count, original_quote_count,
411            "Every single quote must be doubled in SQL Server"
412        );
413    }
414
415    #[test]
416    fn test_sqlserver_injection_double_quote_or() {
417        let payload = r#"" OR "1"="1"#;
418        let result = escape_sqlserver_json_path(&[payload.to_string()]);
419        assert!(result.starts_with("$."), "SQL Server path must start with '$.'");
420    }
421
422    #[test]
423    fn test_sqlserver_injection_backslash() {
424        let payload = r"\";
425        let result = escape_sqlserver_json_path(&[payload.to_string()]);
426        assert!(result.starts_with("$."), "SQL Server path must start with '$.'");
427    }
428
429    #[test]
430    fn test_sqlserver_injection_like_percent() {
431        let payload = "%";
432        let result = escape_sqlserver_json_path(&[payload.to_string()]);
433        assert_eq!(result, "$.%");
434    }
435
436    #[test]
437    fn test_sqlserver_injection_like_underscore() {
438        let payload = "_";
439        let result = escape_sqlserver_json_path(&[payload.to_string()]);
440        assert_eq!(result, "$._");
441    }
442
443    #[test]
444    fn test_sqlserver_injection_xss_script_tag() {
445        let payload = "<script>alert(1)</script>";
446        let result = escape_sqlserver_json_path(&[payload.to_string()]);
447        assert!(result.starts_with("$."), "SQL Server path must start with '$.'");
448    }
449
450    #[test]
451    fn test_sqlserver_injection_null_literal() {
452        let payload = "NULL";
453        let result = escape_sqlserver_json_path(&[payload.to_string()]);
454        assert_eq!(result, "$.NULL");
455    }
456
457    #[test]
458    fn test_sqlserver_injection_empty_segment() {
459        let result = escape_sqlserver_json_path(&[String::new()]);
460        assert_eq!(result, "$.");
461    }
462
463    #[test]
464    fn test_sqlserver_injection_unicode_accents() {
465        let payload = "François";
466        let result = escape_sqlserver_json_path(&[payload.to_string()]);
467        assert_eq!(result, "$.François");
468    }
469
470    // --- Cross-dialect consistency checks ---
471
472    #[test]
473    fn test_postgres_segment_double_single_quote_roundtrip() {
474        // A single quote in input → doubled in output
475        let input = "it's";
476        let escaped = escape_postgres_jsonb_segment(input);
477        assert_eq!(escaped, "it''s");
478    }
479
480    #[test]
481    fn test_mysql_vs_sqlite_same_escaping_for_single_quote() {
482        let payload = "user'name";
483        let mysql_result = escape_mysql_json_path(&[payload.to_string()]);
484        let sqlite_result = escape_sqlite_json_path(&[payload.to_string()]);
485        // Both use double-single-quote escaping (no backslash dependency)
486        assert_eq!(
487            mysql_result, sqlite_result,
488            "MySQL and SQLite should escape single quotes identically"
489        );
490        assert!(mysql_result.contains("''"), "MySQL must use double-single-quote escaping");
491        assert!(!mysql_result.contains("\\'"), "MySQL must not use backslash escaping");
492    }
493
494    #[test]
495    fn test_sqlserver_vs_postgres_same_doubling_strategy() {
496        let payload = "user'name";
497        let pg_seg = escape_postgres_jsonb_segment(payload);
498        let ss_result = escape_sqlserver_json_path(&[payload.to_string()]);
499        // PostgreSQL doubles the quote in the segment; SQL Server doubles it in the path body
500        assert!(pg_seg.contains("''"), "PostgreSQL should double the quote");
501        assert!(ss_result.contains("''"), "SQL Server should double the quote");
502    }
503
504    #[test]
505    fn test_postgres_path_multi_segment_escaping() {
506        let path = vec!["user'name".to_string(), "field's".to_string()];
507        let result = escape_postgres_jsonb_path(&path);
508        assert_eq!(result[0], "user''name");
509        assert_eq!(result[1], "field''s");
510    }
511
512    #[test]
513    fn test_mysql_multi_segment_path_joins_with_dot() {
514        let path = vec![
515            "user".to_string(),
516            "address".to_string(),
517            "city".to_string(),
518        ];
519        let result = escape_mysql_json_path(&path);
520        assert_eq!(result, "$.user.address.city");
521    }
522
523    // --- NO_BACKSLASH_ESCAPES safety ---
524
525    #[test]
526    fn mysql_escape_single_quote_no_backslash_mode() {
527        // Verifies that the MySQL path escaper uses '' rather than \' so it is
528        // safe when the server operates with NO_BACKSLASH_ESCAPES enabled.
529        let result = escape_mysql_json_path(&["user'name".to_string()]);
530        assert!(
531            !result.contains('\\'),
532            "Must not contain backslash (breaks under NO_BACKSLASH_ESCAPES)"
533        );
534        assert!(result.contains("''"), "Must double single quotes");
535        assert_eq!(result, "$.user''name");
536    }
537
538    #[test]
539    fn sqlite_escape_single_quote_no_backslash_mode() {
540        // SQLite does not recognise \' as an escape sequence in standard mode;
541        // doubling the quote is the only portable approach.
542        let result = escape_sqlite_json_path(&["user'name".to_string()]);
543        assert!(!result.contains('\\'), "Must not contain backslash");
544        assert!(result.contains("''"), "Must double single quotes");
545        assert_eq!(result, "$.user''name");
546    }
547}