Skip to content

Commit fa0ad6d

Browse files
committed
Correctly encode warning headers
The warnings headers have a fairly limited set of valid characters (cf. quoted-text in RFC 7230). While we have assertions that we adhere to this set of valid characters ensuring that our warning messages do not violate the specificaion, we were neglecting the possibility that arbitrary user input would trickle into these warning headers. Thus, missing here was tests for these situations and encoding of characters that appear outside the set of valid characters. This commit addresses this by encoding any characters in a deprecation message that are not from the set of valid characters. Relates #27269
1 parent 7e64749 commit fa0ad6d

File tree

5 files changed

+165
-14
lines changed

5 files changed

+165
-14
lines changed

core/src/main/java/org/elasticsearch/common/logging/DeprecationLogger.java

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@
2626
import org.elasticsearch.common.SuppressLoggerChecks;
2727
import org.elasticsearch.common.util.concurrent.ThreadContext;
2828

29+
import java.io.CharArrayWriter;
30+
import java.nio.charset.Charset;
2931
import java.time.ZoneId;
3032
import java.time.ZonedDateTime;
3133
import java.time.format.DateTimeFormatter;
3234
import java.time.format.DateTimeFormatterBuilder;
3335
import java.time.format.SignStyle;
36+
import java.util.BitSet;
3437
import java.util.Collections;
3538
import java.util.HashMap;
3639
import java.util.Iterator;
@@ -228,7 +231,7 @@ public void deprecatedAndMaybeLog(final String key, final String msg, final Obje
228231
public static Pattern WARNING_HEADER_PATTERN = Pattern.compile(
229232
"299 " + // warn code
230233
"Elasticsearch-\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\d+)?(?:-SNAPSHOT)?-(?:[a-f0-9]{7}|Unknown) " + // warn agent
231-
"\"((?:\t| |!|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x80-\\xff]|\\\\|\\\\\")*)\" " + // quoted warning value, captured
234+
"\"((?:\t| |!|[\\x23-\\x5B]|[\\x5D-\\x7E]|[\\x80-\\xFF]|\\\\|\\\\\")*)\" " + // quoted warning value, captured
232235
// quoted RFC 1123 date format
233236
"\"" + // opening quote
234237
"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), " + // weekday
@@ -304,7 +307,7 @@ void deprecated(final Set<ThreadContext> threadContexts, final String message, f
304307
final String formattedMessage = LoggerMessageFormat.format(message, params);
305308
final String warningHeaderValue = formatWarning(formattedMessage);
306309
assert WARNING_HEADER_PATTERN.matcher(warningHeaderValue).matches();
307-
assert extractWarningValueFromWarningHeader(warningHeaderValue).equals(escape(formattedMessage));
310+
assert extractWarningValueFromWarningHeader(warningHeaderValue).equals(escapeAndEncode(formattedMessage));
308311
while (iterator.hasNext()) {
309312
try {
310313
final ThreadContext next = iterator.next();
@@ -328,7 +331,17 @@ void deprecated(final Set<ThreadContext> threadContexts, final String message, f
328331
* @return a warning value formatted according to RFC 7234
329332
*/
330333
public static String formatWarning(final String s) {
331-
return String.format(Locale.ROOT, WARNING_FORMAT, escape(s), RFC_7231_DATE_TIME.format(ZonedDateTime.now(GMT)));
334+
return String.format(Locale.ROOT, WARNING_FORMAT, escapeAndEncode(s), RFC_7231_DATE_TIME.format(ZonedDateTime.now(GMT)));
335+
}
336+
337+
/**
338+
* Escape and encode a string as a valid RFC 7230 quoted-string.
339+
*
340+
* @param s the string to escape and encode
341+
* @return the escaped and encoded string
342+
*/
343+
public static String escapeAndEncode(final String s) {
344+
return encode(escapeBackslashesAndQuotes(s));
332345
}
333346

334347
/**
@@ -337,8 +350,81 @@ public static String formatWarning(final String s) {
337350
* @param s the string to escape
338351
* @return the escaped string
339352
*/
340-
public static String escape(String s) {
353+
static String escapeBackslashesAndQuotes(final String s) {
341354
return s.replaceAll("([\"\\\\])", "\\\\$1");
342355
}
343356

357+
private static BitSet doesNotNeedEncoding;
358+
359+
static {
360+
doesNotNeedEncoding = new BitSet(1 + 0xFF);
361+
doesNotNeedEncoding.set('\t');
362+
doesNotNeedEncoding.set(' ');
363+
doesNotNeedEncoding.set('!');
364+
doesNotNeedEncoding.set('\\');
365+
doesNotNeedEncoding.set('"');
366+
// we have to skip '%' which is 0x25 so that it is percent-encoded too
367+
for (int i = 0x23; i <= 0x24; i++) {
368+
doesNotNeedEncoding.set(i);
369+
}
370+
for (int i = 0x26; i <= 0x5B; i++) {
371+
doesNotNeedEncoding.set(i);
372+
}
373+
for (int i = 0x5D; i <= 0x7E; i++) {
374+
doesNotNeedEncoding.set(i);
375+
}
376+
for (int i = 0x80; i <= 0xFF; i++) {
377+
doesNotNeedEncoding.set(i);
378+
}
379+
assert !doesNotNeedEncoding.get('%');
380+
}
381+
382+
private static final Charset UTF_8 = Charset.forName("UTF-8");
383+
384+
/**
385+
* Encode a string containing characters outside of the legal characters for an RFC 7230 quoted-string.
386+
*
387+
* @param s the string to encode
388+
* @return the encoded string
389+
*/
390+
static String encode(final String s) {
391+
final StringBuilder sb = new StringBuilder(s.length());
392+
boolean encodingNeeded = false;
393+
for (int i = 0; i < s.length();) {
394+
int current = (int) s.charAt(i);
395+
/*
396+
* Either the character does not need encoding or it does; when the character does not need encoding we append the character to
397+
* a buffer and move to the next character and when the character does need encoding, we peel off as many characters as possible
398+
* which we encode using UTF-8 until we encounter another character that does not need encoding.
399+
*/
400+
if (doesNotNeedEncoding.get(current)) {
401+
// append directly and move to the next character
402+
sb.append((char) current);
403+
i++;
404+
} else {
405+
int startIndex = i;
406+
do {
407+
i++;
408+
} while (i < s.length() && !doesNotNeedEncoding.get(s.charAt(i)));
409+
410+
final byte[] bytes = s.substring(startIndex, i).getBytes(UTF_8);
411+
// noinspection ForLoopReplaceableByForEach
412+
for (int j = 0; j < bytes.length; j++) {
413+
sb.append('%').append(hex(bytes[j] >> 4)).append(hex(bytes[j]));
414+
}
415+
encodingNeeded = true;
416+
}
417+
}
418+
return encodingNeeded ? sb.toString() : s;
419+
}
420+
421+
private static char hex(int b) {
422+
final char ch = Character.forDigit(b & 0xF, 16);
423+
if (Character.isLetter(ch)) {
424+
return Character.toUpperCase(ch);
425+
} else {
426+
return ch;
427+
}
428+
}
429+
344430
}

core/src/test/java/org/elasticsearch/common/logging/DeprecationLoggerTests.java

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
import org.elasticsearch.common.util.concurrent.ThreadContext;
2424
import org.elasticsearch.test.ESTestCase;
2525
import org.elasticsearch.test.hamcrest.RegexMatcher;
26+
import org.hamcrest.core.IsSame;
2627

2728
import java.io.IOException;
2829
import java.util.Collections;
2930
import java.util.HashSet;
3031
import java.util.List;
32+
import java.util.Locale;
3133
import java.util.Map;
3234
import java.util.Set;
3335
import java.util.stream.IntStream;
@@ -71,6 +73,54 @@ public void testAddsHeaderWithThreadContext() throws IOException {
7173
}
7274
}
7375

76+
public void testContainingNewline() throws IOException {
77+
try (ThreadContext threadContext = new ThreadContext(Settings.EMPTY)) {
78+
final Set<ThreadContext> threadContexts = Collections.singleton(threadContext);
79+
80+
logger.deprecated(threadContexts, "this message contains a newline\n");
81+
82+
final Map<String, List<String>> responseHeaders = threadContext.getResponseHeaders();
83+
84+
assertThat(responseHeaders.size(), equalTo(1));
85+
final List<String> responses = responseHeaders.get("Warning");
86+
assertThat(responses, hasSize(1));
87+
assertThat(responses.get(0), warningValueMatcher);
88+
assertThat(responses.get(0), containsString("\"this message contains a newline%0A\""));
89+
}
90+
}
91+
92+
public void testSurrogatePair() throws IOException {
93+
try (ThreadContext threadContext = new ThreadContext(Settings.EMPTY)) {
94+
final Set<ThreadContext> threadContexts = Collections.singleton(threadContext);
95+
96+
logger.deprecated(threadContexts, "this message contains a surrogate pair 😱");
97+
98+
final Map<String, List<String>> responseHeaders = threadContext.getResponseHeaders();
99+
100+
assertThat(responseHeaders.size(), equalTo(1));
101+
final List<String> responses = responseHeaders.get("Warning");
102+
assertThat(responses, hasSize(1));
103+
assertThat(responses.get(0), warningValueMatcher);
104+
105+
// convert UTF-16 to UTF-8 by hand to show the hard-coded constant below is correct
106+
assertThat("😱", equalTo("\uD83D\uDE31"));
107+
final int code = 0x10000 + ((0xD83D & 0x3FF) << 10) + (0xDE31 & 0x3FF);
108+
@SuppressWarnings("PointlessBitwiseExpression")
109+
final int[] points = new int[] {
110+
(code >> 18) & 0x07 | 0xF0,
111+
(code >> 12) & 0x3F | 0x80,
112+
(code >> 6) & 0x3F | 0x80,
113+
(code >> 0) & 0x3F | 0x80};
114+
final StringBuilder sb = new StringBuilder();
115+
// noinspection ForLoopReplaceableByForEach
116+
for (int i = 0; i < points.length; i++) {
117+
sb.append("%").append(Integer.toString(points[i], 16).toUpperCase(Locale.ROOT));
118+
}
119+
assertThat(sb.toString(), equalTo("%F0%9F%98%B1"));
120+
assertThat(responses.get(0), containsString("\"this message contains a surrogate pair %F0%9F%98%B1\""));
121+
}
122+
}
123+
74124
public void testAddsCombinedHeaderWithThreadContext() throws IOException {
75125
try (ThreadContext threadContext = new ThreadContext(Settings.EMPTY)) {
76126
final Set<ThreadContext> threadContexts = Collections.singleton(threadContext);
@@ -172,15 +222,28 @@ public void testWarningValueFromWarningHeader() throws InterruptedException {
172222
assertThat(DeprecationLogger.extractWarningValueFromWarningHeader(first), equalTo(s));
173223
}
174224

175-
public void testEscape() {
176-
assertThat(DeprecationLogger.escape("\\"), equalTo("\\\\"));
177-
assertThat(DeprecationLogger.escape("\""), equalTo("\\\""));
178-
assertThat(DeprecationLogger.escape("\\\""), equalTo("\\\\\\\""));
179-
assertThat(DeprecationLogger.escape("\"foo\\bar\""),equalTo("\\\"foo\\\\bar\\\""));
225+
public void testEscapeBackslashesAndQuotes() {
226+
assertThat(DeprecationLogger.escapeBackslashesAndQuotes("\\"), equalTo("\\\\"));
227+
assertThat(DeprecationLogger.escapeBackslashesAndQuotes("\""), equalTo("\\\""));
228+
assertThat(DeprecationLogger.escapeBackslashesAndQuotes("\\\""), equalTo("\\\\\\\""));
229+
assertThat(DeprecationLogger.escapeBackslashesAndQuotes("\"foo\\bar\""),equalTo("\\\"foo\\\\bar\\\""));
180230
// test that characters other than '\' and '"' are left unchanged
181-
String chars = "\t !" + range(0x23, 0x5b) + range(0x5d, 0x73) + range(0x80, 0xff);
231+
String chars = "\t !" + range(0x23, 0x24) + range(0x26, 0x5b) + range(0x5d, 0x73) + range(0x80, 0xff);
232+
final String s = new CodepointSetGenerator(chars.toCharArray()).ofCodePointsLength(random(), 16, 16);
233+
assertThat(DeprecationLogger.escapeBackslashesAndQuotes(s), equalTo(s));
234+
}
235+
236+
public void testEncode() {
237+
assertThat(DeprecationLogger.encode("\n"), equalTo("%0A"));
238+
assertThat(DeprecationLogger.encode("😱"), equalTo("%F0%9F%98%B1"));
239+
assertThat(DeprecationLogger.encode("福島深雪"), equalTo("%E7%A6%8F%E5%B3%B6%E6%B7%B1%E9%9B%AA"));
240+
assertThat(DeprecationLogger.encode("100%\n"), equalTo("100%25%0A"));
241+
// test that valid characters are left unchanged
242+
String chars = "\t !" + range(0x23, 0x24) + range(0x26, 0x5b) + range(0x5d, 0x73) + range(0x80, 0xff) + '\\' + '"';
182243
final String s = new CodepointSetGenerator(chars.toCharArray()).ofCodePointsLength(random(), 16, 16);
183-
assertThat(DeprecationLogger.escape(s), equalTo(s));
244+
assertThat(DeprecationLogger.encode(s), equalTo(s));
245+
// when no encoding is needed, the original string is returned (optimization)
246+
assertThat(DeprecationLogger.encode(s), IsSame.sameInstance(s));
184247
}
185248

186249
private String range(int lowerInclusive, int upperInclusive) {

qa/evil-tests/src/test/java/org/elasticsearch/common/logging/EvilLoggerTests.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ public void testConcurrentDeprecationLogger() throws IOException, UserException,
165165
final Set<String> actualWarningValues =
166166
warnings.stream().map(DeprecationLogger::extractWarningValueFromWarningHeader).collect(Collectors.toSet());
167167
for (int j = 0; j < 128; j++) {
168-
assertThat(actualWarningValues, hasItem(DeprecationLogger.escape("This is a maybe logged deprecation message" + j)));
168+
assertThat(
169+
actualWarningValues,
170+
hasItem(DeprecationLogger.escapeAndEncode("This is a maybe logged deprecation message" + j)));
169171
}
170172

171173
try {

test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ protected final void assertWarnings(String... expectedWarnings) {
318318
final Set<String> actualWarningValues =
319319
actualWarnings.stream().map(DeprecationLogger::extractWarningValueFromWarningHeader).collect(Collectors.toSet());
320320
for (String msg : expectedWarnings) {
321-
assertThat(actualWarningValues, hasItem(DeprecationLogger.escape(msg)));
321+
assertThat(actualWarningValues, hasItem(DeprecationLogger.escapeAndEncode(msg)));
322322
}
323323
assertEquals("Expected " + expectedWarnings.length + " warnings but found " + actualWarnings.size() + "\nExpected: "
324324
+ Arrays.asList(expectedWarnings) + "\nActual: " + actualWarnings, expectedWarnings.length, actualWarnings.size());

test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/DoSection.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ void checkWarningHeaders(final List<String> warningHeaders) {
263263
final List<String> missing = new ArrayList<>();
264264
// LinkedHashSet so that missing expected warnings come back in a predictable order which is nice for testing
265265
final Set<String> expected =
266-
new LinkedHashSet<>(expectedWarningHeaders.stream().map(DeprecationLogger::escape).collect(Collectors.toList()));
266+
new LinkedHashSet<>(expectedWarningHeaders.stream().map(DeprecationLogger::escapeAndEncode).collect(Collectors.toList()));
267267
for (final String header : warningHeaders) {
268268
final Matcher matcher = WARNING_HEADER_PATTERN.matcher(header);
269269
final boolean matches = matcher.matches();

0 commit comments

Comments
 (0)