Skip to content

Commit ec60c02

Browse files
committed
fix(snapbi): preserve whitespace in JSON string values during minification
The minifyJson() method used a regex (replaceAll("\s+", "")) that naively stripped ALL whitespace including whitespace inside JSON string values. This corrupted payloads like {"name": "Midtrans Yudhi"} into {"name":"MidtransYudhi"}, producing incorrect SHA-256 hashes and failing SNAP-BI webhook notification signature verification. Replace the regex with a character-level JSON minifier that tracks whether we are inside a quoted string (handling escaped quotes) and only removes whitespace outside strings. This preserves both string content and original key ordering, which is critical for hash-based signature verification. Also replace Lombok @Getter on MidtransError with explicit getter methods to fix JDK 25 compatibility (Lombok 1.18.12 annotation processing silently fails on JDK 25). Bump version to 3.2.2.
1 parent 4488217 commit ec60c02

8 files changed

Lines changed: 303 additions & 8 deletions

File tree

.gitignore

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ target/
3232
.classpath
3333
.project
3434
.settings
35+
.factorypath
3536
**/.checkstyle
3637
target/
3738
bin/
@@ -57,4 +58,14 @@ dist
5758
/.mvn/
5859
/mvnw
5960
/mvnw.bat
60-
mvnw.cmd
61+
mvnw.cmd
62+
63+
## GitHub Copilot prompts & skills (OpenSpec-generated):
64+
.github/prompts/
65+
.github/skills/
66+
67+
## OpenCode (AI agent config):
68+
.opencode/
69+
70+
## OpenSpec (spec-driven workflow artifacts):
71+
openspec/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## CHANGELOG MIDTRANS JAVA LIBRARY
22

3+
## v3.2.2 (April 7, 2026)
4+
5+
Bugs fix:
6+
- Fix `minifyJson()` in SnapBi stripping whitespace inside JSON string values, which caused incorrect SNAP-BI webhook notification signature verification. The method now uses a character-level JSON minifier that preserves whitespace within quoted strings.
7+
- Fix `MidtransError` getter methods not being generated by Lombok on JDK 25. Replaced `@Getter` annotation with explicit getter methods for `statusCode`, `responseBody`, and `response`.
8+
39
## v3.2.1 (October 16, 2024)
410

511
Feature:

example/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
<dependency>
4545
<groupId>com.midtrans</groupId>
4646
<artifactId>java-library</artifactId>
47-
<version>3.2.1</version>
47+
<version>3.2.2</version>
4848
<scope>compile</scope>
4949
</dependency>
5050
</dependencies>

library/pom.xml

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

66
<groupId>com.midtrans</groupId>
77
<artifactId>java-library</artifactId>
8-
<version>3.2.1</version>
8+
<version>3.2.2</version>
99
<packaging>jar</packaging>
1010

1111
<name>Midtrans Java Official Library</name>

library/src/main/java/com/midtrans/httpclient/error/MidtransError.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
11
package com.midtrans.httpclient.error;
22

3-
import lombok.Getter;
43
import okhttp3.Response;
54

