Skip to content

Commit e5e9562

Browse files
authored
Support hyphen in identifier names (#246)
1 parent a02a279 commit e5e9562

File tree

3 files changed

+61
-106
lines changed

3 files changed

+61
-106
lines changed

document-store/src/main/java/org/hypertrace/core/documentstore/postgres/utils/BasicPostgresSecurityValidator.java

Lines changed: 4 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -43,50 +43,18 @@ public class BasicPostgresSecurityValidator implements PostgresSecurityValidator
4343
* Documentation</a>
4444
*/
4545
private static final String DEFAULT_IDENTIFIER_PATTERN =
46-
"^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)*$";
47-
48-
/**
49-
* Default pattern for JSON field names within JSONB columns.
50-
*
51-
* <p>Pattern: {@code ^[a-zA-Z0-9_]+$}
52-
*
53-
* <p><b>Allowed:</b>
54-
*
55-
* <ul>
56-
* <li>Can start with: letter (a-z, A-Z), digit (0-9), or underscore (_)
57-
* <li>Can contain: letters (a-z, A-Z), digits (0-9), underscores (_)
58-
* <li>Examples: {@code "brand"}, {@code "1st_choice"}, {@code "field123"}, {@code "_private"}
59-
* </ul>
60-
*
61-
* <p><b>Not allowed:</b>
62-
*
63-
* <ul>
64-
* <li>Hyphens: {@code "field-name"}
65-
* <li>Dots: {@code "field.name"} (use path segments instead: ["field", "name"])
66-
* <li>Spaces: {@code "my field"}
67-
* <li>Special characters: {@code "field@name"}, {@code "field#name"}
68-
* <li>Quotes: {@code "field\"name"}, {@code "field'name"}
69-
* <li>Semicolons: {@code "field; DROP TABLE"}
70-
* <li>SQL operators: {@code "field\" OR \"1\"=\"1"}
71-
* </ul>
72-
*
73-
* <p><b>Note:</b> More permissive than identifier pattern to support flexible JSON schemas where
74-
* field names may start with numbers.
75-
*/
76-
private static final String DEFAULT_JSON_FIELD_PATTERN = "^[a-zA-Z0-9_]+$";
46+
"^[a-zA-Z_][a-zA-Z0-9_-]*(\\.[a-zA-Z_][a-zA-Z0-9_-]*)*$";
7747

7848
/** Default instance with hardcoded values for convenient static access. */
7949
private static final BasicPostgresSecurityValidator DEFAULT =
8050
new BasicPostgresSecurityValidator(
8151
DEFAULT_MAX_IDENTIFIER_LENGTH,
8252
DEFAULT_MAX_JSON_FIELD_LENGTH,
8353
DEFAULT_MAX_JSON_PATH_DEPTH,
84-
DEFAULT_IDENTIFIER_PATTERN,
85-
DEFAULT_JSON_FIELD_PATTERN);
54+
DEFAULT_IDENTIFIER_PATTERN);
8655

8756
// Instance variables for configured limits
8857
private final Pattern validIdentifier;
89-
private final Pattern validJsonField;
9058
private final int maxIdentifierLength;
9159
private final int maxJsonFieldLength;
9260
private final int maxJsonPathDepth;
@@ -105,13 +73,11 @@ private BasicPostgresSecurityValidator(
10573
int maxIdentifierLength,
10674
int maxJsonFieldLength,
10775
int maxJsonPathDepth,
108-
String identifierPattern,
109-
String jsonFieldPattern) {
76+
String identifierPattern) {
11077
this.maxIdentifierLength = maxIdentifierLength;
11178
this.maxJsonFieldLength = maxJsonFieldLength;
11279
this.maxJsonPathDepth = maxJsonPathDepth;
11380
this.validIdentifier = Pattern.compile(identifierPattern);
114-
this.validJsonField = Pattern.compile(jsonFieldPattern);
11581
}
11682

11783
@Override
@@ -163,7 +129,7 @@ public void validateJsonPath(List<String> path) {
163129
field, i, maxJsonFieldLength));
164130
}
165131

