Skip to content

Commit f7afa50

Browse files
committed
GROOVY-12273: Write config keys and values as data, not as source
ConfigObject.writeTo documents a round trip with ConfigSlurper.parse, which compiles its output as a Groovy script. Keys were written bare unless they were Groovy keywords, and values were rendered by FormatHelper.inspect, which quotes a String but not other types. What that produced was source rather than data, and it was read back as whatever it happened to parse as. Measured before the change, writing a ConfigObject and parsing it back: key 'a b' did not parse key "a'b" did not parse key "x = <statement>; y" *** executed on re-parse *** nested block under key 'a b' did not parse GString value holding a dollar did not parse StringBuilder value did not parse GString inside a list came back altered a value of any other type did not parse The executing case is the one that matters: a key is data, and an application which stores an attacker-influenced entry name and later persists the configuration would run it. Render every key as an identifier when it is one and as a quoted literal otherwise, rather than only quoting keywords, and give a quoted leading key the receiver it needs to open a statement, which is what keyword keys have always been given. Nested blocks accept a quoted key unchanged, so only the rendering moved. writeValue now receives a key path whose components have already been rendered, because it is also called with a composed path and must not quote the path as a whole. Carry a value which has no literal form over to its text so that it is rendered as a quoted String: a CharSequence which is not a String would otherwise be written double quoted, where a dollar is live, and a value of any other type would be written as a bare toString(). Collections and maps are converted through, which covers the same value nested inside them. Numbers and booleans already write as themselves and are untouched.
1 parent be150ef commit f7afa50

2 files changed

Lines changed: 216 additions & 15 deletions

File tree

src/main/java/groovy/util/ConfigObject.java

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@
3030
import java.io.BufferedWriter;
3131
import java.io.IOException;
3232
import java.io.Writer;
33+
import java.lang.reflect.Array;
3334
import java.net.URL;
35+
import java.util.ArrayList;
3436
import java.util.Collection;
3537
import java.util.HashMap;
3638
import java.util.LinkedHashMap;
39+
import java.util.List;
3740
import java.util.Map;
3841
import java.util.Properties;
3942
import java.util.Set;
@@ -251,20 +254,20 @@ private void writeConfig(String prefix, ConfigObject map, BufferedWriter out, in
251254

252255
if (configSize == 1 || DefaultGroovyMethods.asBoolean(dotsInKeys)) {
253256
if (firstSize == 1 && firstValue instanceof ConfigObject) {
254-
key = KEYWORDS.contains(key) ? FormatHelper.inspect(key) : key;
255-
String writePrefix = prefix + key + "." + firstKey + ".";
257+
key = renderKey(key);
258+
String writePrefix = prefix + key + "." + renderKey(String.valueOf(firstKey)) + ".";
256259
writeConfig(writePrefix, (ConfigObject) firstValue, out, tab, true);
257260
} else if (!DefaultGroovyMethods.asBoolean(dotsInKeys) && firstValue instanceof ConfigObject) {
258261
writeNode(key, space, tab, value, out);
259262
} else {
260263
for (Object j : value.keySet()) {
261264
Object v2 = value.get(j);
262-
Object k2 = ((String) j).indexOf('.') > -1 ? FormatHelper.inspect(j) : j;
265+
Object k2 = renderKey((String) j);
263266
if (v2 instanceof ConfigObject) {
264-
key = KEYWORDS.contains(key) ? FormatHelper.inspect(key) : key;
267+
key = renderKey(key);
265268
writeConfig(prefix + key, (ConfigObject) v2, out, tab, false);
266269
} else {
267-
writeValue(key + "." + k2, space, prefix, v2, out);
270+
writeValue(renderKey(key) + "." + k2, space, prefix, v2, out);
268271
}
269272
}
270273
}
@@ -273,30 +276,111 @@ private void writeConfig(String prefix, ConfigObject map, BufferedWriter out, in
273276
}
274277
}
275278
} else {
276-
writeValue(key, space, prefix, v, out);
279+
writeValue(renderKey(key), space, prefix, v, out);
277280
}
278281
}
279282
}
280283

