diff --git a/src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java b/src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java new file mode 100644 index 0000000..f2a88d8 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java @@ -0,0 +1,75 @@ +package com.ankurm.jackson3.part7security; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import tools.jackson.databind.DefaultTyping; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; + +/** + * Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/ + * Section: "The Safe Alternative: PolymorphicTypeValidator" + * + * The post's snippet in working Jackson 3 form. Note DefaultTyping is a top-level + * enum in tools.jackson.databind, not ObjectMapper.DefaultTyping as in Jackson 2. + * + * Default typing remains a last resort. Prefer H01. This exists because legacy object + * graphs and plugin systems sometimes genuinely need it, and when they do, the + * allowlist has to be provable — hence the negative test at the bottom. + */ +public class H03PolymorphicTypeValidatorAllowlist { + + public abstract static class BasePayload { } + public static class SafePayload extends BasePayload { + public String note; + public SafePayload() { } + public SafePayload(String n) { note = n; } + @Override public String toString() { return "SafePayload[" + note + "]"; } + } + + /** Deliberately outside the allowlisted base type. */ + public static class RoguePayload { + public String note; + } + + public static class Envelope { + public Object body; // the field default typing has to resolve + public Envelope() { } + public Envelope(Object b) { body = b; } + } + + public static void main(String[] args) { + // Permit ONLY the envelope and our own payload hierarchy. Anything else is + // refused at type-resolution time, before any class is instantiated. + // + // Note the gotcha: with DefaultTyping.NON_FINAL, Jackson writes a type id for + // the ROOT object too, so Envelope must be allowlisted as well. Allowlisting + // only BasePayload makes even the happy path fail — which is how most people + // first meet this API. + PolymorphicTypeValidator safeTypeValidator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType(Envelope.class) + .allowIfSubType(BasePayload.class) + .build(); + + JsonMapper mapper = JsonMapper.builder() + .activateDefaultTyping(safeTypeValidator, + DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY) + .build(); + + String allowed = mapper.writeValueAsString(new Envelope(new SafePayload("ok"))); + System.out.println("allowed written : " + allowed); + System.out.println("allowed read : " + + ((Envelope) mapper.readValue(allowed, Envelope.class)).body); + + // Negative test: a class outside the allowlist is rejected even though it + // exists on the classpath and would deserialise perfectly well otherwise. + String rogue = "{\"body\":[\"" + RoguePayload.class.getName() + "\",{\"note\":\"pwn\"}]}"; + try { + mapper.readValue(rogue, Envelope.class); + System.out.println("rogue : UNEXPECTEDLY ACCEPTED"); + } catch (Exception e) { + System.out.println("rogue : rejected with " + e.getClass().getSimpleName()); + } + } +}