166-
if (!validJsonField.matcher(field).matches()) {
132+
if (!validIdentifier.matcher(field).matches()) {
167133
throw new SecurityException(
168134
String.format(
169135
"JSON field '%s' at index %d contains invalid characters. "

document-store/src/test/java/org/hypertrace/core/documentstore/expression/impl/JsonIdentifierExpressionSecurityTest.java

Lines changed: 37 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,48 +7,51 @@
77
import java.util.List;
88
import org.junit.jupiter.api.Test;
99

10-
/** Security tests for JsonIdentifierExpression to ensure SQL injection prevention. */
1110
public class JsonIdentifierExpressionSecurityTest {
1211

13-
// ===== Valid Expressions =====
14-
1512
@Test
16-
void testValidExpression_SimpleField() {
13+
void testValidExpressionSimpleField() {
1714
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "brand"));
1815
}
1916

2017
@Test
21-
void testValidExpression_NestedField() {
18+
void testValidExpressionNestedField() {
2219
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "seller", "name"));
2320
}
2421

2522
@Test
26-
void testValidExpression_DeeplyNested() {
23+
void testValidExpressionDeeplyNested() {
2724
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "seller", "address", "city"));
2825
}
2926

3027
@Test
31-
void testValidExpression_WithNumbers() {
28+
void testValidExpressionWithNumbers() {
3229
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "field123"));
33-
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "1st_choice"));
30+
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "field_1"));
3431
}
3532

3633
@Test
37-
void testValidExpression_WithUnderscore() {
34+
void testInvalidExpressionJsonPathStartsWithNumber() {
35+
SecurityException ex =
36+
assertThrows(
37+
SecurityException.class, () -> JsonIdentifierExpression.of("props", "1st_choice"));
38+
assertTrue(ex.getMessage().contains("invalid characters"));
39+
}
40+
41+
@Test
42+
void testValidExpressionWithUnderscore() {
3843
assertDoesNotThrow(() -> JsonIdentifierExpression.of("_internal", "field"));
3944
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "_private"));
4045
}
4146

