diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..b974002 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + branches: ["**"] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + java-version: "8" + distribution: temurin + cache: maven + + - name: Run core unit tests + run: ./mvnw test -pl core diff --git a/core/src/main/java/com/contentful/vault/BlobUtils.java b/core/src/main/java/com/contentful/vault/BlobUtils.java index 4d813b2..22cd074 100644 --- a/core/src/main/java/com/contentful/vault/BlobUtils.java +++ b/core/src/main/java/com/contentful/vault/BlobUtils.java @@ -19,19 +19,67 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InvalidClassException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; +import java.io.ObjectStreamClass; import java.io.Serializable; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; final class BlobUtils { + // Java serialization stream magic header (0xACED) + private static final int SERIAL_MAGIC_1 = (byte) 0xAC; + private static final int SERIAL_MAGIC_2 = (byte) 0xED; + // Generous upper bound; real blobs are small field maps/lists + private static final int MAX_BLOB_SIZE = 1024 * 1024; // 1 MB + + // Allowlisted types that may appear in deserialized blobs (top-level or nested within Maps/Lists). + private static final Set ALLOWED_CLASS_NAMES = new HashSet<>(Arrays.asList( + java.util.HashMap.class.getName(), + java.util.LinkedHashMap.class.getName(), + java.util.ArrayList.class.getName(), + java.lang.String.class.getName(), + java.lang.Integer.class.getName(), + java.lang.Long.class.getName(), + java.lang.Double.class.getName(), + java.lang.Float.class.getName(), + java.lang.Boolean.class.getName(), + java.lang.Number.class.getName(), + "[B" // byte[] + )); + + private static void validateBlobInput(byte[] blob) throws IOException { + if (blob == null || blob.length == 0) { + throw new IllegalArgumentException("Blob must not be null or empty"); + } + if (blob.length > MAX_BLOB_SIZE) { + throw new IllegalArgumentException("Blob exceeds maximum allowed size"); + } + if ((blob[0] & 0xFF) != (SERIAL_MAGIC_1 & 0xFF) || (blob[1] & 0xFF) != (SERIAL_MAGIC_2 & 0xFF)) { + throw new InvalidClassException("Invalid serialized format: missing Java serialization header"); + } + } + @SuppressWarnings("unchecked") - static T fromBlob(Class clazz, byte[] blob) + static T fromBlob(final Class clazz, byte[] blob) throws IOException, ClassNotFoundException { + validateBlobInput(blob); T result = null; ObjectInputStream ois = null; try { ByteArrayInputStream bos = new ByteArrayInputStream(blob); - ois = new ObjectInputStream(bos); + ois = new ObjectInputStream(bos) { + @Override protected Class resolveClass(ObjectStreamClass desc) + throws IOException, ClassNotFoundException { + String name = desc.getName(); + if (!name.equals(clazz.getName()) && !ALLOWED_CLASS_NAMES.contains(name)) { + throw new InvalidClassException("Unauthorized deserialization attempt", name); + } + return super.resolveClass(desc); + } + }; result = (T) ois.readObject(); } finally { if (ois != null) { diff --git a/core/src/test/java/com/contentful/vault/BlobUtilsTest.java b/core/src/test/java/com/contentful/vault/BlobUtilsTest.java new file mode 100644 index 0000000..bc6bb53 --- /dev/null +++ b/core/src/test/java/com/contentful/vault/BlobUtilsTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2018 Contentful GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.contentful.vault; + +import java.io.ByteArrayOutputStream; +import java.io.InvalidClassException; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class BlobUtilsTest { + + // --- round-trip (allowed types) --- + + @Test public void testHashMapRoundTrip() throws Exception { + HashMap map = new HashMap<>(); + map.put("key", "value"); + map.put("num", 42.0); + + byte[] blob = BlobUtils.toBlob(map); + HashMap result = BlobUtils.fromBlob(HashMap.class, blob); + + assertNotNull(result); + assertEquals("value", result.get("key")); + assertEquals(42.0, result.get("num")); + } + + @Test public void testArrayListRoundTrip() throws Exception { + ArrayList list = new ArrayList<>(); + list.add("a"); + list.add("b"); + + byte[] blob = BlobUtils.toBlob(list); + ArrayList result = BlobUtils.fromBlob(ArrayList.class, blob); + + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("a", result.get(0)); + } + + // --- malicious class rejection --- + + @Test(expected = InvalidClassException.class) + public void testMaliciousClassIsRejected() throws Exception { + byte[] maliciousBlob = serializeRaw(new MaliciousClass()); + BlobUtils.fromBlob(HashMap.class, maliciousBlob); + } + + // --- input validation --- + + @Test(expected = IllegalArgumentException.class) + public void testNullBlobRejected() throws Exception { + BlobUtils.fromBlob(HashMap.class, null); + } + + @Test(expected = IllegalArgumentException.class) + public void testEmptyBlobRejected() throws Exception { + BlobUtils.fromBlob(HashMap.class, new byte[0]); + } + + @Test(expected = InvalidClassException.class) + public void testInvalidHeaderRejected() throws Exception { + BlobUtils.fromBlob(HashMap.class, new byte[]{0x00, 0x01, 0x02, 0x03}); + } + + @Test(expected = IllegalArgumentException.class) + public void testOversizedBlobRejected() throws Exception { + BlobUtils.fromBlob(HashMap.class, new byte[1024 * 1024 + 1]); + } + + // --- helpers --- + + private static byte[] serializeRaw(Serializable obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(obj); + } + return baos.toByteArray(); + } + + static class MaliciousClass implements Serializable { + private static final long serialVersionUID = 1L; + } +}