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

13,125

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": {
        "&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;"
}

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": "&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;"

}

Share:
13,125
Brad Parks
Author by

Brad Parks

Web programmer, interested in node js, cross platform development, and automating the things!

Updated on July 24, 2022

Comments

  • Brad Parks
    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("&quot;", "\"");
            return result;
        } catch (JsonProcessingException e) {
            return "null";
        }
    }
    
  • ABC123
    ABC123 about 4 years
    You may want to describe what is new in your update.