4247
@Test
43-
void testValidExpression_UsingListConstructor() {
48+
void testValidExpressionUsingListConstructor() {
4449
assertDoesNotThrow(
4550
() -> JsonIdentifierExpression.of("props", List.of("seller", "address", "city")));
4651
}
4752

48-
// ===== Invalid Column Names =====
49-
5053
@Test
51-
void testInvalidExpression_ColumnName_DropTable() {
54+
void testInvalidExpressionColumnNameDropTable() {
5255
SecurityException ex =
5356
assertThrows(
5457
SecurityException.class,
@@ -57,49 +60,39 @@ void testInvalidExpression_ColumnName_DropTable() {
5760
}
5861

5962
@Test
60-
void testInvalidExpression_ColumnName_WithQuote() {
63+
void testInvalidExpressionColumnNameWithQuote() {
6164
SecurityException ex =
6265
assertThrows(
6366
SecurityException.class, () -> JsonIdentifierExpression.of("props\"name", "brand"));
6467
assertTrue(ex.getMessage().contains("invalid"));
6568
}
6669

6770
@Test
68-
void testInvalidExpression_ColumnName_WithSemicolon() {
71+
void testInvalidExpressionColumnNameWithSemicolon() {
6972
SecurityException ex =
7073
assertThrows(
7174
SecurityException.class, () -> JsonIdentifierExpression.of("props;SELECT", "brand"));
7275
assertTrue(ex.getMessage().contains("invalid"));
7376
}
7477

7578
@Test
76-
void testInvalidExpression_ColumnName_StartsWithNumber() {
79+
void testInvalidExpressionColumnNameStartsWithNumber() {
7780
SecurityException ex =
7881
assertThrows(
7982
SecurityException.class, () -> JsonIdentifierExpression.of("123props", "brand"));
8083
assertTrue(ex.getMessage().contains("Must start with a letter or underscore"));
8184
}
8285

8386
@Test
84-
void testInvalidExpression_ColumnName_WithHyphen() {
85-
SecurityException ex =
86-
assertThrows(
87-
SecurityException.class, () -> JsonIdentifierExpression.of("my-column", "brand"));
88-
assertTrue(ex.getMessage().contains("invalid"));
89-
}
90-
91-
@Test
92-
void testInvalidExpression_ColumnName_WithSpace() {
87+
void testInvalidExpressionColumnNameWithSpace() {
9388
SecurityException ex =
9489
assertThrows(
9590
SecurityException.class, () -> JsonIdentifierExpression.of("my column", "brand"));
9691
assertTrue(ex.getMessage().contains("invalid"));
9792
}
9893

99-
// ===== Invalid JSON Paths =====
100-
10194
@Test
102-
void testInvalidExpression_JsonPath_WithQuote() {
95+
void testInvalidExpressionJsonPathWithQuote() {
10396
SecurityException ex =
10497
assertThrows(
10598
SecurityException.class,
@@ -108,47 +101,43 @@ void testInvalidExpression_JsonPath_WithQuote() {
108101
}
109102

110103
@Test
111-
void testInvalidExpression_JsonPath_WithDoubleQuote() {
104+
void testInvalidExpressionJsonPathWithDoubleQuote() {
112105
SecurityException ex =
113106
assertThrows(
114107
SecurityException.class, () -> JsonIdentifierExpression.of("props", "name\"--"));
115108
assertTrue(ex.getMessage().contains("invalid characters"));
116109
}
117110

118111
@Test
119-
void testInvalidExpression_JsonPath_WithSemicolon() {
112+
void testInvalidExpressionJsonPathWithSemicolon() {
120113
SecurityException ex =
121114
assertThrows(
122115
SecurityException.class, () -> JsonIdentifierExpression.of("props", "field; DROP"));
123116
assertTrue(ex.getMessage().contains("invalid characters"));
124117
}
125118

126119
@Test
127-
void testInvalidExpression_JsonPath_WithHyphen() {
128-
SecurityException ex =
129-
assertThrows(
130-
SecurityException.class, () -> JsonIdentifierExpression.of("props", "field-name"));
131-
assertTrue(ex.getMessage().contains("invalid characters"));
120+
void testValidExpressionJsonPathWithHyphen() {
121+
assertDoesNotThrow(() -> JsonIdentifierExpression.of("customAttribute", "repo-url"));
122+
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "user-id"));
132123
}
133124

134125
@Test
135-
void testInvalidExpression_JsonPath_WithDot() {
136-
SecurityException ex =
137-
assertThrows(
138-
SecurityException.class, () -> JsonIdentifierExpression.of("props", "field.name"));
139-
assertTrue(ex.getMessage().contains("invalid characters"));
126+
void testValidExpressionJsonPathWithDot() {
127+
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "field.name"));
128+
assertDoesNotThrow(() -> JsonIdentifierExpression.of("props", "user.address.city"));
140129
}
141130

142131
@Test
143-
void testInvalidExpression_JsonPath_WithSpace() {
132+
void testInvalidExpressionJsonPathWithSpace() {
144133
SecurityException ex =
145134
assertThrows(
146135
SecurityException.class, () -> JsonIdentifierExpression.of("props", "field name"));
147136
assertTrue(ex.getMessage().contains("invalid characters"));
148137
}
149138

150139
@Test
151-
void testInvalidExpression_JsonPath_EmptyElement() {
140+
void testInvalidExpression_sonPathEmptyElement() {
152141
SecurityException ex =
153142
assertThrows(
154143
SecurityException.class,
@@ -157,7 +146,7 @@ void testInvalidExpression_JsonPath_EmptyElement() {
157146
}
158147

159148
@Test
160-
void testInvalidExpression_JsonPath_TooDeep() {
149+
void testInvalidExpressionJsonPathTooDeep() {
161150
String[] deepPath = new String[11]; // Max is 10
162151
for (int i = 0; i < 11; i++) {
163152
deepPath[i] = "level" + i;
@@ -167,10 +156,8 @@ void testInvalidExpression_JsonPath_TooDeep() {
167156
assertTrue(ex.getMessage().contains("exceeds maximum depth"));
168157
}
169158

170-
// ===== Real-world Attack Scenarios =====
171-
172159
@Test
173-
void testAttackScenario_SqlCommentInjection() {
160+
void testAttackScenarioSqlCommentInjection() {
174161
SecurityException ex =
175162
assertThrows(
176163
SecurityException.class,
@@ -179,7 +166,7 @@ void testAttackScenario_SqlCommentInjection() {
179166
}
180167

181168
@Test
182-
void testAttackScenario_UnionSelect() {
169+
void testAttackScenarioUnionSelect() {
183170
SecurityException ex =
184171
assertThrows(
185172
SecurityException.class,
@@ -190,7 +177,7 @@ void testAttackScenario_UnionSelect() {
190177
}
191178

192179
@Test
193-
void testAttackScenario_OrTrueInjection() {
180+
void testAttackScenarioOrTrueInjection() {
194181
SecurityException ex =
195182
assertThrows(
196183
SecurityException.class,
@@ -199,7 +186,7 @@ void testAttackScenario_OrTrueInjection() {
199186
}
200187

201188
@Test
202-
void testAttackScenario_NestedInjection() {
189+
void testAttackScenarioNestedInjection() {
203190
SecurityException ex =
204191
assertThrows(
205192
SecurityException.class,
@@ -208,7 +195,7 @@ void testAttackScenario_NestedInjection() {
208195
}
209196

210197
@Test
211-
void testAttackScenario_SpecialCharacterCombination() {
198+
void testAttackScenarioSpecialCharacterCombination() {
212199
SecurityException ex =
213200
assertThrows(
214201
SecurityException.class, () -> JsonIdentifierExpression.of("props", "field'\"`;DROP"));

document-store/src/test/java/org/hypertrace/core/documentstore/postgres/utils/PostgresSecurityValidatorTest.java

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ void testValidIdentifierWithNumbers() {
3131
assertDoesNotThrow(() -> validator.validateIdentifier("col_1"));
3232
}
3333

34+
@Test
35+
void testValidIdentifierWithHyphens() {
36+
assertDoesNotThrow(() -> validator.validateIdentifier("repo-url"));
37+
}
38+
3439
@Test
3540
void testInvalidIdentifierNull() {
3641
SecurityException ex =
@@ -75,13 +80,6 @@ void testInvalidIdentifierSqlInjection_Semicolon() {
7580
assertTrue(ex.getMessage().contains("invalid"));
7681
}
7782

78-
@Test
79-
void testInvalidIdentifierHyphen() {
80-
SecurityException ex =
81-
assertThrows(SecurityException.class, () -> validator.validateIdentifier("field-name"));
82-
assertTrue(ex.getMessage().contains("invalid"));
83-
}
84-
8583
@Test
8684
void testValidIdentifierWithDotNotation() {
8785
assertDoesNotThrow(() -> validator.validateIdentifier("field.name"));
@@ -142,7 +140,15 @@ void testValidJsonPathMultiLevel() {
142140
@Test
143141
void testValidJsonPathWithNumbers() {
144142
assertDoesNotThrow(() -> validator.validateJsonPath(List.of("field123")));
145-
assertDoesNotThrow(() -> validator.validateJsonPath(List.of("1st_choice")));
143+
assertDoesNotThrow(() -> validator.validateJsonPath(List.of("field_1")));
144+
}
145+
146+
@Test
147+
void testInvalidJsonPathStartsWithNumber() {
148+
SecurityException ex =
149+
assertThrows(
150+
SecurityException.class, () -> validator.validateJsonPath(List.of("1st_choice")));
151+
assertTrue(ex.getMessage().contains("invalid characters"));
146152
}
147153

148154
@Test
@@ -211,19 +217,15 @@ void testInvalidJsonPathSqlInjectionSemicolon() {
211217
}
212218

213219
@Test
214-
void testInvalidJsonPathHyphen() {
215-
SecurityException ex =
216-
assertThrows(
217-
SecurityException.class, () -> validator.validateJsonPath(List.of("field-name")));
218-
assertTrue(ex.getMessage().contains("invalid characters"));
220+
void testValidJsonPathWithHyphen() {
221+
assertDoesNotThrow(() -> validator.validateJsonPath(List.of("field-name")));
222+
assertDoesNotThrow(() -> validator.validateJsonPath(List.of("user-id")));
219223
}
220224

221225
@Test
222-
void testInvalidJsonPathDot() {
223-
SecurityException ex =
224-
assertThrows(
225-
SecurityException.class, () -> validator.validateJsonPath(List.of("field.name")));
226-
assertTrue(ex.getMessage().contains("invalid characters"));
226+
void testValidJsonPathWithDotNotation() {
227+
assertDoesNotThrow(() -> validator.validateJsonPath(List.of("field.name")));
228+
assertDoesNotThrow(() -> validator.validateJsonPath(List.of("user.address.city")));
227229
}
228230

229231
@Test

0 commit comments

Comments
 (0)