Skip to content

Commit bf7bc7d

Browse files
committed
fix(dataconverter): harden custom converter payload handling
Signed-off-by: “Kevin” <kevlar_ksb@yahoo.com>
1 parent ea146ac commit bf7bc7d

3 files changed

Lines changed: 86 additions & 42 deletions

File tree

src/main/java/com/uber/cadence/samples/dataconverter/CompressedJsonDataConverter.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,28 @@
3434
* <p>For repetitive JSON payloads this typically achieves 60-80% size reduction, lowering storage
3535
* cost and bandwidth without changing any workflow or activity code. Apply by setting it on the
3636
* {@code WorkflowClientOptions} used by both the worker and any client that triggers the workflow.
37+
* The decode path caps decompressed payloads to avoid unbounded memory growth on malformed input.
3738
*/
3839
public final class CompressedJsonDataConverter implements DataConverter {
3940

41+
/** Production code should choose a limit appropriate for its workflow payload contract. */
42+
public static final int DEFAULT_MAX_DECOMPRESSED_BYTES = 10 * 1024 * 1024;
43+
4044
private static final DataConverter delegate = JsonDataConverter.getInstance();
4145

46+
private final int maxDecompressedBytes;
47+
48+
public CompressedJsonDataConverter() {
49+
this(DEFAULT_MAX_DECOMPRESSED_BYTES);
50+
}
51+
52+
public CompressedJsonDataConverter(int maxDecompressedBytes) {
53+
if (maxDecompressedBytes <= 0) {
54+
throw new IllegalArgumentException("maxDecompressedBytes must be positive");
55+
}
56+
this.maxDecompressedBytes = maxDecompressedBytes;
57+
}
58+
4259
@Override
4360
public byte[] toData(Object... values) throws DataConverterException {
4461
if (values == null || values.length == 0) {
@@ -65,23 +82,27 @@ public <T> T fromData(byte[] content, Class<T> valueClass, Type valueType)
6582
if (content == null || content.length == 0) {
6683
return delegate.fromData(content, valueClass, valueType);
6784
}
68-
return delegate.fromData(decompress(content), valueClass, valueType);
85+
return delegate.fromData(decompress(content, maxDecompressedBytes), valueClass, valueType);
6986
}
7087

7188
@Override
7289
public Object[] fromDataArray(byte[] content, Type... valueTypes) throws DataConverterException {
7390
if (content == null || content.length == 0) {
7491
return delegate.fromDataArray(content, valueTypes);
7592
}
76-
return delegate.fromDataArray(decompress(content), valueTypes);
93+
return delegate.fromDataArray(decompress(content, maxDecompressedBytes), valueTypes);
7794
}
7895

79-
private static byte[] decompress(byte[] content) throws DataConverterException {
96+
private static byte[] decompress(byte[] content, int maxBytes) throws DataConverterException {
8097
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(content));
8198
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
8299
byte[] buf = new byte[4096];
83100
int read;
84101
while ((read = gzip.read(buf)) != -1) {
102+
if (out.size() > maxBytes - read) {
103+
throw new DataConverterException(
104+
"Gunzip payload exceeds maximum size of " + maxBytes + " bytes", null);
105+
}
85106
out.write(buf, 0, read);
86107
}
87108
return out.toByteArray();

src/main/java/com/uber/cadence/samples/dataconverter/LocalFsBlobStore.java

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@
1818
package com.uber.cadence.samples.dataconverter;
1919

2020
import java.io.IOException;
21+
import java.nio.charset.StandardCharsets;
2122
import java.nio.file.Files;
2223
import java.nio.file.Path;
2324
import java.nio.file.Paths;
25+
import java.security.MessageDigest;
26+
import java.security.NoSuchAlgorithmException;
2427

2528
/**
2629
* {@link BlobStore} implementation backed by the local filesystem.
2730
*
28-
* <p>The default zero-config implementation used by {@link S3OffloadDataConverter} when running
29-
* the demo without real AWS. Files are written under {@code
31+
* <p>The default zero-config implementation used by {@link S3OffloadDataConverter} when running the
32+
* demo without real AWS. Files are written under {@code
3033
* ${java.io.tmpdir}/cadence-java-samples-data-s3/}.
3134
*/
3235
public final class LocalFsBlobStore implements BlobStore {
@@ -38,11 +41,14 @@ public LocalFsBlobStore() {
3841
}
3942

4043
public LocalFsBlobStore(Path baseDir) {
41-
this.baseDir = baseDir;
44+
if (baseDir == null) {
45+
throw new IllegalArgumentException("baseDir must not be null");
46+
}
47+
this.baseDir = baseDir.toAbsolutePath().normalize();
4248
try {
43-
Files.createDirectories(baseDir);
49+
Files.createDirectories(this.baseDir);
4450
} catch (IOException e) {
45-
throw new IllegalStateException("Failed to create blob store dir " + baseDir, e);
51+
throw new IllegalStateException("Failed to create blob store dir " + this.baseDir, e);
4652
}
4753
}
4854

@@ -53,22 +59,33 @@ public Path baseDir() {
5359

5460
@Override
5561
public void put(String key, byte[] data) throws IOException {
56-
Files.write(baseDir.resolve(sanitizeKey(key)), data);
62+
Files.write(baseDir.resolve(filenameForKey(key)), data);
5763
}
5864

5965
@Override
6066
public byte[] get(String key) throws IOException {
61-
return Files.readAllBytes(baseDir.resolve(sanitizeKey(key)));
67+
return Files.readAllBytes(baseDir.resolve(filenameForKey(key)));
6268
}
6369

6470
/**
65-
* Turns a {@code bucket/sha256hex} key into a single safe filename. Keys are always generated
66-
* internally by the DataConverter, but this provides a belt-and-suspenders guarantee against
67-
* directory traversal in case a future caller passes a user-controlled key.
71+
* Turns any blob-store key into a fixed safe filename. Keys are usually generated internally by
72+
* the DataConverter, but hashing prevents directory traversal even if a future caller passes a
73+
* user-controlled key.
6874
*/
69-
private static String sanitizeKey(String key) {
70-
String flat = key.replace('/', '_').replace('\\', '_');
71-
int slash = Math.max(flat.lastIndexOf('/'), flat.lastIndexOf('\\'));
72-
return slash >= 0 ? flat.substring(slash + 1) : flat;
75+
private static String filenameForKey(String key) throws IOException {
76+
if (key == null || key.isEmpty()) {
77+
throw new IOException("BlobStore key must not be null or empty");
78+
}
79+
try {
80+
MessageDigest md = MessageDigest.getInstance("SHA-256");
81+
byte[] digest = md.digest(key.getBytes(StandardCharsets.UTF_8));
82+
StringBuilder sb = new StringBuilder(digest.length * 2);
83+
for (byte b : digest) {
84+
sb.append(String.format("%02x", b & 0xff));
85+
}
86+
return sb.toString();
87+
} catch (NoSuchAlgorithmException e) {
88+
throw new IOException("SHA-256 is not available in this JVM", e);
89+
}
7390
}
7491
}

src/main/java/com/uber/cadence/samples/dataconverter/S3OffloadDataConverter.java

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import com.uber.cadence.converter.JsonDataConverter;
2323
import java.io.IOException;
2424
import java.lang.reflect.Type;
25-
import java.nio.charset.StandardCharsets;
2625
import java.security.MessageDigest;
2726
import java.security.NoSuchAlgorithmException;
2827

@@ -39,15 +38,15 @@
3938
*
4039
* <ul>
4140
* <li>{@code 0x00 || json} — payload is small enough to inline.
42-
* <li>{@code 0x01 || jsonEnvelope} — payload was offloaded; the envelope JSON has the form
43-
* {@code {"__s3_ref":"<bucket>/<sha256hex>"}}.
41+
* <li>{@code 0x01 || jsonEnvelope} — payload was offloaded; the envelope JSON has the form {@code
42+
* {"s3Ref":"<bucket>/<sha256hex>"}}.
4443
* </ul>
4544
*
4645
* <p>Keys are derived from the SHA-256 of the payload so {@code toData} is idempotent across
4746
* Cadence workflow replays. Using a fresh UUID per call would write a new orphaned blob on every
4847
* replay because the SDK calls {@code toData} again each time the workflow re-executes from the
49-
* top. If the workflow needs to control the key (e.g. to encode routing metadata), generate it
50-
* with {@code Workflow.sideEffect} and pass it alongside the payload instead.
48+
* top. If the workflow needs to control the key (e.g. to encode routing metadata), generate it with
49+
* {@code Workflow.sideEffect} and pass it alongside the payload instead.
5150
*/
5251
/*
5352
* =============================================================================
@@ -104,12 +103,31 @@ public final class S3OffloadDataConverter implements DataConverter {
104103
private final String bucket;
105104
private final int thresholdBytes;
106105

106+
static final class BlobReference {
107+
public String s3Ref;
108+
109+
public BlobReference() {}
110+
111+
BlobReference(String s3Ref) {
112+
this.s3Ref = s3Ref;
113+
}
114+
}
115+
107116
/**
108117
* @param store the BlobStore backend (use {@link LocalFsBlobStore} for zero-config demo).
109118
* @param bucket logical bucket / prefix name embedded in the reference key.
110119
* @param thresholdBytes max inline payload size; larger payloads are offloaded.
111120
*/
112121
public S3OffloadDataConverter(BlobStore store, String bucket, int thresholdBytes) {
122+
if (store == null) {
123+
throw new IllegalArgumentException("store must not be null");
124+
}
125+
if (bucket == null || bucket.trim().isEmpty()) {
126+
throw new IllegalArgumentException("bucket must not be null or empty");
127+
}
128+
if (thresholdBytes < 0) {
129+
throw new IllegalArgumentException("thresholdBytes must not be negative");
130+
}
113131
this.store = store;
114132
this.bucket = bucket;
115133
this.thresholdBytes = thresholdBytes;
@@ -140,8 +158,7 @@ public byte[] toData(Object... values) throws DataConverterException {
140158
"Failed to offload payload to blob store (key=" + key + ")", e);
141159
}
142160

143-
String envelope = "{\"__s3_ref\":\"" + key + "\"}";
144-
byte[] envBytes = envelope.getBytes(StandardCharsets.UTF_8);
161+
byte[] envBytes = delegate.toData(new BlobReference(key));
145162
byte[] result = new byte[1 + envBytes.length];
146163
result[0] = OFFLOAD_PREFIX;
147164
System.arraycopy(envBytes, 0, result, 1, envBytes.length);
@@ -173,7 +190,7 @@ private byte[] unwrap(byte[] content) throws DataConverterException {
173190
case INLINE_PREFIX:
174191
return body;
175192
case OFFLOAD_PREFIX:
176-
String key = extractS3Ref(new String(body, StandardCharsets.UTF_8));
193+
String key = extractS3Ref(body);
177194
try {
178195
return store.get(key);
179196
} catch (IOException e) {
@@ -186,24 +203,13 @@ private byte[] unwrap(byte[] content) throws DataConverterException {
186203
}
187204
}
188205

189-
/**
190-
* Extracts the value of {@code __s3_ref} from the envelope JSON without bringing in a JSON
191-
* parser. The envelope is produced by this class, so the format is fixed and trivially parseable.
192-
*/
193-
private static String extractS3Ref(String envelopeJson) throws DataConverterException {
194-
String marker = "\"__s3_ref\":\"";
195-
int start = envelopeJson.indexOf(marker);
196-
if (start < 0) {
197-
throw new DataConverterException(
198-
"s3 offload: envelope missing __s3_ref field: " + envelopeJson, null);
199-
}
200-
start += marker.length();
201-
int end = envelopeJson.indexOf('"', start);
202-
if (end < 0) {
203-
throw new DataConverterException(
204-
"s3 offload: envelope __s3_ref field is unterminated: " + envelopeJson, null);
206+
private static String extractS3Ref(byte[] envelopeJson) throws DataConverterException {
207+
BlobReference reference =
208+
delegate.fromData(envelopeJson, BlobReference.class, BlobReference.class);
209+
if (reference == null || reference.s3Ref == null || reference.s3Ref.isEmpty()) {
210+
throw new DataConverterException("s3 offload: envelope missing s3Ref field", null);
205211
}
206-
return envelopeJson.substring(start, end);
212+
return reference.s3Ref;
207213
}
208214

209215
private static String sha256Hex(byte[] data) throws DataConverterException {

0 commit comments

Comments
 (0)