In Java, we've got some code that takes a complex java object and serializes it to json. It then writes that json directly to the markup of a page, in a script tag, assigning it to a variable.
// Get object as JSON using Jackson
ObjectWriter jsonWriter = new ObjectMapper().writer().withDefaultPrettyPrinter();
String json = jsonWriter.writeValueAsString(complexObject);
// Write JSON out to page, and assign it to a javascript variable.
Writer out = environment.getOut();
out.write("var data = " + json);
The complex object can have end user content in it, which could open us up to XSS attacks.
How can I get a json version of the complex java object that has each json attribute HTML escaped, to protect against XSS injection?
I've read the OWASP XSS Guide and the best I've come up with so far is this, which HTML escapes the entire JSON string, then undoes the quotes, so it can be assigned to a variable in javascript. I'm sure there are better ways to do this, but this seems to work. Any suggestions?
private String objectToHtmlEscapedJson(Object value) {
try {
String result = jsonWriter.writeValueAsString(value);
result = StringEscapeUtils.escapeHtml(result);
result = result.replace(""", "\"");
return result;
} catch (JsonProcessingException e) {
return "null";
}
}
Escaping is the primary means to avoid cross-site scripting attacks. When escaping, you are effectively telling the web browser that the data you are sending should be treated as data and should not be interpreted in any other way.
The functionality to translate a JavaScript object into a string-based representation is hardly thrilling. But when the stars align, a simple JSON serialization operation can result in a significant XSS vulnerability.
Escaping won't fix XSS either But for HTML injection (XSS), it is necessary to sanitize—or more specifically, to escape—the user-controlled data. However, escaping is context-dependent. That is to say, if the same data is used in multiple places, a different escaping may be necessary.
How to prevent XSS attacks. To prevent XSS attacks, your application must validate all the input data, make sure that only the allowlisted data is allowed, and ensure that all variable output in a page is encoded before it is returned to the user.
A possible approach could be to iterate over the object entries and individually escape each key and value once the node is constructed by your chosen library.
Following my comment above, I've implemented a simple recursive solution using both Jackson (from your question) and GSON, a different library where objects are slightly easier to construct and the code is more readable. The escaping mechanism used is the OWASP Java Encoder:
private static JsonNode clean(JsonNode node) {
if(node.isValueNode()) { // Base case - we have a Number, Boolean or String
if(JsonNodeType.STRING == node.getNodeType()) {
// Escape all String values
return JsonNodeFactory.instance.textNode(Encode.forHtml(node.asText()));
} else {
return node;
}
} else { // Recursive case - iterate over JSON object entries
ObjectNode clean = JsonNodeFactory.instance.objectNode();
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
// Encode the key right away and encode the value recursively
clean.set(Encode.forHtml(entry.getKey()), clean(entry.getValue()));
}
return clean;
}
}
private static JsonElement clean(JsonElement elem) {
if (elem.isJsonPrimitive()) { // Base case - we have a Number, Boolean or String
JsonPrimitive primitive = elem.getAsJsonPrimitive();
if(primitive.isString()) {
// Escape all String values
return new JsonPrimitive(Encode.forHtml(primitive.getAsString()));
} else {
return primitive;
}
} else if (elem.isJsonArray()) { // We have an array - GSON requires handling this separately
JsonArray cleanArray = new JsonArray();
for(JsonElement arrayElement: elem.getAsJsonArray()) {
cleanArray.add(clean(arrayElement));
}
return cleanArray;
} else { // Recursive case - iterate over JSON object entries
JsonObject obj = elem.getAsJsonObject();
JsonObject clean = new JsonObject();
for(Map.Entry<String, JsonElement> entry : obj.entrySet()) {
// Encode the key right away and encode the value recursively
clean.add(Encode.forHtml(entry.getKey()), clean(entry.getValue()));
}
return clean;
}
}
Sample input (both libraries):
{
"nested": {
"<html>": "<script>(function(){alert('xss1')})();</script>"
},
"xss": "<script>(function(){alert('xss2')})();</script>"
}
Sample output (both libraries):
{
"nested": {
"<html>": "<script>(function(){alert('xss1')})();</script>"
},
"xss": "<script>(function(){alert('xss2')})();</script>"
}
Updating Paul Benn's answer of the Gson version to include json value being an array
private static JsonElement clean(JsonElement elem) {
if(elem.isJsonPrimitive()) { // Base case - we have a Number, Boolean or String
JsonPrimitive primitive = elem.getAsJsonPrimitive();
if(primitive.isString()) {
// Escape all String values
return new JsonPrimitive(Encode.forHtml(primitive.getAsString()));
} else {
return primitive;
}
} else if( elem.isJsonArray() ) { // If the object is an array "cars": ["toyota", "nissan", "bmw"]
JsonArray jsonA = elem.getAsJsonArray();
JsonArray cleanedNewArray = new JsonArray();
for(JsonElement jsonAE: jsonA) {
cleanedNewArray.add(clean(jsonAE));
}
return cleanedNewArray;
} else { // Recursive case - iterate over JSON object entries
JsonObject obj = elem.getAsJsonObject();
JsonObject clean = new JsonObject();
for(Map.Entry<String, JsonElement> entry : obj.entrySet()) {
// Encode the key right away and encode the value recursively
clean.add(Encode.forHtml(entry.getKey()), clean(entry.getValue()));
}
return clean;
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With