65
/**
76
* MidtransError class to catch error messages
87
*/
9-
@Getter
108
public class MidtransError extends Exception {
119

1210
private String message;
1311
private Integer statusCode;
1412
private String responseBody;
1513
private Response response;
1614

15+
public String getMessage() {
16+
return message;
17+
}
18+
19+
public Integer getStatusCode() {
20+
return statusCode;
21+
}
22+
23+
public String getResponseBody() {
24+
return responseBody;
25+
}
26+
27+
public Response getResponse() {
28+
return response;
29+
}
30+
1731
/**
1832
* Constructs a Midtrans exception with the message
1933
*

library/src/main/java/com/midtrans/snapbi/SnapBi.java

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,35 @@ public static String getSymmetricSignatureHmacSh512(String accessToken, Map<Stri
294294
}
295295
}
296296

297-
private static String minifyJson(String json) {
298-
// Minify JSON by removing whitespace
299-
return json.replaceAll("\\s+", "");
297+
static String minifyJson(String json) {
298+
// Minify JSON by removing insignificant whitespace (outside of string values)
299+
// while preserving whitespace inside quoted strings and maintaining key order.
300+
StringBuilder sb = new StringBuilder(json.length());
301+
boolean inString = false;
302+
boolean escaped = false;
303+
for (int i = 0; i < json.length(); i++) {
304+
char c = json.charAt(i);
305+
if (escaped) {
306+
sb.append(c);
307+
escaped = false;
308+
continue;
309+
}
310+
if (c == '\\' && inString) {
311+
sb.append(c);
312+
escaped = true;
313+
continue;
314+
}
315+
if (c == '"') {
316+
inString = !inString;
317+
sb.append(c);
318+
continue;
319+
}
320+
if (!inString && Character.isWhitespace(c)) {
321+
continue;
322+
}
323+
sb.append(c);
324+
}
325+
return sb.toString();
300326
}
301327

302328
private static byte[] hashSha256(String input) throws NoSuchAlgorithmException {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.midtrans.snapbi;
2+
3+
import org.junit.jupiter.api.Tag;
4+
import org.junit.jupiter.api.Test;
5+
6+
import static org.junit.jupiter.api.Assertions.*;
7+
8+
@Tag("junit5")
9+
public class SnapBiMinifyJsonTest {
10+
11+
@Test
12+
void spacesInStringValuesArePreserved() {
13+
String input = "{\"name\" : \"Midtrans Yudhi\", \"amount\" : \"10000\"}";
14+
String expected = "{\"name\":\"Midtrans Yudhi\",\"amount\":\"10000\"}";
15+
assertEquals(expected, SnapBi.minifyJson(input));
16+
}
17+
18+
@Test
19+
void noSpacesInStringValues() {
20+
String input = "{ \"code\" : \"ABC123\" , \"status\" : \"active\" }";
21+
String expected = "{\"code\":\"ABC123\",\"status\":\"active\"}";
22+
assertEquals(expected, SnapBi.minifyJson(input));
23+
}
24+
25+
@Test
26+
void escapedQuotesInStringValues() {
27+
String input = "{\"msg\" : \"say \\\"hello world\\\"\"}";
28+
String expected = "{\"msg\":\"say \\\"hello world\\\"\"}";
29+
assertEquals(expected, SnapBi.minifyJson(input));
30+
}
31+
32+
@Test
33+
void nestedObjectsAndArrays() {
34+
String input = "{ \"outer\" : { \"inner\" : \"hello world\" } , \"list\" : [ \"a b\" , \"c d\" ] }";
35+
String expected = "{\"outer\":{\"inner\":\"hello world\"},\"list\":[\"a b\",\"c d\"]}";
36+
assertEquals(expected, SnapBi.minifyJson(input));
37+
}
38+
39+
@Test
40+
void alreadyMinifiedJsonUnchanged() {
41+
String input = "{\"name\":\"Midtrans Yudhi\",\"amount\":\"10000\"}";
42+
assertEquals(input, SnapBi.minifyJson(input));
43+
}
44+
45+
@Test
46+
void keyOrderingPreserved() {
47+
String input = "{ \"z_key\" : 1 , \"a_key\" : 2 , \"m_key\" : 3 }";
48+
String expected = "{\"z_key\":1,\"a_key\":2,\"m_key\":3}";
49+
assertEquals(expected, SnapBi.minifyJson(input));
50+
}
51+
52+
@Test
53+
void tabsAndNewlinesOutsideStringsRemoved() {
54+
String input = "{\n\t\"name\"\t:\t\"John Doe\"\n}";
55+
String expected = "{\"name\":\"John Doe\"}";
56+
assertEquals(expected, SnapBi.minifyJson(input));
57+
}
58+
59+
@Test
60+
void emptyObjectAndArray() {
61+
assertEquals("{}", SnapBi.minifyJson("{ }"));
62+
assertEquals("[]", SnapBi.minifyJson("[ ]"));
63+
}
64+
65+
@Test
66+
void realisticSnapBiVaNotificationPayload() {
67+
String input = "{\n" +
68+
" \"originalReferenceNo\" : \"A1202409231511081IDnMTwbGpYp\",\n" +
69+
" \"virtualAccountNo\" : \"7082214536759543\",\n" +
70+
" \"virtualAccountName\" : \"Midtrans Yudhi\",\n" +
71+
" \"paymentRequestId\" : \"test yudhi3\",\n" +
72+
" \"paidAmount\" : {\n" +
73+
" \"value\" : \"15000.00\",\n" +
74+
" \"currency\" : \"IDR\"\n" +
75+
" },\n" +
76+
" \"customField\" : { }\n" +
77+
"}";
78+
String result = SnapBi.minifyJson(input);
79+
// Spaces inside string values must be preserved
80+
assertTrue(result.contains("\"Midtrans Yudhi\""), "Space in virtualAccountName must be preserved");
81+
assertTrue(result.contains("\"test yudhi3\""), "Space in paymentRequestId must be preserved");
82+
// No insignificant whitespace should remain
83+
assertFalse(result.contains(" :"), "Whitespace around colons should be removed");
84+
assertFalse(result.contains(": "), "Whitespace around colons should be removed (except inside strings)");
85+
assertFalse(result.contains("\n"), "Newlines should be removed");
86+
}
87+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package com.midtrans.snapbi;
2+
3+
import org.junit.jupiter.api.AfterEach;
4+
import org.junit.jupiter.api.Tag;
5+
import org.junit.jupiter.api.Test;
6+
7+
import java.nio.charset.StandardCharsets;
8+
import java.security.*;
9+
import java.util.Base64;
10+
11+
import static org.junit.jupiter.api.Assertions.*;
12+
13+
@Tag("junit5")
14+
public class SnapBiWebhookSignatureTest {
15+
16+
// RSA key pair generated once for all tests
17+
private static final KeyPair KEY_PAIR = generateKeyPair();
18+
19+
private static KeyPair generateKeyPair() {
20+
try {
21+
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
22+
kpg.initialize(2048);
23+
return kpg.generateKeyPair();
24+
} catch (NoSuchAlgorithmException e) {
25+
throw new RuntimeException(e);
26+
}
27+
}
28+
29+
private static String publicKeyToPem(PublicKey publicKey) {
30+
String base64 = Base64.getEncoder().encodeToString(publicKey.getEncoded());
31+
return "-----BEGIN PUBLIC KEY-----\n" + base64 + "\n-----END PUBLIC KEY-----";
32+
}
33+
34+
/**
35+
* Produces the same signature that Midtrans would produce:
36+
* sign(SHA256withRSA, "POST:<urlPath>:<sha256hex(minifiedBody)>:<timestamp>")
37+
*/
38+
private static String signPayload(String notificationPayload, String urlPath, String timeStamp) throws Exception {
39+
String minified = SnapBi.minifyJson(notificationPayload);
40+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
41+
byte[] hash = digest.digest(minified.getBytes());
42+
String hexHash = bytesToHex(hash).toLowerCase();
43+
44+
String rawString = "POST:" + urlPath + ":" + hexHash + ":" + timeStamp;
45+
46+
Signature signer = Signature.getInstance("SHA256withRSA");
47+
signer.initSign(KEY_PAIR.getPrivate());
48+
signer.update(rawString.getBytes(StandardCharsets.UTF_8));
49+
byte[] signatureBytes = signer.sign();
50+
return Base64.getEncoder().encodeToString(signatureBytes);
51+
}
52+
53+
private static String bytesToHex(byte[] bytes) {
54+
StringBuilder sb = new StringBuilder(bytes.length * 2);
55+
for (byte b : bytes) {
56+
sb.append(String.format("%02x", b));
57+
}
58+
return sb.toString();
59+
}
60+
61+
@AfterEach
62+
void cleanUp() {
63+
// Reset static config to avoid leaking into other tests
64+
SnapBiConfig.setSnapBiPublicKey(null);
65+
}
66+
67+
@Test
68+
void verificationSucceedsWithSpacesInStringValues() throws Exception {
69+
String urlPath = "/webhook/snap-bi/va";
70+
String timeStamp = "2024-09-23T15:11:08+07:00";
71+
String payload = "{\n" +
72+
" \"originalReferenceNo\" : \"A1202409231511081IDnMTwbGpYp\",\n" +
73+
" \"virtualAccountNo\" : \"7082214536759543\",\n" +
74+
" \"virtualAccountName\" : \"Midtrans Yudhi\",\n" +
75+
" \"paymentRequestId\" : \"test yudhi3\",\n" +
76+
" \"paidAmount\" : {\n" +
77+
" \"value\" : \"15000.00\",\n" +
78+
" \"currency\" : \"IDR\"\n" +
79+
" }\n" +
80+
"}";
81+
82+
String signature = signPayload(payload, urlPath, timeStamp);
83+
SnapBiConfig.setSnapBiPublicKey(publicKeyToPem(KEY_PAIR.getPublic()));
84+
85+
Boolean result = SnapBi.notification()
86+
.withNotificationPayload(payload)
87+
.withNotificationUrlPath(urlPath)
88+
.withTimeStamp(timeStamp)
89+
.withSignature(signature)
90+
.isWebhookNotificationVerified();
91+
92+
assertTrue(result, "Signature verification should succeed for payload with spaces in string values");
93+
}
94+
95+
@Test
96+
void verificationFailsWithTamperedPayload() throws Exception {
97+
String urlPath = "/webhook/snap-bi/va";
98+
String timeStamp = "2024-09-23T15:11:08+07:00";
99+
String originalPayload = "{\"name\" : \"Midtrans Yudhi\"}";
100+
101+
// Sign the original payload
102+
String signature = signPayload(originalPayload, urlPath, timeStamp);
103+
SnapBiConfig.setSnapBiPublicKey(publicKeyToPem(KEY_PAIR.getPublic()));
104+
105+
// Verify with tampered payload
106+
String tamperedPayload = "{\"name\" : \"Midtrans Hacker\"}";
107+
108+
Boolean result = SnapBi.notification()
109+
.withNotificationPayload(tamperedPayload)
110+
.withNotificationUrlPath(urlPath)
111+
.withTimeStamp(timeStamp)
112+
.withSignature(signature)
113+
.isWebhookNotificationVerified();
114+
115+
assertFalse(result, "Signature verification should fail for tampered payload");
116+
}
117+
118+
@Test
119+
void verificationSucceedsWithAlreadyMinifiedPayload() throws Exception {
120+
String urlPath = "/api/notification";
121+
String timeStamp = "2024-01-01T00:00:00Z";
122+
// Already minified - no extra whitespace
123+
String payload = "{\"code\":\"ABC\",\"name\":\"John Doe\"}";
124+
125+
String signature = signPayload(payload, urlPath, timeStamp);
126+
SnapBiConfig.setSnapBiPublicKey(publicKeyToPem(KEY_PAIR.getPublic()));
127+
128+
Boolean result = SnapBi.notification()
129+
.withNotificationPayload(payload)
130+
.withNotificationUrlPath(urlPath)
131+
.withTimeStamp(timeStamp)
132+
.withSignature(signature)
133+
.isWebhookNotificationVerified();
134+
135+
assertTrue(result, "Signature verification should succeed for already-minified payload with spaces in values");
136+
}
137+
138+
@Test
139+
void verificationThrowsWhenPublicKeyNotSet() {
140+
SnapBiConfig.setSnapBiPublicKey(null);
141+
142+
assertThrows(IllegalStateException.class, () ->
143+
SnapBi.notification()
144+
.withNotificationPayload("{}")
145+
.withNotificationUrlPath("/test")
146+
.withTimeStamp("2024-01-01T00:00:00Z")
147+
.withSignature("dummysig")
148+
.isWebhookNotificationVerified()
149+
);
150+
}
151+
}

0 commit comments

Comments
 (0)