Repository navigation
Expand file tree
/
Copy pathSqlIdentifierTest.java
More file actions
174 lines (151 loc) · 7.44 KB
/
Copy pathSqlIdentifierTest.java
File metadata and controls
174 lines (151 loc) · 7.44 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
package com.dbaagent.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Identifier quoting for the SQL that cannot be parameterised.
*
* <p>Table and column names cannot be bind parameters, so they are interpolated into SQL by
* hand. {@code CardinalityEstimationService.quoteIdentifier} wrapped them in quotes and never
* doubled an embedded one, which is no protection at all — the same way
* {@code "'" + value + "'"} is no protection for a string literal.
*
* <p>Verified against a real PostgreSQL, not inferred. A {@code tableName} path variable of
* {@code victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim} produced
*
* <pre>SELECT COUNT(*) FROM zz_inj."victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim"</pre>
*
* which ran with no errors: the count returned, the table was dropped (it existed before and
* did not after), and the trailing select returned its rows. The payload carries no {@code /},
* so Spring's {@code StrictHttpFirewall} does not stand in its way.
*/
class SqlIdentifierTest {
// ── the injection that was proven to execute ──────────────────────────────
@Test
void doublesAnEmbeddedDoubleQuoteSoTheIdentifierCannotBeClosed() {
String payload = "victim\" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj.\"victim";
String quoted = SqlIdentifier.quote(payload, "postgres");
assertEquals(
"\"victim\"\" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj.\"\"victim\"",
quoted);
// the whole payload is now one identifier: no unescaped quote can terminate it
assertEquals(1, countUnescapedQuotes(quoted, '"'),
"an unescaped quote inside the body would end the identifier early: " + quoted);
}
@Test
void doublesAnEmbeddedBacktickForMysql() {
String payload = "victim` ; DROP TABLE probe; SELECT 1 FROM `victim";
String quoted = SqlIdentifier.quote(payload, "mysql");
assertEquals("`victim`` ; DROP TABLE probe; SELECT 1 FROM ``victim`", quoted);
assertEquals(1, countUnescapedQuotes(quoted, '`'), quoted);
}
/**
* The sinks build {@code SELECT COUNT(*) FROM %s}, so the property that matters is that the
* whole payload lands inside one identifier rather than becoming a second statement.
*
* <p>Asserted on the parse, not on the text. A first version of this test used the regex
* {@code .*"\s*;.*} and failed on correct output, because {@code "";} is an <em>escaped</em>
* quote followed by a semicolon <em>inside</em> the identifier — textually close to a
* terminator and semantically its opposite. That is the same confusion the vulnerable
* quoter made, so it is worth not repeating in the test.
*
* <p>Confirmed against a real PostgreSQL: this exact SQL answers
* {@code ERROR: relation "t"; DROP TABLE zz_v.probe; --" does not exist} — the server read
* the payload as one table name — and the probe table it names was still there afterwards,
* where the unescaped form had dropped it.
*/
@Test
void theInjectedStatementCollapsesIntoASingleIdentifier() {
String payload = "t\"; DROP TABLE probe; --";
String quoted = SqlIdentifier.quote(payload, "postgres");
String sql = "SELECT COUNT(*) FROM " + quoted;
assertEquals("SELECT COUNT(*) FROM \"t\"\"; DROP TABLE probe; --\"", sql);
assertTrue(sql.endsWith(quoted), "the identifier must be the whole tail of the statement");
assertEquals(1, countUnescapedQuotes(quoted, '"'),
"only the closing quote may be unescaped, or the identifier ends early: " + quoted);
}
// ── ordinary identifiers must keep working ────────────────────────────────
@Test
void leavesAnOrdinaryIdentifierAloneApartFromTheQuotes() {
assertEquals("\"orders\"", SqlIdentifier.quote("orders", "postgres"));
assertEquals("\"total_amount\"", SqlIdentifier.quote("total_amount", "postgres"));
assertEquals("`orders`", SqlIdentifier.quote("orders", "mysql"));
}
@Test
void picksTheQuoteCharacterFromTheDialect() {
assertEquals("`t`", SqlIdentifier.quote("t", "mysql"));
assertEquals("`t`", SqlIdentifier.quote("t", "MySQL"));
assertEquals("\"t\"", SqlIdentifier.quote("t", "postgres"));
assertEquals("\"t\"", SqlIdentifier.quote("t", "postgresql"));
}
/**
* An unknown or null dialect must not fall through to "no quoting". Postgres double quotes
* are the ANSI form and the safe default; guessing MySQL backticks for an unknown dialect
* would be the riskier direction.
*/
@Test
void defaultsToAnsiQuotingForAnUnknownDialect() {
assertEquals("\"t\"", SqlIdentifier.quote("t", null));
assertEquals("\"t\"", SqlIdentifier.quote("t", "oracle"));
assertEquals("\"t\"", SqlIdentifier.quote("t", ""));
}
// ── rejecting what should never reach SQL at all ──────────────────────────
/**
* Escaping alone makes injection impossible but still lets a caller name an identifier the
* feature never meant to touch. {@code requireSafe} is the second layer: the brain's
* statistics paths only ever address real tables and columns, so anything that cannot be
* one is refused before a statement is built.
*/
@Test
void refusesAnIdentifierCarryingSqlSyntax() {
for (String bad : new String[] {
"victim\" AS t; DROP TABLE probe; --",
"t; DROP TABLE probe",
"t--comment",
"t/*x*/",
"t'or'1'='1"
}) {
assertThrows(IllegalArgumentException.class,
() -> SqlIdentifier.requireSafe(bad), "should refuse: " + bad);
}
}
@Test
void refusesBlankAndNull() {
assertThrows(IllegalArgumentException.class, () -> SqlIdentifier.requireSafe(null));
assertThrows(IllegalArgumentException.class, () -> SqlIdentifier.requireSafe(" "));
}
/**
* Real schemas carry all of these. A validator that refused them would break the feature it
* is protecting, which is the usual reason such a check gets deleted later.
*/
@Test
void acceptsTheIdentifiersRealSchemasActuallyUse() {
for (String ok : new String[] {
"orders",
"total_amount",
"Orders",
"order_items_2026",
"public.orders",
"_private",
"v_daily_revenue",
"tableName$"
}) {
assertEquals(ok, SqlIdentifier.requireSafe(ok), "should accept: " + ok);
}
}
@Test
void requireSafeReturnsTheIdentifierSoItComposesWithQuote() {
assertEquals("\"orders\"",
SqlIdentifier.quote(SqlIdentifier.requireSafe("orders"), "postgres"));
}
/** Counts quote characters that are not part of a doubled pair. */
private static int countUnescapedQuotes(String quoted, char q) {
String body = quoted.substring(1, quoted.length() - 1);
int unescaped = 0;
for (int i = 0; i < body.length(); i++) {
if (body.charAt(i) != q) continue;
if (i + 1 < body.length() && body.charAt(i + 1) == q) { i++; continue; }
unescaped++;
}
return unescaped + 1; // the closing quote
}
}