281-
private static void writeValue(String key, String space, String prefix, Object value, BufferedWriter out) throws IOException {
282-
// key = key.indexOf('.') > -1 ? InvokerHelper.inspect(key) : key;
283-
boolean isKeyword = KEYWORDS.contains(key);
284-
key = isKeyword ? FormatHelper.inspect(key) : key;
285-
286-
if (!StringGroovyMethods.asBoolean(prefix) && isKeyword) prefix = "this.";
287-
out.append(space).append(prefix).append(key).append('=').append(FormatHelper.inspect(value));
284+
/**
285+
* Writes one entry, given a key path whose components have already been rendered.
286+
*
287+
* @param keyPath the rendered key path, such as {@code foo} or {@code foo.'a b'}
288+
*/
289+
private static void writeValue(String keyPath, String space, String prefix, Object value, BufferedWriter out) throws IOException {
290+
// A quoted key cannot open a statement on its own, so it needs a receiver, exactly as a
291+
// keyword key has always done. The statement opens with the prefix when there is one.
292+
String statementStart = StringGroovyMethods.asBoolean(prefix) ? prefix : keyPath;
293+
if (statementStart.startsWith("'")) prefix = "this." + prefix;
294+
out.append(space).append(prefix).append(keyPath).append('=').append(renderValue(value));
288295
out.newLine();
289296
}
290297

291298
private void writeNode(String key, String space, int tab, ConfigObject value, BufferedWriter out) throws IOException {
292-
key = KEYWORDS.contains(key) ? FormatHelper.inspect(key) : key;
293-
out.append(space).append(key).append(" {");
299+
out.append(space).append(renderKey(key)).append(" {");
294300
out.newLine();
295301
writeConfig("", value, out, tab + 1, true);
296302
out.append(space).append('}');
297303
out.newLine();
298304
}
299305

306+
/**
307+
* Renders a key as it must appear in the written configuration: bare when it is a plain
308+
* identifier, and as a quoted literal otherwise. A key which is not an identifier would
309+
* otherwise be written as though it were source, and read back as whatever it happened to
310+
* parse as.
311+
*
312+
* @param key the key to render
313+
* @return the key as it should be written
314+
*/
315+
private static String renderKey(String key) {
316+
return isIdentifier(key) ? key : FormatHelper.inspect(key);
317+
}
318+
319+
private static boolean isIdentifier(String key) {
320+
if (key == null || key.isEmpty() || KEYWORDS.contains(key)) return false;
321+
if (!Character.isJavaIdentifierStart(key.charAt(0))) return false;
322+
for (int i = 1, n = key.length(); i < n; i += 1) {
323+
if (!Character.isJavaIdentifierPart(key.charAt(i))) return false;
324+
}
325+
return true;
326+
}
327+
328+
/**
329+
* Renders a value as a literal which reads back as the same data.
330+
*
331+
* @param value the value to render
332+
* @return the value as it should be written
333+
*/
334+
private static String renderValue(Object value) {
335+
return FormatHelper.inspect(asWritableData(value));
336+
}
337+
338+
/**
339+
* Converts a value into something {@link FormatHelper#inspect} renders as inert data.
340+
* <p>
341+
* A {@link CharSequence} which is not a {@code String} is rendered as a double quoted
342+
* literal, in which a dollar is live, so its text is carried over to a {@code String} and
343+
* rendered single quoted instead. An array has no literal form, so it is carried over to a
344+
* {@code List}, element by element, and reads back as one. A value of any other type
345+
* without a literal form would be written as a bare {@code toString()}, which is not data
346+
* at all, so its text is carried over in the same way. Numbers and booleans already write
347+
* as themselves.
348+
*
349+
* @param value the value to convert
350+
* @return a value whose rendering is data
351+
*/
352+
private static Object asWritableData(Object value) {
353+
if (value == null || value instanceof String || value instanceof Number || value instanceof Boolean) {
354+
return value;
355+
}
356+
if (value instanceof CharSequence) {
357+
return value.toString();
358+
}
359+
if (value instanceof Map<?, ?> map) {
360+
Map<Object, Object> converted = new LinkedHashMap<>(map.size());
361+
for (Map.Entry<?, ?> entry : map.entrySet()) {
362+
converted.put(asWritableData(entry.getKey()), asWritableData(entry.getValue()));
363+
}
364+
return converted;
365+
}
366+
if (value instanceof Collection<?> collection) {
367+
List<Object> converted = new ArrayList<>(collection.size());
368+
for (Object element : collection) {
369+
converted.add(asWritableData(element));
370+
}
371+
return converted;
372+
}
373+
if (value.getClass().isArray()) {
374+
int length = Array.getLength(value);
375+
List<Object> converted = new ArrayList<>(length);
376+
for (int i = 0; i < length; i += 1) {
377+
converted.add(asWritableData(Array.get(value, i)));
378+
}
379+
return converted;
380+
}
381+
return value.toString();
382+
}
383+
300384
private static Properties convertValuesToString(Map props) {
301385
Properties newProps = new Properties();
302386

src/test/groovy/groovy/util/ConfigObjectTest.groovy

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,121 @@ development {
9696
def config = new ConfigSlurper().parse(configString)
9797
assert config == new ConfigSlurper().parse(config.prettyPrint())
9898
}
99+
100+
// GROOVY-12273: writeTo documents a round trip with ConfigSlurper.parse, which compiles the
101+
// output as a Groovy script. Anything written as source rather than as a literal is read
102+
// back as whatever it happens to parse as, up to and including running.
103+
private static void assertRoundTrips(String description, ConfigObject original) {
104+
def written = new StringWriter()
105+
original.writeTo(written)
106+
def text = written.toString()
107+
def reparsed
108+
try {
109+
reparsed = new ConfigSlurper().parse(text)
110+
} catch (Exception e) {
111+
assert false, "$description did not parse back: ${e.message}\n$text"
112+
}
113+
// The contract is that data survives, not that types do: a value with no literal form,
114+
// such as a StringBuilder, necessarily comes back as its text.
115+
assert asText(reparsed) == asText(original), "$description changed across the round trip\n$text"
116+
}
117+
118+
private static Object asText(Object value) {
119+
if (value instanceof CharSequence) return value.toString()
120+
if (value instanceof Map) return value.collectEntries { k, v -> [(asText(k)): asText(v)] }
121+
if (value instanceof Collection) return value.collect { asText(it) }
122+
if (value != null && value.class.array) return value.collect { asText(it) }
123+
value
124+
}
125+
126+
private static ConfigObject configOf(Map entries) {
127+
def config = new ConfigObject()
128+
entries.each { k, v -> config.put(k, v) }
129+
config
130+
}
131+
132+
@Test
133+
void testKeysThatAreNotIdentifiersRoundTrip() {
134+
[
135+
'a keyword' : 'class',
136+
'a space' : 'a b',
137+
'a quote' : "a'b",
138+
'a backslash' : 'a\\b',
139+
'a dot' : 'a.b',
140+
'a leading digit' : '1abc',
141+
'a hyphen' : 'a-b',
142+
'empty' : '',
143+
].each { description, key ->
144+
assertRoundTrips("key with $description", configOf([(key): 1]))
145+
}
146+
}
147+
148+
@Test
149+
void testKeyCannotSmuggleCodeIntoTheRoundTrip() {
150+
def marker = 'groovy.test.configObjectInjection'
151+
System.clearProperty(marker)
152+
// Concatenated rather than interpolated: a ConfigObject key must be a String.
153+
def key = 'x = System.setProperty(\'' + marker + '\', \'yes\'); y'
154+
155+
assertRoundTrips('key holding an assignment', configOf([(key): 1]))
156+
assert System.getProperty(marker) == null, 'a key was executed rather than read as data'
157+
}
158+
159+
@Test
160+
void testNestedConfigUnderAKeyThatIsNotAnIdentifierRoundTrips() {
161+
def nested = new ConfigObject()
162+
nested.p = 1
163+
nested.q = 2
164+
assertRoundTrips('nested block under an awkward key', configOf(['a b': nested]))
165+
}
166+
167+
@Test
168+
void testValuesWhoseTextContainsADollarRoundTrip() {
169+
def dollar = '$'
170+
[
171+
'String' : 'costs $5',
172+
'GString' : "costs ${dollar}5",
173+
'StringBuilder' : new StringBuilder('costs $5'),
174+
'GString in a list' : ["a ${dollar}b"],
175+
'GString in a map' : [k: "a ${dollar}b"],
176+
].each { description, value ->
177+
assertRoundTrips("value of type $description", configOf([foo: value]))
178+
}
179+
}
180+
181+
@Test
182+
void testAwkwardKeysInsideFlattenedKeyChainsRoundTrip() {
183+
// single-entry chains are written as dotted prefixes; every component must be rendered,
184+
// and a chain opening with a quoted key needs a receiver to parse as navigation
185+
def middle = new ConfigObject()
186+
middle.x.'a b'.y = 1
187+
assertRoundTrips('awkward key in the middle of a chain', middle)
188+
189+
def leading = new ConfigObject()
190+
leading.'a b'.c.d = 1
191+
assertRoundTrips('awkward key opening a chain', leading)
192+
}
193+
194+
@Test
195+
void testArrayValuesRoundTripAsLists() {
196+
// arrays have no literal form; they are written as list literals and read back as lists
197+
[
198+
'String[]' : ['one', 'two'] as String[],
199+
'int[]' : [1, 2, 3] as int[],
200+
'array inside a list' : [[1, 2] as int[]],
201+
].each { description, value ->
202+
assertRoundTrips("value of type $description", configOf([foo: value]))
203+
}
204+
}
205+
206+
@Test
207+
void testValuesThatWriteAsThemselvesAreUnchanged() {
208+
def written = new StringWriter()
209+
configOf([i: 42, d: 1.5, b: true, s: 'plain']).writeTo(written)
210+
def text = written.toString()
211+
assert text.contains('i=42')
212+
assert text.contains('d=1.5')
213+
assert text.contains('b=true')
214+
assert text.contains("s='plain'")
215+
}
99216
}

0 commit comments

Comments
 (0)