Skip to content

Commit 23d3173

Browse files
fix: validate Unicode escapes of PostgreSQL escape strings (#2861)
With Dialect.POSTGRESQL, StringValue.getNotExcapedValue resolved the \uXXXX and \UXXXXXXXX escapes of an E'...' string with Integer.parseInt and StringBuilder.appendCodePoint and no range or surrogate check, so E'\uD800' produced an unpaired surrogate, E'\u0000' a NUL, E'\U00110000' threw from StringBuilder and E'\UFFFFFFFF' a NumberFormatException, while the parser accepted them all. PostgreSQL rejects each of them. Decode the E'' Unicode escapes with the rules the U&'' path already applies: exactly 4 or 8 hexadecimal digits, no code point zero or value above U+10FFFF, and a surrogate pair only as two consecutive escapes. The parser rejects such a literal under the PostgreSQL dialect as it does for U&'' strings.
1 parent 6c71e15 commit 23d3173

3 files changed

Lines changed: 108 additions & 23 deletions

File tree

‎src/main/java/net/sf/jsqlparser/expression/StringValue.java‎

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,8 @@ public StringValue withUnicodeEscapeCharacter(Character unicodeEscapeCharacter)
234234
* returned unchanged.
235235
*
236236
* @throws IllegalArgumentException if a {@link Escaping#POSTGRESQL_UNICODE_ESCAPE} value has an
237-
* escape that PostgreSQL rejects; the parser rejects such literals
237+
* escape that PostgreSQL rejects, or a {@link Escaping#POSTGRESQL_ESCAPE_STRING} value
238+
* has a Unicode escape that PostgreSQL rejects; the parser rejects such literals
238239
*/
239240
public String getNotExcapedValue() {
240241
if (quoteStr != null && quoteStr.startsWith("$")) {
@@ -317,26 +318,26 @@ private static int appendPostgresEscape(Unescaped result, String text, int index
317318
result.append('\t');
318319
return index + 1;
319320
case 'x':
320-
return appendNumber(result, text, index + 1, 16, 1, 2, c, true);
321+
return appendByte(result, text, index + 1, 16, 1, 2, c);
321322
case 'u':
322-
return appendNumber(result, text, index + 1, 16, 4, 4, c, false);
323+
return appendCodePoint(result, text, index, 4);
323324
case 'U':
324-
return appendNumber(result, text, index + 1, 16, 8, 8, c, false);
325+
return appendCodePoint(result, text, index, 8);
325326
default:
326327
if (c >= '0' && c <= '7') {
327-
return appendNumber(result, text, index, 8, 1, 3, c, true);
328+
return appendByte(result, text, index, 8, 1, 3, c);
328329
}
329330
result.append(c);
330331
return index + 1;
331332
}
332333
}
333334

334335
/**
335-
* Appends the byte or code point spelled by {@code min} to {@code max} digits at {@code start},
336-
* or {@code fallback} when fewer digits follow, and returns the next index.
336+
* Appends the byte spelled by {@code min} to {@code max} digits at {@code start}, or
337+
* {@code fallback} when fewer digits follow, and returns the next index.
337338
*/
338-
private static int appendNumber(Unescaped result, String text, int start, int radix,
339-
int min, int max, char fallback, boolean isByte) {
339+
private static int appendByte(Unescaped result, String text, int start, int radix, int min,
340+
int max, char fallback) {
340341
int end = start;
341342
while (end < text.length() && end - start < max
342343
&& Character.digit(text.charAt(end), radix) >= 0) {
@@ -346,12 +347,28 @@ private static int appendNumber(Unescaped result, String text, int start, int ra
346347
result.append(fallback);
347348
return start;
348349
}
349-
int number = Integer.parseInt(text.substring(start, end), radix);
350-
if (isByte) {
351-
result.appendByte(number);
352-
} else {
353-
result.appendCodePoint(number);
350+
result.appendByte(Integer.parseInt(text.substring(start, end), radix));
351+
return end;
352+
}
353+
354+
/**
355+
* Appends the code point spelled by exactly {@code digits} hexadecimal digits after the escape
356+
* letter at {@code index} and returns the next index. As in a Unicode escape string, the code
357+
* point must be valid and a UTF-16 surrogate pair must be written as two consecutive escapes.
358+
*/
359+
private static int appendCodePoint(Unescaped result, String text, int index, int digits) {
360+
int start = index + 1;
361+
int end = start + digits;
362+
long codePoint = parseHex(text, start, end);
363+
if (codePoint < 0) {
364+
throw new IllegalArgumentException("invalid Unicode escape at position " + (index - 1)
365+
+ ": Unicode escapes must be \\u followed by 4 or \\U by 8 hexadecimal digits");
366+
}
367+
if (codePoint == 0 || codePoint > Character.MAX_CODE_POINT) {
368+
throw new IllegalArgumentException(
369+
"invalid Unicode escape value at position " + (index - 1));
354370
}
371+
result.appendCodePoint((int) codePoint, index - 1);
355372
return end;
356373
}
357374

@@ -393,7 +410,7 @@ private static int appendUnicodeEscape(UnicodeUnescaped result, String text, int
393410
boolean plus = position + 1 < text.length() && text.charAt(position + 1) == '+';
394411
int start = plus ? position + 2 : position + 1;
395412
int end = start + (plus ? 6 : 4);
396-
int codePoint = parseHex(text, start, end);
413+
long codePoint = parseHex(text, start, end);
397414
if (codePoint < 0) {
398415
throw new IllegalArgumentException("invalid Unicode escape at position " + position
399416
+ ": Unicode escapes must be " + escape + "XXXX or " + escape + "+XXXXXX");
@@ -402,7 +419,7 @@ private static int appendUnicodeEscape(UnicodeUnescaped result, String text, int
402419
throw new IllegalArgumentException(
403420
"invalid Unicode escape value at position " + position);
404421
}
405-
result.appendCodePoint(codePoint, position);
422+
result.appendCodePoint((int) codePoint, position);
406423
return end;
407424
}
408425

@@ -416,11 +433,11 @@ private static boolean isHexDigit(char c) {
416433
}
417434

418435
/** Returns the ASCII hexadecimal number spelled by text[start, end), or -1. */
419-
private static int parseHex(String text, int start, int end) {
436+
private static long parseHex(String text, int start, int end) {
420437
if (end > text.length()) {
421438
return -1;
422439
}
423-
int number = 0;
440+
long number = 0;
424441
for (int i = start; i < end; i++) {
425442
char c = text.charAt(i);
426443
if (!isHexDigit(c)) {
@@ -431,26 +448,50 @@ private static int parseHex(String text, int start, int end) {
431448
return number;
432449
}
433450

434-
/** Collects unescaped characters; consecutive escaped bytes are decoded as UTF-8. */
451+
/**
452+
* Collects unescaped characters; consecutive escaped bytes are decoded as UTF-8. A UTF-16
453+
* surrogate pair must be written as two consecutive Unicode escapes, as PostgreSQL requires.
454+
*/
435455
private static final class Unescaped {
436456
private final StringBuilder chars;
437457
private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
458+
private char highSurrogate;
459+
private int highSurrogatePosition;
438460

439461
Unescaped(int capacity) {
440462
chars = new StringBuilder(capacity);
441463
}
442464

443465
void append(char c) {
466+
requireNoPendingSurrogate();
444467
flushBytes();
445468
chars.append(c);
446469
}
447470

448-
void appendCodePoint(int codePoint) {
471+
/** Appends the code point of the Unicode escape at {@code position}. */
472+
void appendCodePoint(int codePoint, int position) {
449473
flushBytes();
450-
chars.appendCodePoint(codePoint);
474+
if (codePoint >= Character.MIN_LOW_SURROGATE
475+
&& codePoint <= Character.MAX_LOW_SURROGATE) {
476+
if (highSurrogate == 0) {
477+
throw invalidSurrogatePair(position);
478+
}
479+
chars.appendCodePoint(Character.toCodePoint(highSurrogate, (char) codePoint));
480+
highSurrogate = 0;
481+
return;
482+
}
483+
requireNoPendingSurrogate();
484+
if (codePoint >= Character.MIN_HIGH_SURROGATE
485+
&& codePoint <= Character.MAX_HIGH_SURROGATE) {
486+
highSurrogate = (char) codePoint;
487+
highSurrogatePosition = position;
488+
} else {
489+
chars.appendCodePoint(codePoint);
490+
}
451491
}
452492

453493
void appendByte(int value) {
494+
requireNoPendingSurrogate();
454495
bytes.write(value);
455496
}
456497

@@ -461,8 +502,15 @@ private void flushBytes() {
461502
}
462503
}
463504

505+
private void requireNoPendingSurrogate() {
506+
if (highSurrogate != 0) {
507+
throw invalidSurrogatePair(highSurrogatePosition);
508+
}
509+
}
510+
464511
@Override
465512
public String toString() {
513+
requireNoPendingSurrogate();
466514
flushBytes();
467515
return chars.toString();
468516
}

‎src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3417,8 +3417,11 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
34173417
}
34183418
}
34193419

3420-
/** Creates a string literal that remembers how this configuration lexed its backslashes. */
3421-
private StringValue stringValue(Token token) {
3420+
/**
3421+
* Creates a string literal that remembers how this configuration lexed its backslashes. A
3422+
* PostgreSQL escape string with a Unicode escape that the server rejects is rejected as well.
3423+
*/
3424+
private StringValue stringValue(Token token) throws ParseException {
34223425
StringValue value = new StringValue(token.image);
34233426
// a U&'...' string brings its own escaping
34243427
if (value.getQuoteStr() == null || value.getQuoteStr().startsWith("$")
@@ -3430,6 +3433,12 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
34303433
if (Dialect.POSTGRESQL.name().equals(dialect)) {
34313434
if (backslashes || "E".equalsIgnoreCase(value.getPrefix())) {
34323435
value.setEscaping(StringValue.Escaping.POSTGRESQL_ESCAPE_STRING);
3436+
try {
3437+
value.getNotExcapedValue();
3438+
} catch (IllegalArgumentException e) {
3439+
throw new ParseException("Invalid escape string " + token.image + ": "
3440+
+ e.getMessage());
3441+
}
34333442
}
34343443
} else if (backslashes && (dialect == null || isMySqlDialect())) {
34353444
value.setEscaping(StringValue.Escaping.MYSQL_BACKSLASH);

‎src/test/java/net/sf/jsqlparser/expression/StringValueTest.java‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,34 @@ void testPostgresEscapeStrings() throws JSQLParserException {
147147
.getNotExcapedValue());
148148
}
149149

150+
@Test
151+
void testPostgresEscapeStringUnicodeEscapes() throws JSQLParserException {
152+
String[][] cases = {
153+
{"E'\\U0010FFFF'", new String(Character.toChars(0x10FFFF))},
154+
// a surrogate pair as two escapes, in either form
155+
{"E'\\uD83D\\uDE00'", "\uD83D\uDE00"},
156+
{"E'\\ud83d\\U0000de00'", "\uD83D\uDE00"},
157+
{"E'\\x41\\uD83D\\uDE00\\101'", "A\uD83D\uDE00A"}};
158+
for (String[] c : cases) {
159+
assertEquals(c[1], literal(c[0], Dialect.POSTGRESQL).getNotExcapedValue(), c[0]);
160+
}
161+
162+
// the escapes PostgreSQL rejects: too few digits, invalid code points and a surrogate
163+
// not written as a pair
164+
for (String sql : new String[] {"E'\\u00e'", "E'\\U0001F60'", "E'\\u0000'",
165+
"E'\\U00000000'", "E'\\U00110000'", "E'\\U7FFFFFFF'", "E'\\UFFFFFFFF'",
166+
"E'\\uD800'", "E'\\uD800x'", "E'\\uD800\\u0041'", "E'\\uD800\\x41'",
167+
"E'\\uD800'''", "E'\\uDE00'", "E'\\uD800\\uD800\\uDE00'"}) {
168+
assertThrows(IllegalArgumentException.class, () -> new StringValue(sql)
169+
.withEscaping(StringValue.Escaping.POSTGRESQL_ESCAPE_STRING)
170+
.getNotExcapedValue(), sql);
171+
assertThrows(JSQLParserException.class, () -> literal(sql, Dialect.POSTGRESQL), sql);
172+
}
173+
// only the escapes of PostgreSQL strings are checked
174+
assertEquals("\\uD800", literal("'\\uD800'", Dialect.POSTGRESQL).getNotExcapedValue());
175+
assertEquals("\\uD800", literal("E'\\uD800'", null).getNotExcapedValue());
176+
}
177+
150178
@Test
151179
void testPostgresUnicodeEscapeStrings() throws JSQLParserException {
152180
StringValue value = literal("U&'\\0041BC \\00e9'", Dialect.POSTGRESQL);

0 commit comments

Comments
 (0)