Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 50 additions & 2 deletions core/src/main/java/com/contentful/vault/BlobUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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 extends Serializable> T fromBlob(Class<T> clazz, byte[] blob)
static <T extends Serializable> T fromBlob(final Class<T> 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) {
Expand Down
103 changes: 103 additions & 0 deletions core/src/test/java/com/contentful/vault/BlobUtilsTest.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String> 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;
}
}
Loading