Java and XSS: How to html escape a JSON string to protect against XSS?
Solution 1
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:
Jackson
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;
}
}
GSON
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>"
}
Solution 2
I think Paul Benn's answer is the best approach overall, but if you don't want to iterate over the json nodes, you could consider using Encode.forHtmlContent, which doesn't escape quotes. I feel this is probably safe as I can't think of how introducing an additional quote into a quoted string could cause an exploit. I'll leave it to the reader to review the docs and decide for themselves!
ivy.xml
<dependency org="org.owasp.encoder" name="encoder" rev="1.2.1"/>
and some code to do the html encoding
private String objectToJson(Object value)
{
String result;
try
{
result = jsonWriter.writeValueAsString(value);
return Encode.forHtmlContent(result);
}
catch (JsonProcessingException e)
{
return "null";
}
}
Solution 3
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;
}
}
Solution 4
Adding a version of JKRo using Jackson with Esapi.
private JsonNode clean(JsonNode node, ObjectMapper mapper) {
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(ESAPI.encoder().encodeForHTML(node.asText()));
} else {
return node;
}
} else if(node.isArray()) { // If the object is an array "cars": ["toyota", "nissan", "bmw"]
ArrayNode cleanedNewArray = mapper.createArrayNode();
for (final JsonNode objNode : node) {
cleanedNewArray.add(clean(objNode, mapper));
}
return cleanedNewArray;
} 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(ESAPI.encoder().encodeForHTML(entry.getKey()), clean(entry.getValue(), mapper));
}
return clean;
}
}
Request Body:
{
"param1": "<input class='btn btn-default' value='0' placeholder='Ingrese sus datos'></input>",
"param3": [
{
"nombre" : "<input class='btn btn-default' value='0' placeholder='Ingrese sus datos'></input>",
"apellido": "<script>alert('Hola mundex');</script>"
},
{
"param4": {
"nombre" : "<input class='btn btn-default' value='0' placeholder='Ingrese sus datos'></input>",
"apellido": "<script>alert('Hola mundex');</script>"
}
}],
"param2": "alert('Hola')"
}
Response Body:
{
"param1": "<input class='btn btn-default' value='0' placeholder='Ingrese sus datos'></input>",
"param3": [
{
"nombre": "<input class='btn btn-default' value='0' placeholder='Ingrese sus datos'></input>",
"apellido": "<script>alert('Hola mundex');</script>"
},
{
"param4": {
"nombre": "<input class='btn btn-default' value='0' placeholder='Ingrese sus datos'></input>",
"apellido": "<script>alert('Hola mundex');</script>"
}
}
],
"param2": "alert('Hola')"
}
Brad Parks
Web programmer, interested in node js, cross platform development, and automating the things!
Updated on July 24, 2022Comments
-
Brad Parks over 1 year
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"; } }
-
ABC123 about 4 yearsYou may want to describe what is new in your update.