Skip to content

Commit 16ec6ff

Browse files
authored
fix(kafka): trust declared Avro types under Avro 1.12.2 class allowlist (#2688)
* fix(kafka): trust declared Avro types under Avro 1.12.2 class allowlist Avro 1.12.2 only loads classes trusted by ClassSecurityValidator. KafkaAvroDeserializer now trusts the exact class names of the records, enums and fixed types in the declared type's compiled schema and composes this allowlist with the existing global validator. * refactor(kafka): merge enum and fixed switch cases * refactor(kafka): explain empty default switch case
1 parent f628502 commit 16ec6ff

7 files changed

Lines changed: 158 additions & 3 deletions

File tree

‎docs/utilities/kafka.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,35 @@ Handle errors gracefully when processing Kafka messages to ensure your applicati
560560
}
561561
```
562562

563+
### Trusted Avro classes
564+
565+
Starting with version 1.12.2, Apache Avro only loads classes that its JVM-wide `ClassSecurityValidator` trusts. By default, Avro trusts a small set of JDK types and no application classes.
566+
567+
Powertools trusts the Avro class you declare in your handler signature, for example `Order` in `ConsumerRecords<String, Order>`, and the records, enums and fixed types nested in its compiled schema. Powertools trusts no other classes. This is safe because Powertools never reads a schema from the Kafka record. It always decodes with the schema compiled into your declared class, so a producer cannot make Avro load any other class.
568+
569+
To trust additional classes, add them in a static initializer of your handler class:
570+
571+
```java
572+
import org.apache.avro.util.ClassSecurityValidator;
573+
574+
public class OrderHandler implements RequestHandler<ConsumerRecords<String, Order>, String> {
575+
576+
static {
577+
ClassSecurityValidator.setGlobal(ClassSecurityValidator.composite(
578+
ClassSecurityValidator.getGlobal(),
579+
ClassSecurityValidator.builder().add(MyClass.class).build()));
580+
}
581+
582+
// ...
583+
}
584+
```
585+
586+
<!-- prettier-ignore -->
587+
!!! warning "Always compose with `ClassSecurityValidator.getGlobal()`"
588+
Calling `ClassSecurityValidator.setGlobal` without composing `ClassSecurityValidator.getGlobal()` replaces the Powertools allowlist, and Avro rejects your declared classes.
589+
590+
You can also list trusted classes or packages with the `org.apache.avro.SERIALIZABLE_CLASSES` and `org.apache.avro.SERIALIZABLE_PACKAGES` system properties, for example `JAVA_TOOL_OPTIONS=-Dorg.apache.avro.SERIALIZABLE_PACKAGES=com.example.model`. Setting `SERIALIZABLE_PACKAGES` to `*` turns off the protection for the whole JVM.
591+
563592
### Integrating with Idempotency
564593

565594
When processing Kafka messages in Lambda, failed batches can result in message reprocessing. The idempotency utility prevents duplicate processing by tracking which messages have already been handled, ensuring each message is processed exactly once.

‎examples/powertools-examples-kafka/pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<maven.compiler.source>17</maven.compiler.source>
1212
<maven.compiler.target>17</maven.compiler.target>
1313
<aspectj.version>1.9.20.1</aspectj.version>
14-
<avro.version>1.12.1</avro.version>
14+
<avro.version>1.12.2</avro.version>
1515
<protobuf.version>4.35.0</protobuf.version>
1616
</properties>
1717

‎examples/powertools-examples-kafka/tools/pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<properties>
1212
<maven.compiler.source>17</maven.compiler.source>
1313
<maven.compiler.target>17</maven.compiler.target>
14-
<avro.version>1.12.1</avro.version>
14+
<avro.version>1.12.2</avro.version>
1515
<protobuf.version>4.31.0</protobuf.version>
1616
<kafka-clients.version>4.0.2</kafka-clients.version>
1717
</properties>

‎powertools-kafka/pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
<properties>
3737
<kafka-clients.version>4.3.1</kafka-clients.version>
38-
<avro.version>1.12.1</avro.version>
38+
<avro.version>1.12.2</avro.version>
3939
<protobuf.version>4.35.0</protobuf.version>
4040
<lambda-serialization.version>1.4.1</lambda-serialization.version>
4141
</properties>

‎powertools-kafka/src/main/java/software/amazon/lambda/powertools/kafka/serializers/KafkaAvroDeserializer.java‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,33 @@
1313
package software.amazon.lambda.powertools.kafka.serializers;
1414

1515
import java.io.IOException;
16+
import java.util.Set;
17+
import java.util.concurrent.ConcurrentHashMap;
1618

19+
import org.apache.avro.Schema;
1720
import org.apache.avro.io.DatumReader;
1821
import org.apache.avro.io.Decoder;
1922
import org.apache.avro.io.DecoderFactory;
23+
import org.apache.avro.specific.SpecificData;
2024
import org.apache.avro.specific.SpecificDatumReader;
2125
import org.apache.avro.specific.SpecificRecordBase;
26+
import org.apache.avro.util.ClassSecurityValidator;
2227

2328
/**
2429
* Deserializer for Kafka records using Avro format.
2530
*/
2631
public class KafkaAvroDeserializer extends AbstractKafkaDeserializer {
2732

33+
private final Set<Class<?>> trustedTypes = ConcurrentHashMap.newKeySet();
34+
private final Set<String> trustedClassNames = ConcurrentHashMap.newKeySet();
35+
2836
@Override
2937
protected <T> T deserializeObject(byte[] data, Class<T> type, SchemaRegistryType schemaRegistryType)
3038
throws IOException {
3139
// If no Avro generated class is passed we cannot deserialize using Avro
3240
if (SpecificRecordBase.class.isAssignableFrom(type)) {
3341
try {
42+
trust(type);
3443
DatumReader<T> datumReader = new SpecificDatumReader<>(type);
3544
Decoder decoder = DecoderFactory.get().binaryDecoder(data, null);
3645

@@ -44,4 +53,38 @@ protected <T> T deserializeObject(byte[] data, Class<T> type, SchemaRegistryType
4453
+ "Consider using an alternative Deserializer.");
4554
}
4655
}
56+
57+
// Avro 1.12.2+ only loads classes trusted by ClassSecurityValidator. We decode with the schema compiled into the
58+
// declared type and never with a schema from the record, so Avro only loads classes the developer authored.
59+
private void trust(Class<?> type) {
60+
if (trustedTypes.contains(type)) {
61+
return;
62+
}
63+
synchronized (this) {
64+
if (trustedTypes.isEmpty()) {
65+
ClassSecurityValidator.setGlobal(ClassSecurityValidator.composite(ClassSecurityValidator.getGlobal(),
66+
clazz -> trustedClassNames.contains(clazz.getName())));
67+
}
68+
collectNamedTypes(SpecificData.get().getSchema(type));
69+
trustedTypes.add(type);
70+
}
71+
}
72+
73+
private void collectNamedTypes(Schema schema) {
74+
switch (schema.getType()) {
75+
case RECORD -> {
76+
// Skip records we have already seen to stop on recursive schemas
77+
if (trustedClassNames.add(SpecificData.getClassName(schema))) {
78+
schema.getFields().forEach(field -> collectNamedTypes(field.schema()));
79+
}
80+
}
81+
case ENUM, FIXED -> trustedClassNames.add(SpecificData.getClassName(schema));
82+
case ARRAY -> collectNamedTypes(schema.getElementType());
83+
case MAP -> collectNamedTypes(schema.getValueType());
84+
case UNION -> schema.getTypes().forEach(this::collectNamedTypes);
85+
default -> {
86+
// Primitive types have no generated class to trust
87+
}
88+
}
89+
}
4790
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"namespace": "software.amazon.lambda.powertools.kafka.serializers.test.avro",
3+
"type": "record",
4+
"name": "TestOrder",
5+
"fields": [
6+
{"name": "id", "type": "string"},
7+
{
8+
"name": "customer",
9+
"type": ["null", {
10+
"type": "record",
11+
"name": "TestCustomer",
12+
"fields": [
13+
{"name": "name", "type": "string"}
14+
]
15+
}],
16+
"default": null
17+
},
18+
{
19+
"name": "status",
20+
"type": {"type": "enum", "name": "TestOrderStatus", "symbols": ["PENDING", "SHIPPED"]}
21+
}
22+
]
23+
}

‎powertools-kafka/src/test/java/software/amazon/lambda/powertools/kafka/serializers/KafkaAvroDeserializerTest.java‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,40 @@
1313
package software.amazon.lambda.powertools.kafka.serializers;
1414

1515
import static org.assertj.core.api.Assertions.assertThat;
16+
import static org.assertj.core.api.Assertions.assertThatCode;
1617
import static org.assertj.core.api.Assertions.assertThatThrownBy;
1718
import static software.amazon.lambda.powertools.kafka.testutils.TestUtils.serializeAvro;
1819

1920
import java.io.IOException;
2021

22+
import org.apache.avro.util.ClassSecurityValidator;
23+
import org.apache.avro.util.ClassSecurityValidator.ClassSecurityPredicate;
24+
import org.junit.jupiter.api.AfterEach;
2125
import org.junit.jupiter.api.BeforeEach;
2226
import org.junit.jupiter.api.Test;
2327

28+
import software.amazon.lambda.powertools.kafka.serializers.test.avro.TestCustomer;
29+
import software.amazon.lambda.powertools.kafka.serializers.test.avro.TestOrder;
30+
import software.amazon.lambda.powertools.kafka.serializers.test.avro.TestOrderStatus;
2431
import software.amazon.lambda.powertools.kafka.serializers.test.avro.TestProduct;
32+
import software.amazon.lambda.powertools.kafka.testutils.TestProductPojo;
2533

2634
class KafkaAvroDeserializerTest {
2735

2836
private KafkaAvroDeserializer deserializer;
37+
private ClassSecurityPredicate originalValidator;
2938

3039
@BeforeEach
3140
void setUp() {
41+
originalValidator = ClassSecurityValidator.getGlobal();
3242
deserializer = new KafkaAvroDeserializer();
3343
}
3444

45+
@AfterEach
46+
void tearDown() {
47+
ClassSecurityValidator.setGlobal(originalValidator);
48+
}
49+
3550
@Test
3651
void shouldThrowExceptionWhenTypeIsNotAvroSpecificRecord() {
3752
// Given
@@ -73,4 +88,49 @@ void shouldThrowExceptionWhenDeserializingInvalidAvroData() {
7388
.hasMessageContaining("Failed to deserialize Avro data");
7489
}
7590

91+
@Test
92+
void shouldDeserializeRecordWithNestedRecordAndEnum() throws IOException {
93+
// Given
94+
TestOrder order = new TestOrder("order-1", new TestCustomer("Jane"), TestOrderStatus.SHIPPED);
95+
byte[] avroData = serializeAvro(order);
96+
97+
// When
98+
TestOrder result = deserializer.deserializeObject(avroData, TestOrder.class,
99+
AbstractKafkaDeserializer.SchemaRegistryType.NONE);
100+
101+
// Then
102+
assertThat(result).isEqualTo(order);
103+
}
104+
105+
@Test
106+
void shouldNotTrustClassesOutsideDeclaredSchema() throws IOException {
107+
// Given
108+
byte[] avroData = serializeAvro(new TestProduct(123, "Test Product", 99.99));
109+
110+
// When
111+
deserializer.deserializeObject(avroData, TestProduct.class, AbstractKafkaDeserializer.SchemaRegistryType.NONE);
112+
113+
// Then
114+
assertThatThrownBy(() -> ClassSecurityValidator.validate(TestProductPojo.class))
115+
.isInstanceOf(SecurityException.class);
116+
}
117+
118+
@Test
119+
void shouldKeepValidatorConfiguredBeforeFirstUse() throws IOException {
120+
// Given
121+
ClassSecurityValidator.setGlobal(ClassSecurityValidator.builder().add(TestProductPojo.class).build());
122+
byte[] avroData = serializeAvro(new TestProduct(123, "Test Product", 99.99));
123+
124+
// When
125+
TestProduct result = deserializer.deserializeObject(avroData, TestProduct.class,
126+
AbstractKafkaDeserializer.SchemaRegistryType.NONE);
127+
128+
// Then
129+
assertThat(result.getId()).isEqualTo(123);
130+
assertThatCode(() -> {
131+
ClassSecurityValidator.validate(TestProductPojo.class);
132+
ClassSecurityValidator.validate(TestProduct.class);
133+
}).doesNotThrowAnyException();
134+
}
135+
76136
}

0 commit comments

Comments
 (0)