Java and XSS: How to html escape a JSON string to protect against XSS?

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": {
        "&lt;html&gt;": "&lt;script&gt;(function(){alert(&#39;xss1&#39;)})();&lt;/script&gt;"
    },
    "xss": "&lt;script&gt;(function(){alert(&#39;xss2&#39;)})();&lt;/script&gt;"
}

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";
    }
}

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;
    }
}


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": "&lt;input class&#x3d;&#x27;btn btn-default&#x27; value&#x3d;&#x27;0&#x27; placeholder&#x3d;&#x27;Ingrese sus datos&#x27;&gt;&lt;&#x2f;input&gt;",
"param3": [
    {
        "nombre": "&lt;input class&#x3d;&#x27;btn btn-default&#x27; value&#x3d;&#x27;0&#x27; placeholder&#x3d;&#x27;Ingrese sus datos&#x27;&gt;&lt;&#x2f;input&gt;",
        "apellido": "&lt;script&gt;alert&#x28;&#x27;Hola mundex&#x27;&#x29;&#x3b;&lt;&#x2f;script&gt;"
    },
    {
        "param4": {
            "nombre": "&lt;input class&#x3d;&#x27;btn btn-default&#x27; value&#x3d;&#x27;0&#x27; placeholder&#x3d;&#x27;Ingrese sus datos&#x27;&gt;&lt;&#x2f;input&gt;",
            "apellido": "&lt;script&gt;alert&#x28;&#x27;Hola mundex&#x27;&#x29;&#x3b;&lt;&#x2f;script&gt;"
        }
    }
],
"param2": "alert&#x28;&#x27;Hola&#x27;&#x29;"

}