t-sql 0.1.2__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {t_sql-0.1.2 → t_sql-1.0.0}/PKG-INFO +26 -3
- t_sql-0.1.2/t_sql.egg-info/PKG-INFO → t_sql-1.0.0/README.md +24 -11
- {t_sql-0.1.2 → t_sql-1.0.0}/pyproject.toml +5 -1
- t_sql-0.1.2/README.md → t_sql-1.0.0/t_sql.egg-info/PKG-INFO +35 -3
- {t_sql-0.1.2 → t_sql-1.0.0}/t_sql.egg-info/SOURCES.txt +4 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/tests/test_asyncpg_integration.py +2 -35
- t_sql-1.0.0/tests/test_escaped_binary_hex.py +146 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/tests/test_helper_functions.py +17 -1
- t_sql-1.0.0/tests/test_injection_edge_cases.py +161 -0
- t_sql-1.0.0/tests/test_injection_protection_validation.py +237 -0
- t_sql-1.0.0/tests/test_injections_for_escaped.py +163 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/tsql/__init__.py +28 -7
- {t_sql-0.1.2 → t_sql-1.0.0}/tsql/styles.py +4 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/LICENSE +0 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/setup.cfg +0 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/t_sql.egg-info/dependency_links.txt +0 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/t_sql.egg-info/top_level.txt +0 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/tests/test_different_object_types.py +0 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/tests/test_escaped.py +0 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/tests/test_styles.py +0 -0
- {t_sql-0.1.2 → t_sql-1.0.0}/tests/test_tsql.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: t-sql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
|
+
Project-URL: Homepage, https://github.com/nhumrich/tsql
|
|
5
6
|
Requires-Python: >=3.14
|
|
6
7
|
Description-Content-Type: text/markdown
|
|
7
8
|
License-File: LICENSE
|
|
@@ -14,10 +15,27 @@ A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP
|
|
|
14
15
|
TSQL provides a safe way to write SQL queries using Python's template strings (t-strings) while preventing SQL injection attacks through multiple parameter styling options.
|
|
15
16
|
|
|
16
17
|
## ⚠️ Python Version Requirement
|
|
17
|
-
This library requires Python 3.
|
|
18
|
+
This library requires Python 3.14+
|
|
18
19
|
|
|
19
20
|
TSQL is built specifically to take advantage of the new t-string feature introduced in PEP 750, which is only available in Python 3.14+.
|
|
20
21
|
|
|
22
|
+
## Installing
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
# with pip
|
|
26
|
+
pip install t-sql
|
|
27
|
+
|
|
28
|
+
# with uv
|
|
29
|
+
uv add t-sql
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## using
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
import tsql
|
|
36
|
+
|
|
37
|
+
tsql.render(t"select * from users where name={name)")
|
|
38
|
+
```
|
|
21
39
|
|
|
22
40
|
## Parameter Styles
|
|
23
41
|
|
|
@@ -95,7 +113,7 @@ You can use the "unsafe" format spec for these
|
|
|
95
113
|
cases:
|
|
96
114
|
```python
|
|
97
115
|
dynamic_where = input('type where clause')
|
|
98
|
-
tsql.render(
|
|
116
|
+
tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
|
|
99
117
|
```
|
|
100
118
|
|
|
101
119
|
### as_values
|
|
@@ -103,6 +121,11 @@ tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
|
|
|
103
121
|
The spec `:as_values` formats a dictionary into the format:
|
|
104
122
|
`(key1, key2, ...) VALUES (value1, value2, ...)` for uses in insert statements.
|
|
105
123
|
|
|
124
|
+
### as_set
|
|
125
|
+
|
|
126
|
+
The spec `:as_set` formats a dictionary into the format:
|
|
127
|
+
`key1='?', key2='?'` for uses in update statements.
|
|
128
|
+
|
|
106
129
|
### traditional format_spec
|
|
107
130
|
|
|
108
131
|
All other format specs should be handled as they would in a normal f-string.
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: t-sql
|
|
3
|
-
Version: 0.1.2
|
|
4
|
-
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
|
-
Requires-Python: >=3.14
|
|
6
|
-
Description-Content-Type: text/markdown
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Dynamic: license-file
|
|
9
|
-
|
|
10
1
|
# tsql
|
|
11
2
|
|
|
12
3
|
A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
|
|
@@ -14,10 +5,27 @@ A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP
|
|
|
14
5
|
TSQL provides a safe way to write SQL queries using Python's template strings (t-strings) while preventing SQL injection attacks through multiple parameter styling options.
|
|
15
6
|
|
|
16
7
|
## ⚠️ Python Version Requirement
|
|
17
|
-
This library requires Python 3.
|
|
8
|
+
This library requires Python 3.14+
|
|
18
9
|
|
|
19
10
|
TSQL is built specifically to take advantage of the new t-string feature introduced in PEP 750, which is only available in Python 3.14+.
|
|
20
11
|
|
|
12
|
+
## Installing
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
# with pip
|
|
16
|
+
pip install t-sql
|
|
17
|
+
|
|
18
|
+
# with uv
|
|
19
|
+
uv add t-sql
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## using
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
import tsql
|
|
26
|
+
|
|
27
|
+
tsql.render(t"select * from users where name={name)")
|
|
28
|
+
```
|
|
21
29
|
|
|
22
30
|
## Parameter Styles
|
|
23
31
|
|
|
@@ -95,7 +103,7 @@ You can use the "unsafe" format spec for these
|
|
|
95
103
|
cases:
|
|
96
104
|
```python
|
|
97
105
|
dynamic_where = input('type where clause')
|
|
98
|
-
tsql.render(
|
|
106
|
+
tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
|
|
99
107
|
```
|
|
100
108
|
|
|
101
109
|
### as_values
|
|
@@ -103,6 +111,11 @@ tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
|
|
|
103
111
|
The spec `:as_values` formats a dictionary into the format:
|
|
104
112
|
`(key1, key2, ...) VALUES (value1, value2, ...)` for uses in insert statements.
|
|
105
113
|
|
|
114
|
+
### as_set
|
|
115
|
+
|
|
116
|
+
The spec `:as_set` formats a dictionary into the format:
|
|
117
|
+
`key1='?', key2='?'` for uses in update statements.
|
|
118
|
+
|
|
106
119
|
### traditional format_spec
|
|
107
120
|
|
|
108
121
|
All other format specs should be handled as they would in a normal f-string.
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "t-sql"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "1.0.0"
|
|
4
4
|
description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
7
7
|
dependencies = []
|
|
8
8
|
|
|
9
|
+
[project.urls]
|
|
10
|
+
Homepage = "https://github.com/nhumrich/tsql"
|
|
11
|
+
|
|
12
|
+
|
|
9
13
|
[dependency-groups]
|
|
10
14
|
dev = [
|
|
11
15
|
"anyio>=4.9.0",
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: t-sql
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
|
+
Project-URL: Homepage, https://github.com/nhumrich/tsql
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
1
11
|
# tsql
|
|
2
12
|
|
|
3
13
|
A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
|
|
@@ -5,10 +15,27 @@ A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP
|
|
|
5
15
|
TSQL provides a safe way to write SQL queries using Python's template strings (t-strings) while preventing SQL injection attacks through multiple parameter styling options.
|
|
6
16
|
|
|
7
17
|
## ⚠️ Python Version Requirement
|
|
8
|
-
This library requires Python 3.
|
|
18
|
+
This library requires Python 3.14+
|
|
9
19
|
|
|
10
20
|
TSQL is built specifically to take advantage of the new t-string feature introduced in PEP 750, which is only available in Python 3.14+.
|
|
11
21
|
|
|
22
|
+
## Installing
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
# with pip
|
|
26
|
+
pip install t-sql
|
|
27
|
+
|
|
28
|
+
# with uv
|
|
29
|
+
uv add t-sql
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## using
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
import tsql
|
|
36
|
+
|
|
37
|
+
tsql.render(t"select * from users where name={name)")
|
|
38
|
+
```
|
|
12
39
|
|
|
13
40
|
## Parameter Styles
|
|
14
41
|
|
|
@@ -86,7 +113,7 @@ You can use the "unsafe" format spec for these
|
|
|
86
113
|
cases:
|
|
87
114
|
```python
|
|
88
115
|
dynamic_where = input('type where clause')
|
|
89
|
-
tsql.render(
|
|
116
|
+
tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
|
|
90
117
|
```
|
|
91
118
|
|
|
92
119
|
### as_values
|
|
@@ -94,6 +121,11 @@ tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
|
|
|
94
121
|
The spec `:as_values` formats a dictionary into the format:
|
|
95
122
|
`(key1, key2, ...) VALUES (value1, value2, ...)` for uses in insert statements.
|
|
96
123
|
|
|
124
|
+
### as_set
|
|
125
|
+
|
|
126
|
+
The spec `:as_set` formats a dictionary into the format:
|
|
127
|
+
`key1='?', key2='?'` for uses in update statements.
|
|
128
|
+
|
|
97
129
|
### traditional format_spec
|
|
98
130
|
|
|
99
131
|
All other format specs should be handled as they would in a normal f-string.
|
|
@@ -148,4 +180,4 @@ def execute_sql_query(query):
|
|
|
148
180
|
|
|
149
181
|
return sql_engine.execute(*tsql.render(query))
|
|
150
182
|
|
|
151
|
-
```
|
|
183
|
+
```
|
|
@@ -8,7 +8,11 @@ t_sql.egg-info/top_level.txt
|
|
|
8
8
|
tests/test_asyncpg_integration.py
|
|
9
9
|
tests/test_different_object_types.py
|
|
10
10
|
tests/test_escaped.py
|
|
11
|
+
tests/test_escaped_binary_hex.py
|
|
11
12
|
tests/test_helper_functions.py
|
|
13
|
+
tests/test_injection_edge_cases.py
|
|
14
|
+
tests/test_injection_protection_validation.py
|
|
15
|
+
tests/test_injections_for_escaped.py
|
|
12
16
|
tests/test_styles.py
|
|
13
17
|
tests/test_tsql.py
|
|
14
18
|
tsql/__init__.py
|
|
@@ -6,12 +6,11 @@ import tsql.styles
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
# Test configuration
|
|
9
|
-
DATABASE_URL = "postgresql://postgres:password@localhost:
|
|
9
|
+
DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@pytest.fixture
|
|
13
13
|
async def conn():
|
|
14
|
-
"""Helper to set up a clean test table and return a connection"""
|
|
15
14
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
16
15
|
|
|
17
16
|
await conn.execute("""
|
|
@@ -32,10 +31,6 @@ async def conn():
|
|
|
32
31
|
|
|
33
32
|
|
|
34
33
|
async def test_escaped_style_with_postgres(conn):
|
|
35
|
-
"""Test ESCAPED style works correctly with PostgreSQL"""
|
|
36
|
-
|
|
37
|
-
# Insert data using ESCAPED style
|
|
38
|
-
|
|
39
34
|
values = dict(
|
|
40
35
|
name = "John O'Connor",
|
|
41
36
|
age = 30,
|
|
@@ -55,7 +50,6 @@ async def test_escaped_style_with_postgres(conn):
|
|
|
55
50
|
assert "TRUE" in query
|
|
56
51
|
assert "75000.5" in query
|
|
57
52
|
|
|
58
|
-
# Execute the query
|
|
59
53
|
await conn.execute(query)
|
|
60
54
|
|
|
61
55
|
# Verify data was inserted correctly
|
|
@@ -67,10 +61,6 @@ async def test_escaped_style_with_postgres(conn):
|
|
|
67
61
|
|
|
68
62
|
|
|
69
63
|
async def test_numeric_dollar_style_with_asyncpg(conn):
|
|
70
|
-
"""Test NUMERIC_DOLLAR style works correctly with PostgreSQL"""
|
|
71
|
-
|
|
72
|
-
# Insert data using ESCAPED style
|
|
73
|
-
|
|
74
64
|
values = dict(
|
|
75
65
|
name="John O'Connor",
|
|
76
66
|
age=30,
|
|
@@ -88,7 +78,6 @@ async def test_numeric_dollar_style_with_asyncpg(conn):
|
|
|
88
78
|
assert "$3" in query
|
|
89
79
|
assert "'$4" in query
|
|
90
80
|
|
|
91
|
-
# Execute the query
|
|
92
81
|
await conn.execute(query)
|
|
93
82
|
|
|
94
83
|
# Verify data was inserted correctly
|
|
@@ -100,8 +89,6 @@ async def test_numeric_dollar_style_with_asyncpg(conn):
|
|
|
100
89
|
|
|
101
90
|
|
|
102
91
|
async def test_escaped_prevents_sql_injection_in_db(conn):
|
|
103
|
-
"""Test that ESCAPED style prevents SQL injection in real database"""
|
|
104
|
-
|
|
105
92
|
# Attempt SQL injection
|
|
106
93
|
malicious_name = "'; DROP TABLE test_users; --"
|
|
107
94
|
age = 25
|
|
@@ -111,11 +98,9 @@ async def test_escaped_prevents_sql_injection_in_db(conn):
|
|
|
111
98
|
style=tsql.styles.ESCAPED
|
|
112
99
|
)
|
|
113
100
|
|
|
114
|
-
# Verify the malicious input is properly escaped
|
|
115
101
|
assert params == []
|
|
116
102
|
assert "'''; DROP TABLE test_users; --'" in query
|
|
117
103
|
|
|
118
|
-
# Execute the query - this should safely insert the malicious string as data
|
|
119
104
|
await conn.execute(query)
|
|
120
105
|
|
|
121
106
|
# Verify the table still exists and contains the escaped data
|
|
@@ -129,8 +114,6 @@ async def test_escaped_prevents_sql_injection_in_db(conn):
|
|
|
129
114
|
|
|
130
115
|
|
|
131
116
|
async def test_numeric_dollar_style_with_asyncpg(conn):
|
|
132
|
-
"""Test NUMERIC_DOLLAR style works natively with asyncpg"""
|
|
133
|
-
|
|
134
117
|
name = "David Wilson"
|
|
135
118
|
age = 33
|
|
136
119
|
|
|
@@ -143,7 +126,6 @@ async def test_numeric_dollar_style_with_asyncpg(conn):
|
|
|
143
126
|
assert "$1" in query and "$2" in query
|
|
144
127
|
assert params == ["David Wilson", 33]
|
|
145
128
|
|
|
146
|
-
# Execute directly (no conversion needed)
|
|
147
129
|
await conn.execute(query, *params)
|
|
148
130
|
|
|
149
131
|
# Verify data was inserted correctly
|
|
@@ -153,8 +135,6 @@ async def test_numeric_dollar_style_with_asyncpg(conn):
|
|
|
153
135
|
|
|
154
136
|
|
|
155
137
|
async def test_escaped_handles_null_values_in_db(conn):
|
|
156
|
-
"""Test ESCAPED style handles NULL values correctly in database"""
|
|
157
|
-
|
|
158
138
|
name = None
|
|
159
139
|
age = 30
|
|
160
140
|
|
|
@@ -167,17 +147,14 @@ async def test_escaped_handles_null_values_in_db(conn):
|
|
|
167
147
|
assert "NULL" in query
|
|
168
148
|
assert "30" in query
|
|
169
149
|
|
|
170
|
-
# Execute the query
|
|
171
150
|
await conn.execute(query)
|
|
172
151
|
|
|
173
|
-
# Verify NULL was inserted correctly
|
|
174
152
|
row = await conn.fetchrow("SELECT * FROM test_users WHERE age = $1", 30)
|
|
175
153
|
assert row['name'] is None
|
|
176
154
|
assert row['age'] == 30
|
|
177
155
|
|
|
178
156
|
|
|
179
157
|
async def test_escaped_complex_query_with_db(conn):
|
|
180
|
-
"""Test ESCAPED style with complex query involving multiple conditions"""
|
|
181
158
|
# Insert some test data first
|
|
182
159
|
await conn.execute("""
|
|
183
160
|
INSERT INTO test_users (name, age, active, salary) VALUES
|
|
@@ -201,7 +178,6 @@ async def test_escaped_complex_query_with_db(conn):
|
|
|
201
178
|
assert "'O''Brien'" in query # Single quote should be escaped
|
|
202
179
|
assert "TRUE" in query
|
|
203
180
|
|
|
204
|
-
# Execute the query
|
|
205
181
|
rows = await conn.fetch(query)
|
|
206
182
|
|
|
207
183
|
# Should find Charlie O'Brien
|
|
@@ -212,21 +188,16 @@ async def test_escaped_complex_query_with_db(conn):
|
|
|
212
188
|
|
|
213
189
|
|
|
214
190
|
async def test_compare_escaped_vs_parameterized(conn):
|
|
215
|
-
"""Compare ESCAPED vs parameterized query results"""
|
|
216
|
-
|
|
217
|
-
# Test data
|
|
218
191
|
name = "Test User"
|
|
219
192
|
age = 25
|
|
220
193
|
active = True
|
|
221
194
|
|
|
222
|
-
# Insert using ESCAPED style
|
|
223
195
|
query1, params1 = tsql.render(
|
|
224
196
|
t"INSERT INTO test_users (name, age, active) VALUES ({name}, {age}, {active})",
|
|
225
197
|
style=tsql.styles.ESCAPED
|
|
226
198
|
)
|
|
227
199
|
await conn.execute(query1)
|
|
228
200
|
|
|
229
|
-
# Insert using NUMERIC_DOLLAR style
|
|
230
201
|
query2, params2 = tsql.render(
|
|
231
202
|
t"INSERT INTO test_users (name, age, active) VALUES ({name}, {age}, {active})",
|
|
232
203
|
style=tsql.styles.NUMERIC_DOLLAR
|
|
@@ -245,16 +216,13 @@ async def test_compare_escaped_vs_parameterized(conn):
|
|
|
245
216
|
|
|
246
217
|
|
|
247
218
|
async def test_escaped_handles_union_attack(conn):
|
|
248
|
-
|
|
249
|
-
# malicious_input = "' UNION SELECT password FROM test_users WHERE '1'='1"
|
|
250
|
-
malicious_input = "11"
|
|
219
|
+
malicious_input = "' UNION SELECT password FROM test_users WHERE '1'='1"
|
|
251
220
|
query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
|
|
252
221
|
rows = await conn.fetch(query)
|
|
253
222
|
assert len(rows) == 0
|
|
254
223
|
|
|
255
224
|
|
|
256
225
|
async def test_escaped_handles_boolean_injection(conn):
|
|
257
|
-
"""Test prevention of boolean-based injection"""
|
|
258
226
|
malicious_input = "' OR '1'='1"
|
|
259
227
|
query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
|
|
260
228
|
rows = await conn.fetch(query)
|
|
@@ -262,7 +230,6 @@ async def test_escaped_handles_boolean_injection(conn):
|
|
|
262
230
|
|
|
263
231
|
|
|
264
232
|
async def test_escaped_handles_comment_injection(conn):
|
|
265
|
-
"""Test prevention of comment-based injection"""
|
|
266
233
|
malicious_input = "admin'--"
|
|
267
234
|
query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
|
|
268
235
|
rows = await conn.fetch(query)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test the hybrid ESCAPED style behavior with binary data
|
|
3
|
+
"""
|
|
4
|
+
import tsql
|
|
5
|
+
import tsql.styles
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_escaped_hex_with_mixed_types():
|
|
9
|
+
"""Test that ESCAPED style handles mixed data types correctly"""
|
|
10
|
+
|
|
11
|
+
# Mix of different data types including binary
|
|
12
|
+
filename = "test.bin"
|
|
13
|
+
size = 1024
|
|
14
|
+
active = True
|
|
15
|
+
binary_data = b"'; DROP TABLE files; --"
|
|
16
|
+
|
|
17
|
+
result = tsql.render(
|
|
18
|
+
t"INSERT INTO files (name, size, active, data) VALUES ({filename}, {size}, {active}, {binary_data})",
|
|
19
|
+
style=tsql.styles.ESCAPED
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Should escape strings, numbers, bools and convert binary to hex literal
|
|
23
|
+
expected_hex = binary_data.hex()
|
|
24
|
+
expected_query = f"INSERT INTO files (name, size, active, data) VALUES ('test.bin', 1024, TRUE, '\\x{expected_hex}')"
|
|
25
|
+
assert result[0] == expected_query
|
|
26
|
+
assert result[1] == [] # No parameters - fully escaped
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_escaped_hex_multiple_binary_values():
|
|
30
|
+
"""Test ESCAPED style with multiple binary values"""
|
|
31
|
+
|
|
32
|
+
binary1 = b"first'; DROP TABLE test; --"
|
|
33
|
+
binary2 = b"second'; DELETE FROM users; --"
|
|
34
|
+
name = "test"
|
|
35
|
+
|
|
36
|
+
result = tsql.render(
|
|
37
|
+
t"INSERT INTO files (name, data1, data2) VALUES ({name}, {binary1}, {binary2})",
|
|
38
|
+
style=tsql.styles.ESCAPED
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Should convert both binary values to hex literals
|
|
42
|
+
hex1 = binary1.hex()
|
|
43
|
+
hex2 = binary2.hex()
|
|
44
|
+
expected_query = f"INSERT INTO files (name, data1, data2) VALUES ('test', '\\x{hex1}', '\\x{hex2}')"
|
|
45
|
+
assert result[0] == expected_query
|
|
46
|
+
assert result[1] == [] # No parameters - fully escaped
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_escaped_hex_binary_with_quotes():
|
|
50
|
+
"""Test that binary data with quotes is safely converted to hex"""
|
|
51
|
+
|
|
52
|
+
# Binary data containing various injection attempts
|
|
53
|
+
malicious_binary = b"'; DELETE FROM users WHERE '1'='1'; --"
|
|
54
|
+
safe_string = "normal string"
|
|
55
|
+
|
|
56
|
+
result = tsql.render(
|
|
57
|
+
t"UPDATE files SET name = {safe_string}, data = {malicious_binary} WHERE id = 1",
|
|
58
|
+
style=tsql.styles.ESCAPED
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expected_hex = malicious_binary.hex()
|
|
62
|
+
expected_query = f"UPDATE files SET name = 'normal string', data = '\\x{expected_hex}' WHERE id = 1"
|
|
63
|
+
assert result[0] == expected_query
|
|
64
|
+
assert result[1] == [] # No parameters - fully escaped
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_escaped_all_string_no_hybrid():
|
|
68
|
+
"""Test that pure string queries remain fully escaped (no parameters)"""
|
|
69
|
+
|
|
70
|
+
name = "file.txt"
|
|
71
|
+
content = "some content"
|
|
72
|
+
|
|
73
|
+
result = tsql.render(
|
|
74
|
+
t"INSERT INTO files (name, content) VALUES ({name}, {content})",
|
|
75
|
+
style=tsql.styles.ESCAPED
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Should be fully escaped with no parameters
|
|
79
|
+
expected_query = "INSERT INTO files (name, content) VALUES ('file.txt', 'some content')"
|
|
80
|
+
assert result[0] == expected_query
|
|
81
|
+
assert result[1] == []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_escaped_string_injection_still_escaped():
|
|
85
|
+
"""Test that string injection attempts are still properly escaped"""
|
|
86
|
+
|
|
87
|
+
malicious_string = "'; DROP TABLE users; --"
|
|
88
|
+
|
|
89
|
+
result = tsql.render(
|
|
90
|
+
t"SELECT * FROM users WHERE name = {malicious_string}",
|
|
91
|
+
style=tsql.styles.ESCAPED
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# String should be escaped, no parameters
|
|
95
|
+
expected_query = "SELECT * FROM users WHERE name = '''; DROP TABLE users; --'"
|
|
96
|
+
assert result[0] == expected_query
|
|
97
|
+
assert result[1] == []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_escaped_binary_only_hex():
|
|
101
|
+
"""Test ESCAPED style with only binary data (pure hex literal)"""
|
|
102
|
+
|
|
103
|
+
binary_data = b"binary content with '; injection"
|
|
104
|
+
|
|
105
|
+
result = tsql.render(
|
|
106
|
+
t"INSERT INTO files (data) VALUES ({binary_data})",
|
|
107
|
+
style=tsql.styles.ESCAPED
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Should be fully escaped as hex literal
|
|
111
|
+
expected_hex = binary_data.hex()
|
|
112
|
+
expected_query = f"INSERT INTO files (data) VALUES ('\\x{expected_hex}')"
|
|
113
|
+
assert result[0] == expected_query
|
|
114
|
+
assert result[1] == [] # No parameters - fully escaped
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_escaped_empty_binary_data():
|
|
118
|
+
"""Test ESCAPED style with empty binary data"""
|
|
119
|
+
|
|
120
|
+
empty_binary = b""
|
|
121
|
+
name = "empty.bin"
|
|
122
|
+
|
|
123
|
+
result = tsql.render(
|
|
124
|
+
t"INSERT INTO files (name, data) VALUES ({name}, {empty_binary})",
|
|
125
|
+
style=tsql.styles.ESCAPED
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expected_query = "INSERT INTO files (name, data) VALUES ('empty.bin', '\\x')"
|
|
129
|
+
assert result[0] == expected_query
|
|
130
|
+
assert result[1] == [] # No parameters - fully escaped
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_escaped_binary_with_null_bytes():
|
|
134
|
+
"""Test ESCAPED style with binary data containing null bytes"""
|
|
135
|
+
|
|
136
|
+
binary_with_nulls = b"data\x00with\x00nulls\x00'; DROP TABLE test; --"
|
|
137
|
+
|
|
138
|
+
result = tsql.render(
|
|
139
|
+
t"INSERT INTO files (data) VALUES ({binary_with_nulls})",
|
|
140
|
+
style=tsql.styles.ESCAPED
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
expected_hex = binary_with_nulls.hex()
|
|
144
|
+
expected_query = f"INSERT INTO files (data) VALUES ('\\x{expected_hex}')"
|
|
145
|
+
assert result[0] == expected_query
|
|
146
|
+
assert result[1] == [] # No parameters - fully escaped
|
|
@@ -21,7 +21,7 @@ def test_as_values():
|
|
|
21
21
|
'age': 30
|
|
22
22
|
}
|
|
23
23
|
result = tsql.render(t"INSERT INTO users {values:as_values}")
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
# Should generate INSERT INTO users (name, age) VALUES (?, ?)
|
|
26
26
|
assert "INSERT INTO users" in result[0]
|
|
27
27
|
assert "name" in result[0] and "age" in result[0]
|
|
@@ -29,6 +29,22 @@ def test_as_values():
|
|
|
29
29
|
assert result[1] == ['John', 30]
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def test_as_set():
|
|
33
|
+
"""Test the as_set format specifier"""
|
|
34
|
+
values = {
|
|
35
|
+
'name': 'John Updated',
|
|
36
|
+
'age': 35
|
|
37
|
+
}
|
|
38
|
+
result = tsql.render(t"UPDATE users SET {values:as_set} WHERE id = {123}")
|
|
39
|
+
|
|
40
|
+
# Should generate UPDATE users SET name = ?, age = ? WHERE id = ?
|
|
41
|
+
assert "UPDATE users SET" in result[0]
|
|
42
|
+
assert "name = ?" in result[0]
|
|
43
|
+
assert "age = ?" in result[0]
|
|
44
|
+
assert "WHERE id = ?" in result[0]
|
|
45
|
+
assert result[1] == ['John Updated', 35, 123]
|
|
46
|
+
|
|
47
|
+
|
|
32
48
|
def test_insert():
|
|
33
49
|
"""Test the insert helper function"""
|
|
34
50
|
values = {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import tsql
|
|
3
|
+
import tsql.styles
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_literal_injection_prevention():
|
|
7
|
+
malicious_table = "users; DROP TABLE secrets; --"
|
|
8
|
+
|
|
9
|
+
with pytest.raises(ValueError):
|
|
10
|
+
tsql.render(t"SELECT * FROM {malicious_table:literal}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_literal_with_quotes_prevention():
|
|
14
|
+
malicious_column = "name'; DROP TABLE users; --"
|
|
15
|
+
|
|
16
|
+
with pytest.raises(ValueError):
|
|
17
|
+
tsql.render(t"SELECT {malicious_column:literal} FROM users")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_binary_data_injection():
|
|
21
|
+
"""Test handling of binary data that might contain SQL"""
|
|
22
|
+
# Binary data that contains SQL-like bytes
|
|
23
|
+
malicious_binary = b"'; DROP TABLE users; --"
|
|
24
|
+
|
|
25
|
+
# Test parameterized style (default)
|
|
26
|
+
result = tsql.render(t"INSERT INTO files (data) VALUES ({malicious_binary})")
|
|
27
|
+
assert result[0] == "INSERT INTO files (data) VALUES (?)"
|
|
28
|
+
# Binary data should be passed through as bytes, not converted to string
|
|
29
|
+
assert result[1] == [malicious_binary]
|
|
30
|
+
assert isinstance(result[1][0], bytes)
|
|
31
|
+
|
|
32
|
+
# Test ESCAPED style - should convert to safe hex literal (no parameters)
|
|
33
|
+
result_escaped = tsql.render(
|
|
34
|
+
t"INSERT INTO files (data) VALUES ({malicious_binary})",
|
|
35
|
+
style=tsql.styles.ESCAPED
|
|
36
|
+
)
|
|
37
|
+
expected_hex = malicious_binary.hex()
|
|
38
|
+
assert result_escaped[0] == f"INSERT INTO files (data) VALUES ('\\x{expected_hex}')"
|
|
39
|
+
assert result_escaped[1] == []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cross_parameter_injection():
|
|
43
|
+
"""Test prevention of injection across multiple parameters"""
|
|
44
|
+
param1 = "'; --"
|
|
45
|
+
param2 = "OR 1=1"
|
|
46
|
+
|
|
47
|
+
# Test parameterized style (default)
|
|
48
|
+
result = tsql.render(t"SELECT * FROM users WHERE name = {param1} AND role = {param2}")
|
|
49
|
+
assert result[0] == "SELECT * FROM users WHERE name = ? AND role = ?"
|
|
50
|
+
assert result[1] == [param1, param2]
|
|
51
|
+
|
|
52
|
+
# Test ESCAPED style
|
|
53
|
+
result_escaped = tsql.render(t"SELECT * FROM users WHERE name = {param1} AND role = {param2}", style=tsql.styles.ESCAPED)
|
|
54
|
+
expected_param1 = param1.replace("'", "''")
|
|
55
|
+
expected_param2 = param2.replace("'", "''")
|
|
56
|
+
assert result_escaped[0] == f"SELECT * FROM users WHERE name = '{expected_param1}' AND role = '{expected_param2}'"
|
|
57
|
+
assert result_escaped[1] == []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_nested_template_injection():
|
|
61
|
+
"""Test injection prevention in nested templates"""
|
|
62
|
+
malicious_value = "'; DROP TABLE users; --"
|
|
63
|
+
inner_template = t"WHERE id = {malicious_value}"
|
|
64
|
+
|
|
65
|
+
# Test parameterized style (default)
|
|
66
|
+
result = tsql.render(t"SELECT * FROM users {inner_template}")
|
|
67
|
+
assert result[0] == "SELECT * FROM users WHERE id = ?"
|
|
68
|
+
assert result[1] == [malicious_value]
|
|
69
|
+
|
|
70
|
+
# Test ESCAPED style
|
|
71
|
+
result_escaped = tsql.render(t"SELECT * FROM users {inner_template}", style=tsql.styles.ESCAPED)
|
|
72
|
+
expected_escaped = malicious_value.replace("'", "''")
|
|
73
|
+
assert result_escaped[0] == f"SELECT * FROM users WHERE id = '{expected_escaped}'"
|
|
74
|
+
assert result_escaped[1] == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_helper_function_injection():
|
|
78
|
+
"""Test injection prevention in helper functions"""
|
|
79
|
+
malicious_table = "users; DROP TABLE secrets; --"
|
|
80
|
+
malicious_id = "1; DELETE FROM users; --"
|
|
81
|
+
|
|
82
|
+
# This should raise ValueError for malicious table name
|
|
83
|
+
with pytest.raises(ValueError):
|
|
84
|
+
tsql.select(malicious_table, malicious_id)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_large_payload_injection():
|
|
88
|
+
"""Test handling of very large malicious payloads"""
|
|
89
|
+
large_payload = "'; " + "DROP TABLE users; " * 1000 + "--"
|
|
90
|
+
|
|
91
|
+
# Test parameterized style (default)
|
|
92
|
+
result = tsql.render(t"SELECT * FROM users WHERE name = {large_payload}")
|
|
93
|
+
assert result[0] == "SELECT * FROM users WHERE name = ?"
|
|
94
|
+
assert result[1] == [large_payload]
|
|
95
|
+
|
|
96
|
+
# Test ESCAPED style
|
|
97
|
+
result_escaped = tsql.render(t"SELECT * FROM users WHERE name = {large_payload}", style=tsql.styles.ESCAPED)
|
|
98
|
+
expected_escaped = large_payload.replace("'", "''")
|
|
99
|
+
assert result_escaped[0] == f"SELECT * FROM users WHERE name = '{expected_escaped}'"
|
|
100
|
+
assert result_escaped[1] == []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_encoding_bypass_attempts():
|
|
104
|
+
"""Test prevention of encoding-based bypass attempts"""
|
|
105
|
+
encoding_attacks = [
|
|
106
|
+
"'; DROP TABLE users; --", # Normal
|
|
107
|
+
"\'; DROP TABLE users; --", # Backslash escaped
|
|
108
|
+
"\\'; DROP TABLE users; --", # Double backslash
|
|
109
|
+
"%27; DROP TABLE users; --", # URL encoded single quote
|
|
110
|
+
"' DROP TABLE users; --" # HTML entity
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
for attack in encoding_attacks:
|
|
114
|
+
# Test parameterized style (default)
|
|
115
|
+
result = tsql.render(t"SELECT * FROM users WHERE name = {attack}")
|
|
116
|
+
assert result[0] == "SELECT * FROM users WHERE name = ?"
|
|
117
|
+
assert result[1] == [attack]
|
|
118
|
+
|
|
119
|
+
# Test ESCAPED style
|
|
120
|
+
result_escaped = tsql.render(t"SELECT * FROM users WHERE name = {attack}", style=tsql.styles.ESCAPED)
|
|
121
|
+
expected_escaped = attack.replace("'", "''")
|
|
122
|
+
assert result_escaped[0] == f"SELECT * FROM users WHERE name = '{expected_escaped}'"
|
|
123
|
+
assert result_escaped[1] == []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_database_specific_functions():
|
|
127
|
+
"""Test prevention of database-specific function injection"""
|
|
128
|
+
db_specific_attacks = [
|
|
129
|
+
"'; SELECT sqlite_version(); --", # SQLite
|
|
130
|
+
"'; SELECT @@version; --", # SQL Server/MySQL
|
|
131
|
+
"'; SELECT version(); --", # PostgreSQL
|
|
132
|
+
"'; SELECT banner FROM v$version; --", # Oracle
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
for attack in db_specific_attacks:
|
|
136
|
+
# Test parameterized style (default)
|
|
137
|
+
result = tsql.render(t"SELECT * FROM users WHERE id = {attack}")
|
|
138
|
+
assert result[0] == "SELECT * FROM users WHERE id = ?"
|
|
139
|
+
assert result[1] == [attack]
|
|
140
|
+
|
|
141
|
+
# Test ESCAPED style
|
|
142
|
+
result_escaped = tsql.render(t"SELECT * FROM users WHERE id = {attack}", style=tsql.styles.ESCAPED)
|
|
143
|
+
expected_escaped = attack.replace("'", "''")
|
|
144
|
+
assert result_escaped[0] == f"SELECT * FROM users WHERE id = '{expected_escaped}'"
|
|
145
|
+
assert result_escaped[1] == []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_unsafe_parameter_injection():
|
|
149
|
+
"""Test that :unsafe parameters are properly handled (if implemented)"""
|
|
150
|
+
# Note: This assumes :unsafe is implemented - remove if not
|
|
151
|
+
malicious_value = "'; DROP TABLE users; --"
|
|
152
|
+
|
|
153
|
+
# :unsafe should allow the value through without parameterization
|
|
154
|
+
# but this is intentionally dangerous and should be used carefully
|
|
155
|
+
result = tsql.render(t"SELECT * FROM users WHERE debug = {malicious_value:unsafe}")
|
|
156
|
+
# This will depend on your :unsafe implementation
|
|
157
|
+
assert "DROP TABLE users" in result[0] # Should be directly embedded
|
|
158
|
+
|
|
159
|
+
# Test ESCAPED style with :unsafe (should behave the same)
|
|
160
|
+
result_escaped = tsql.render(t"SELECT * FROM users WHERE debug = {malicious_value:unsafe}", style=tsql.styles.ESCAPED)
|
|
161
|
+
assert "DROP TABLE users" in result_escaped[0] # Should be directly embedded
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import asyncpg
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
import tsql
|
|
5
|
+
import tsql.styles
|
|
6
|
+
|
|
7
|
+
# Test configuration
|
|
8
|
+
DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
async def conn():
|
|
13
|
+
"""Helper to set up a clean test table and return a connection"""
|
|
14
|
+
conn = await asyncpg.connect(DATABASE_URL)
|
|
15
|
+
|
|
16
|
+
# Create a test table with sensitive data
|
|
17
|
+
await conn.execute("""
|
|
18
|
+
CREATE TABLE IF NOT EXISTS test_users (
|
|
19
|
+
id SERIAL PRIMARY KEY,
|
|
20
|
+
name VARCHAR(100),
|
|
21
|
+
password VARCHAR(100),
|
|
22
|
+
role VARCHAR(50),
|
|
23
|
+
salary DECIMAL(10,2),
|
|
24
|
+
active BOOLEAN DEFAULT TRUE
|
|
25
|
+
)
|
|
26
|
+
""")
|
|
27
|
+
|
|
28
|
+
# Create a sensitive admin table
|
|
29
|
+
await conn.execute("""
|
|
30
|
+
CREATE TABLE IF NOT EXISTS admin_secrets (
|
|
31
|
+
id SERIAL PRIMARY KEY,
|
|
32
|
+
secret_key VARCHAR(255),
|
|
33
|
+
admin_password VARCHAR(100)
|
|
34
|
+
)
|
|
35
|
+
""")
|
|
36
|
+
|
|
37
|
+
# Clean up existing data
|
|
38
|
+
await conn.execute("DELETE FROM test_users")
|
|
39
|
+
await conn.execute("DELETE FROM admin_secrets")
|
|
40
|
+
|
|
41
|
+
# Insert some test data
|
|
42
|
+
await conn.execute("""
|
|
43
|
+
INSERT INTO test_users (name, password, role, salary) VALUES
|
|
44
|
+
('alice', 'secret123', 'user', 50000),
|
|
45
|
+
('bob', 'password456', 'user', 60000),
|
|
46
|
+
('admin', 'supersecret', 'admin', 100000)
|
|
47
|
+
""")
|
|
48
|
+
|
|
49
|
+
await conn.execute("""
|
|
50
|
+
INSERT INTO admin_secrets (secret_key, admin_password) VALUES
|
|
51
|
+
('TOP_SECRET_KEY_123', 'admin_master_password')
|
|
52
|
+
""")
|
|
53
|
+
|
|
54
|
+
yield conn
|
|
55
|
+
|
|
56
|
+
await conn.execute("DROP TABLE IF EXISTS test_users")
|
|
57
|
+
await conn.execute("DROP TABLE IF EXISTS admin_secrets")
|
|
58
|
+
await conn.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def test_parameterized_injection_protection(conn):
|
|
62
|
+
"""Verify parameterized queries properly protect against injection"""
|
|
63
|
+
|
|
64
|
+
# Classic injection attempts
|
|
65
|
+
injection_attempts = [
|
|
66
|
+
"' OR 1=1 --",
|
|
67
|
+
"'; DROP TABLE test_users; --",
|
|
68
|
+
"' UNION SELECT secret_key, admin_password, 'hacker', 0, true FROM admin_secrets --",
|
|
69
|
+
"admin'--"
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
for malicious_input in injection_attempts:
|
|
73
|
+
query, params = tsql.render(
|
|
74
|
+
t"SELECT * FROM test_users WHERE name = {malicious_input}",
|
|
75
|
+
style=tsql.styles.NUMERIC_DOLLAR # Use asyncpg-compatible style
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Should be parameterized with $1 style
|
|
79
|
+
assert "$1" in query
|
|
80
|
+
assert params == [malicious_input]
|
|
81
|
+
|
|
82
|
+
# Execute the parameterized query - should safely find no matching users
|
|
83
|
+
rows = await conn.fetch(query, *params)
|
|
84
|
+
assert len(rows) == 0
|
|
85
|
+
|
|
86
|
+
# Verify database integrity - original data should be intact
|
|
87
|
+
user_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
|
|
88
|
+
admin_count = await conn.fetchval("SELECT COUNT(*) FROM admin_secrets")
|
|
89
|
+
assert user_count == 3 # Original test users
|
|
90
|
+
assert admin_count == 1 # Original admin secret
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_escaped_injection_protection(conn):
|
|
94
|
+
"""Verify ESCAPED style properly protects against injection"""
|
|
95
|
+
|
|
96
|
+
injection_attempts = [
|
|
97
|
+
"' OR 1=1 --",
|
|
98
|
+
"'; DROP TABLE test_users; --",
|
|
99
|
+
"' UNION SELECT secret_key FROM admin_secrets --",
|
|
100
|
+
"admin'--"
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
for malicious_input in injection_attempts:
|
|
104
|
+
query, params = tsql.render(
|
|
105
|
+
t"SELECT * FROM test_users WHERE name = {malicious_input}",
|
|
106
|
+
style=tsql.styles.ESCAPED
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Should be escaped (no parameters)
|
|
110
|
+
assert params == []
|
|
111
|
+
escaped_input = malicious_input.replace("'", "''")
|
|
112
|
+
assert f"'{escaped_input}'" in query
|
|
113
|
+
|
|
114
|
+
# Execute the escaped query - should safely find no matching users
|
|
115
|
+
rows = await conn.fetch(query)
|
|
116
|
+
assert len(rows) == 0
|
|
117
|
+
|
|
118
|
+
# Verify database integrity
|
|
119
|
+
user_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
|
|
120
|
+
admin_count = await conn.fetchval("SELECT COUNT(*) FROM admin_secrets")
|
|
121
|
+
assert user_count == 3
|
|
122
|
+
assert admin_count == 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_injection_treated_as_literal_data(conn):
|
|
126
|
+
"""Verify injection attempts are treated as literal data, not SQL code"""
|
|
127
|
+
|
|
128
|
+
# Insert the malicious input as literal data
|
|
129
|
+
malicious_name = "'; DROP TABLE test_users; --"
|
|
130
|
+
|
|
131
|
+
# First, insert this as actual data using parameterized query
|
|
132
|
+
insert_query, insert_params = tsql.render(
|
|
133
|
+
t"INSERT INTO test_users (name, role) VALUES ({malicious_name}, 'test')",
|
|
134
|
+
style=tsql.styles.NUMERIC_DOLLAR
|
|
135
|
+
)
|
|
136
|
+
await conn.execute(insert_query, *insert_params)
|
|
137
|
+
|
|
138
|
+
# Now search for it using both styles
|
|
139
|
+
for style in [tsql.styles.NUMERIC_DOLLAR, tsql.styles.ESCAPED]:
|
|
140
|
+
query, params = tsql.render(
|
|
141
|
+
t"SELECT * FROM test_users WHERE name = {malicious_name}",
|
|
142
|
+
style=style
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
rows = await conn.fetch(query, *params if params else [])
|
|
146
|
+
|
|
147
|
+
# Should find exactly 1 row with the malicious string as literal data
|
|
148
|
+
assert len(rows) == 1
|
|
149
|
+
assert rows[0]['name'] == malicious_name
|
|
150
|
+
assert rows[0]['role'] == 'test'
|
|
151
|
+
|
|
152
|
+
# Verify the table wasn't dropped by the "injection"
|
|
153
|
+
user_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
|
|
154
|
+
assert user_count == 4 # 3 original + 1 with malicious name
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def test_boolean_bypass_prevention(conn):
|
|
158
|
+
"""Verify OR 1=1 type attacks don't bypass authentication"""
|
|
159
|
+
|
|
160
|
+
# Simulate authentication bypass attempt
|
|
161
|
+
username = "nonexistent"
|
|
162
|
+
malicious_password = "' OR 1=1 --"
|
|
163
|
+
|
|
164
|
+
for style in [tsql.styles.NUMERIC_DOLLAR, tsql.styles.ESCAPED]:
|
|
165
|
+
query, params = tsql.render(
|
|
166
|
+
t"SELECT * FROM test_users WHERE name = {username} AND password = {malicious_password}",
|
|
167
|
+
style=style
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
rows = await conn.fetch(query, *params if params else [])
|
|
171
|
+
|
|
172
|
+
# Should return 0 rows - authentication should fail
|
|
173
|
+
assert len(rows) == 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def test_admin_escalation_prevention(conn):
|
|
177
|
+
"""Verify injection can't escalate privileges to admin"""
|
|
178
|
+
|
|
179
|
+
malicious_inputs = [
|
|
180
|
+
"' OR role = 'admin' --",
|
|
181
|
+
"' UNION SELECT * FROM test_users WHERE role = 'admin' --",
|
|
182
|
+
"anything' OR 1=1 --"
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
for malicious_input in malicious_inputs:
|
|
186
|
+
for style in [tsql.styles.NUMERIC_DOLLAR, tsql.styles.ESCAPED]:
|
|
187
|
+
query, params = tsql.render(
|
|
188
|
+
t"SELECT * FROM test_users WHERE name = {malicious_input} AND role = 'user'",
|
|
189
|
+
style=style
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
rows = await conn.fetch(query, *params if params else [])
|
|
193
|
+
|
|
194
|
+
# Should not return admin users
|
|
195
|
+
for row in rows:
|
|
196
|
+
assert row.get('role') != 'admin'
|
|
197
|
+
assert row.get('name') != 'admin'
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def test_literal_parameter_protection(conn):
|
|
201
|
+
"""Verify :literal parameters reject malicious table/column names"""
|
|
202
|
+
|
|
203
|
+
malicious_table_names = [
|
|
204
|
+
"test_users; DROP TABLE admin_secrets; --",
|
|
205
|
+
"test_users UNION SELECT * FROM admin_secrets --",
|
|
206
|
+
"test_users' OR 1=1 --"
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
for malicious_table in malicious_table_names:
|
|
210
|
+
# Should raise ValueError before reaching database
|
|
211
|
+
with pytest.raises(ValueError):
|
|
212
|
+
tsql.render(t"SELECT * FROM {malicious_table:literal}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def test_stacked_query_prevention(conn):
|
|
216
|
+
"""Verify stacked queries can't execute additional commands"""
|
|
217
|
+
|
|
218
|
+
malicious_input = "'; INSERT INTO test_users (name, role) VALUES ('hacker', 'admin'); --"
|
|
219
|
+
|
|
220
|
+
original_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
|
|
221
|
+
|
|
222
|
+
for style in [tsql.styles.NUMERIC_DOLLAR, tsql.styles.ESCAPED]:
|
|
223
|
+
query, params = tsql.render(
|
|
224
|
+
t"SELECT * FROM test_users WHERE name = {malicious_input}",
|
|
225
|
+
style=style
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
rows = await conn.fetch(query, *params if params else [])
|
|
229
|
+
assert len(rows) == 0
|
|
230
|
+
|
|
231
|
+
# Verify no hacker user was inserted
|
|
232
|
+
final_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
|
|
233
|
+
assert final_count == original_count
|
|
234
|
+
|
|
235
|
+
# Explicitly check no hacker exists
|
|
236
|
+
hacker_count = await conn.fetchval("SELECT COUNT(*) FROM test_users WHERE name = 'hacker'")
|
|
237
|
+
assert hacker_count == 0
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proof-of-concept test demonstrating that tsql actually prevents SQL injection attacks
|
|
3
|
+
against a real PostgreSQL database.
|
|
4
|
+
"""
|
|
5
|
+
import asyncpg
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import tsql
|
|
9
|
+
import tsql.styles
|
|
10
|
+
|
|
11
|
+
DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
async def conn():
|
|
16
|
+
"""Set up test database with sensitive data"""
|
|
17
|
+
conn = await asyncpg.connect(DATABASE_URL)
|
|
18
|
+
|
|
19
|
+
await conn.execute("""
|
|
20
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
21
|
+
id SERIAL PRIMARY KEY,
|
|
22
|
+
username VARCHAR(50),
|
|
23
|
+
password VARCHAR(100),
|
|
24
|
+
is_admin BOOLEAN DEFAULT FALSE
|
|
25
|
+
)
|
|
26
|
+
""")
|
|
27
|
+
|
|
28
|
+
await conn.execute("""
|
|
29
|
+
CREATE TABLE IF NOT EXISTS secrets (
|
|
30
|
+
id SERIAL PRIMARY KEY,
|
|
31
|
+
secret_data VARCHAR(255)
|
|
32
|
+
)
|
|
33
|
+
""")
|
|
34
|
+
|
|
35
|
+
# Clean up previous data if exsist
|
|
36
|
+
await conn.execute("DELETE FROM users")
|
|
37
|
+
await conn.execute("DELETE FROM secrets")
|
|
38
|
+
|
|
39
|
+
# Insert test data
|
|
40
|
+
await conn.execute("""
|
|
41
|
+
INSERT INTO users (username, password, is_admin) VALUES
|
|
42
|
+
('alice', 'password123', false),
|
|
43
|
+
('admin', 'supersecret', true)
|
|
44
|
+
""")
|
|
45
|
+
|
|
46
|
+
await conn.execute("""
|
|
47
|
+
INSERT INTO secrets (secret_data) VALUES
|
|
48
|
+
('TOP_SECRET_ADMIN_KEY')
|
|
49
|
+
""")
|
|
50
|
+
|
|
51
|
+
yield conn
|
|
52
|
+
|
|
53
|
+
# Cleanup
|
|
54
|
+
await conn.execute("DROP TABLE IF EXISTS users")
|
|
55
|
+
await conn.execute("DROP TABLE IF EXISTS secrets")
|
|
56
|
+
await conn.close()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def test_basic_injection_attack_with_real_database(conn):
|
|
60
|
+
malicious_username = "' OR 1=1 --"
|
|
61
|
+
fake_password = "anything"
|
|
62
|
+
|
|
63
|
+
query, params = tsql.render(
|
|
64
|
+
t"SELECT * FROM users WHERE username = {malicious_username} AND password = {fake_password}",
|
|
65
|
+
style=tsql.styles.ESCAPED
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
rows = await conn.fetch(query)
|
|
69
|
+
|
|
70
|
+
assert len(rows) == 0
|
|
71
|
+
|
|
72
|
+
async def test_union_injection_attack_with_real_database(conn):
|
|
73
|
+
malicious_username = "' UNION SELECT id, secret_data, 'fake', true FROM secrets --"
|
|
74
|
+
query, params = tsql.render(
|
|
75
|
+
t"SELECT * FROM users WHERE username = {malicious_username}",
|
|
76
|
+
style=tsql.styles.ESCAPED
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
rows = await conn.fetch(query)
|
|
80
|
+
|
|
81
|
+
assert len(rows) == 0
|
|
82
|
+
|
|
83
|
+
async def test_stacked_injection_attack_with_real_database(conn):
|
|
84
|
+
malicious_username = "'; DROP TABLE secrets; --"
|
|
85
|
+
|
|
86
|
+
query, params = tsql.render(
|
|
87
|
+
t"SELECT * FROM users WHERE username = {malicious_username}",
|
|
88
|
+
style=tsql.styles.ESCAPED
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
rows = await conn.fetch(query)
|
|
92
|
+
assert len(rows) == 0
|
|
93
|
+
|
|
94
|
+
secret_count = await conn.fetchval("SELECT COUNT(*) FROM secrets")
|
|
95
|
+
assert secret_count == 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_legitimate_usage_still_works(conn):
|
|
99
|
+
username = "alice"
|
|
100
|
+
|
|
101
|
+
query, params = tsql.render(
|
|
102
|
+
t"SELECT * FROM users WHERE username = {username}",
|
|
103
|
+
style=tsql.styles.ESCAPED
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
rows = await conn.fetch(query)
|
|
107
|
+
|
|
108
|
+
assert len(rows) == 1
|
|
109
|
+
assert rows[0]['username'] == 'alice'
|
|
110
|
+
assert rows[0]['is_admin'] is False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_malicious_data_stored_safely(conn):
|
|
114
|
+
"""Verify malicious strings can be safely stored and retrieved as data"""
|
|
115
|
+
|
|
116
|
+
# Store what looks like SQL injection as actual data
|
|
117
|
+
malicious_data = "'; DROP TABLE users; --"
|
|
118
|
+
|
|
119
|
+
# Insert it safely
|
|
120
|
+
query, params = tsql.render(
|
|
121
|
+
t"INSERT INTO users (username, password, is_admin) VALUES ({malicious_data}, 'test123', false)",
|
|
122
|
+
style=tsql.styles.ESCAPED
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await conn.execute(query)
|
|
126
|
+
|
|
127
|
+
# Retrieve it safely
|
|
128
|
+
query, params = tsql.render(
|
|
129
|
+
t"SELECT * FROM users WHERE username = {malicious_data}",
|
|
130
|
+
style=tsql.styles.ESCAPED
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
rows = await conn.fetch(query)
|
|
134
|
+
|
|
135
|
+
assert len(rows) == 1
|
|
136
|
+
assert rows[0]['username'] == malicious_data
|
|
137
|
+
|
|
138
|
+
# Verify the database wasn't compromised
|
|
139
|
+
user_count = await conn.fetchval("SELECT COUNT(*) FROM users")
|
|
140
|
+
assert user_count == 3 # alice + admin + our malicious_data user
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def test_error_based_injection_patterns():
|
|
145
|
+
error_attacks = [
|
|
146
|
+
"' AND (SELECT TOP 1 name FROM sysobjects WHERE xtype='U') > 0 --",
|
|
147
|
+
"' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()), 0x7e)) --",
|
|
148
|
+
"' AND (SELECT * FROM (SELECT COUNT(*),CONCAT(version(),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)a) --"
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
for attack in error_attacks:
|
|
152
|
+
# Test parameterized style (default)
|
|
153
|
+
query, params = tsql.render(t"SELECT * FROM users WHERE id = {attack}", s)
|
|
154
|
+
|
|
155
|
+
rows = await conn.fetch(query)
|
|
156
|
+
assert result[0] == "SELECT * FROM users WHERE id = ?"
|
|
157
|
+
assert result[1] == [attack]
|
|
158
|
+
|
|
159
|
+
# Test ESCAPED style
|
|
160
|
+
result_escaped = tsql.render(t"SELECT * FROM users WHERE id = {attack}", style=tsql.styles.ESCAPED)
|
|
161
|
+
expected_escaped = attack.replace("'", "''")
|
|
162
|
+
assert result_escaped[0] == f"SELECT * FROM users WHERE id = '{expected_escaped}'"
|
|
163
|
+
assert result_escaped[1] == []
|
|
@@ -44,7 +44,6 @@ class TSQL:
|
|
|
44
44
|
self._sql_parts = self._sqlize(template_string)
|
|
45
45
|
|
|
46
46
|
def render(self, style:ParamStyle = None) -> RenderedQuery:
|
|
47
|
-
print(self._sql_parts)
|
|
48
47
|
if style is None:
|
|
49
48
|
style = default_style
|
|
50
49
|
result = ''
|
|
@@ -86,7 +85,6 @@ class TSQL:
|
|
|
86
85
|
if val.conversion:
|
|
87
86
|
value = formatter.convert_field(value, val.conversion)
|
|
88
87
|
|
|
89
|
-
print('i', val.format_spec, value, type(value))
|
|
90
88
|
match val.format_spec, value:
|
|
91
89
|
case 'literal', str():
|
|
92
90
|
cls._check_literal(value)
|
|
@@ -95,6 +93,8 @@ class TSQL:
|
|
|
95
93
|
return [value]
|
|
96
94
|
case 'as_values', dict():
|
|
97
95
|
return as_values(value)._sql_parts
|
|
96
|
+
case 'as_set', dict():
|
|
97
|
+
return as_set(value)._sql_parts
|
|
98
98
|
case '', TSQL():
|
|
99
99
|
return val.value._sql_parts
|
|
100
100
|
case "", Template():
|
|
@@ -113,6 +113,8 @@ class TSQL:
|
|
|
113
113
|
return inner
|
|
114
114
|
case _, str():
|
|
115
115
|
return [Parameter(val.expression, formatter.format_field(value, val.format_spec))]
|
|
116
|
+
case _, bytes():
|
|
117
|
+
return [Parameter(val.expression, value)]
|
|
116
118
|
case _, int():
|
|
117
119
|
return [Parameter(val.value, val.value)]
|
|
118
120
|
|
|
@@ -120,7 +122,6 @@ class TSQL:
|
|
|
120
122
|
return [Parameter(val.expression, formatter.format_field(value, val.format_spec))]
|
|
121
123
|
|
|
122
124
|
if isinstance(val, Template):
|
|
123
|
-
print('t', val)
|
|
124
125
|
result = []
|
|
125
126
|
for item in val:
|
|
126
127
|
if isinstance(item, Interpolation):
|
|
@@ -147,7 +148,7 @@ def as_values(value_dict: dict[str, Any]):
|
|
|
147
148
|
"""Convert a dictionary to SQL column list and VALUES clause"""
|
|
148
149
|
keys = list(value_dict.keys())
|
|
149
150
|
values = list(value_dict.values())
|
|
150
|
-
|
|
151
|
+
|
|
151
152
|
# Build column list: (col1, col2, col3)
|
|
152
153
|
column_parts = ['(']
|
|
153
154
|
for i, key in enumerate(keys):
|
|
@@ -155,7 +156,7 @@ def as_values(value_dict: dict[str, Any]):
|
|
|
155
156
|
column_parts.append(', ')
|
|
156
157
|
column_parts.append(key)
|
|
157
158
|
column_parts.append(')')
|
|
158
|
-
|
|
159
|
+
|
|
159
160
|
# Build values list: (?, ?, ?)
|
|
160
161
|
value_parts = [' VALUES (']
|
|
161
162
|
for i, value in enumerate(values):
|
|
@@ -163,13 +164,33 @@ def as_values(value_dict: dict[str, Any]):
|
|
|
163
164
|
value_parts.append(', ')
|
|
164
165
|
value_parts.append(Parameter(f'value_{i}', value))
|
|
165
166
|
value_parts.append(')')
|
|
166
|
-
|
|
167
|
+
|
|
167
168
|
# Create TSQL object manually
|
|
168
169
|
tsql_obj = TSQL.__new__(TSQL)
|
|
169
170
|
tsql_obj._sql_parts = column_parts + value_parts
|
|
170
171
|
return tsql_obj
|
|
171
172
|
|
|
172
173
|
|
|
174
|
+
def as_set(value_dict: dict[str, Any]):
|
|
175
|
+
"""Convert a dictionary to SQL SET clause for UPDATE statements"""
|
|
176
|
+
keys = list(value_dict.keys())
|
|
177
|
+
values = list(value_dict.values())
|
|
178
|
+
|
|
179
|
+
# Build SET clause: col1 = ?, col2 = ?, col3 = ?
|
|
180
|
+
set_parts = []
|
|
181
|
+
for i, (key, value) in enumerate(zip(keys, values)):
|
|
182
|
+
if i > 0:
|
|
183
|
+
set_parts.append(', ')
|
|
184
|
+
set_parts.append(key)
|
|
185
|
+
set_parts.append(' = ')
|
|
186
|
+
set_parts.append(Parameter(f'value_{i}', value))
|
|
187
|
+
|
|
188
|
+
# Create TSQL object manually
|
|
189
|
+
tsql_obj = TSQL.__new__(TSQL)
|
|
190
|
+
tsql_obj._sql_parts = set_parts
|
|
191
|
+
return tsql_obj
|
|
192
|
+
|
|
193
|
+
|
|
173
194
|
def render(query: Template|TSQL, style=None) -> RenderedQuery:
|
|
174
195
|
if not isinstance(query, TSQL):
|
|
175
196
|
query = TSQL(query)
|
|
@@ -219,5 +240,5 @@ def update(table: str, values: dict[str, Any], id: str):
|
|
|
219
240
|
if not isinstance(values, dict):
|
|
220
241
|
raise ValueError("values must be a dictionary")
|
|
221
242
|
|
|
222
|
-
return TSQL(t"UPDATE {table:literal} SET {values:
|
|
243
|
+
return TSQL(t"UPDATE {table:literal} SET {values:as_set} WHERE id = {id} RETURNING *")
|
|
223
244
|
|
|
@@ -85,6 +85,10 @@ class ESCAPED(ParamStyle):
|
|
|
85
85
|
return "TRUE" if value else "FALSE"
|
|
86
86
|
case int() | float():
|
|
87
87
|
return str(value)
|
|
88
|
+
case bytes():
|
|
89
|
+
# Convert binary data to hex literal - safe from injection since hex only contains [0-9A-F]
|
|
90
|
+
hex_data = value.hex()
|
|
91
|
+
return f"'\\x{hex_data}'"
|
|
88
92
|
case _:
|
|
89
93
|
# For other types, convert to string and escape
|
|
90
94
|
return f"'{str(value).replace("'", "''")}'"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|