@@ -10,8 +10,14 @@ package org.dexpace.sdk.serde.jackson
1010import com.fasterxml.jackson.core.JsonGenerator
1111import com.fasterxml.jackson.core.JsonParser
1212import com.fasterxml.jackson.databind.DeserializationFeature
13+ import com.fasterxml.jackson.databind.MapperFeature
1314import com.fasterxml.jackson.databind.ObjectMapper
1415import com.fasterxml.jackson.databind.SerializationFeature
16+ import com.fasterxml.jackson.databind.cfg.CoercionAction
17+ import com.fasterxml.jackson.databind.cfg.CoercionInputShape
18+ import com.fasterxml.jackson.databind.cfg.MutableCoercionConfig
19+ import com.fasterxml.jackson.databind.json.JsonMapper
20+ import com.fasterxml.jackson.databind.type.LogicalType
1521import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
1622import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
1723import com.fasterxml.jackson.module.kotlin.KotlinModule
@@ -28,6 +34,14 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule
2834 * every consumer to ship a new SDK release on each backward-compatible server change.
2935 * - [SerializationFeature.WRITE_DATES_AS_TIMESTAMPS] **disabled** — emits ISO-8601 strings
3036 * rather than numeric epoch millis, matching every public REST API.
37+ * - [MapperFeature.ALLOW_COERCION_OF_SCALARS] **disabled** plus per-type [CoercionAction.Fail]
38+ * rules — Jackson's defaults silently reshape mismatched scalars (a wire string `"5"` becomes a
39+ * numeric field, a number becomes a string, and so on), which masks malformed payloads. The SDK
40+ * rejects those cross-shape coercions so a contract violation surfaces as a failure rather than a
41+ * quietly wrong value. Numeric widening (an integer into a floating-point field) and genuinely
42+ * typed values are unaffected. See [lockDownScalarCoercion]. Auto-detection features
43+ * (`AUTO_DETECT_*`) are deliberately left at their defaults: Kotlin data-class binding relies on
44+ * them, and any further tightening belongs to codegen, which owns its own mapper.
3145 *
3246 * Modules registered, in order:
3347 * - [KotlinModule] — Kotlin data classes, default-argument support, value classes.
@@ -47,11 +61,19 @@ public object JacksonObjectMappers {
4761 * `enable`/`disable` features) but should treat the result as their own instance to mutate.
4862 */
4963 public fun defaultObjectMapper (): ObjectMapper {
50- val mapper = ObjectMapper ()
51- mapper.registerModule(KotlinModule .Builder ().build())
52- mapper.registerModule(JavaTimeModule ())
53- mapper.registerModule(Jdk8Module ())
54- mapper.registerModule(TristateModule ())
64+ // Built via JsonMapper.builder() so the MapperFeature toggle and coercion config are set
65+ // at build time — the runtime ObjectMapper.configure(MapperFeature, ...) setter is
66+ // deprecated in 2.18, and withCoercionConfig is the supported coercion entry point.
67+ val builder =
68+ JsonMapper
69+ .builder()
70+ .addModule(KotlinModule .Builder ().build())
71+ .addModule(JavaTimeModule ())
72+ .addModule(Jdk8Module ())
73+ .addModule(TristateModule ())
74+ .disable(MapperFeature .ALLOW_COERCION_OF_SCALARS )
75+ applyStrictScalarCoercion(builder)
76+ val mapper = builder.build()
5577 mapper.disable(DeserializationFeature .FAIL_ON_UNKNOWN_PROPERTIES )
5678 mapper.disable(SerializationFeature .WRITE_DATES_AS_TIMESTAMPS )
5779 // Caller-owned streams must not be closed by the mapper. Both write and read paths
@@ -61,4 +83,69 @@ public object JacksonObjectMappers {
6183 mapper.factory.disable(JsonParser .Feature .AUTO_CLOSE_SOURCE )
6284 return mapper
6385 }
86+
87+ /* *
88+ * Reject the lenient cross-shape scalar coercions Jackson enables by default, so a payload
89+ * whose JSON shape does not match the target type fails loudly instead of being silently
90+ * reshaped into a wrong value.
91+ *
92+ * This is the second half of the lockdown (the first being [MapperFeature.ALLOW_COERCION_OF_SCALARS]
93+ * disabled by the caller): per-[LogicalType] [CoercionAction.Fail] rules for the specific
94+ * cross-shape pairs, applied through [com.fasterxml.jackson.databind.cfg.MapperBuilder.withCoercionConfig]
95+ * so the rejection holds regardless of how a given scalar target consults coercion config:
96+ *
97+ * - string → integer / floating-point / boolean (the headline `"5"` → numeric case),
98+ * - floating-point → integer (the lossy `1.5` → `1` narrowing),
99+ * - boolean ↔ integer and boolean → floating-point,
100+ * - integer / floating-point / boolean → string,
101+ * - empty string → integer / floating-point / boolean (Jackson otherwise coerces `""` to a
102+ * null/zero scalar, masking a malformed payload).
103+ *
104+ * Untouched on purpose: numeric **widening** (an integer JSON value into a floating-point
105+ * field) stays legal because it is a representation-preserving conversion, not a shape mismatch;
106+ * and genuinely typed values bind exactly as before. The inverse — a floating-point JSON value
107+ * into an integer field — is rejected because it silently truncates (`1.5` → `1`). An empty
108+ * string into a textual field is left alone: `""` is a legitimate string value.
109+ */
110+ private fun applyStrictScalarCoercion (builder : JsonMapper .Builder ) {
111+ fun JsonMapper.Builder.failOn (
112+ target : LogicalType ,
113+ vararg shapes : CoercionInputShape ,
114+ ): JsonMapper .Builder =
115+ withCoercionConfig(target) { cfg: MutableCoercionConfig ->
116+ shapes.forEach { shape -> cfg.setCoercion(shape, CoercionAction .Fail ) }
117+ }
118+
119+ builder
120+ // string "5"/"1.5"/"true" (and an empty string) must not flow into numeric or boolean
121+ // fields, and a floating-point value must not be lossily narrowed into an integer field
122+ // (1.5 -> 1).
123+ .failOn(
124+ LogicalType .Integer ,
125+ CoercionInputShape .String ,
126+ CoercionInputShape .EmptyString ,
127+ CoercionInputShape .Boolean ,
128+ CoercionInputShape .Float ,
129+ )
130+ .failOn(
131+ LogicalType .Float ,
132+ CoercionInputShape .String ,
133+ CoercionInputShape .EmptyString ,
134+ CoercionInputShape .Boolean ,
135+ )
136+ .failOn(
137+ LogicalType .Boolean ,
138+ CoercionInputShape .String ,
139+ CoercionInputShape .EmptyString ,
140+ CoercionInputShape .Integer ,
141+ )
142+ // a non-string scalar must not be stringified into a textual field. An empty string is a
143+ // valid string, so EmptyString is intentionally absent here.
144+ .failOn(
145+ LogicalType .Textual ,
146+ CoercionInputShape .Integer ,
147+ CoercionInputShape .Float ,
148+ CoercionInputShape .Boolean ,
149+ )
150+ }
64151}
0 commit comments