Skip to content

Commit ad554ec

Browse files
authored
Merge branch 'release' into ww-mongodb-operator-helm
2 parents d40ec1f + 5d08e40 commit ad554ec

9 files changed

Lines changed: 318 additions & 73 deletions

File tree

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ ENV APPSMITH_SEGMENT_CE_KEY=${APPSMITH_SEGMENT_CE_KEY}
1212
ARG APPSMITH_BETTERBUGS_API_KEY
1313
ENV APPSMITH_BETTERBUGS_API_KEY=${APPSMITH_BETTERBUGS_API_KEY}
1414

15+
ARG APPSMITH_PYLON_APP_ID
16+
ENV APPSMITH_PYLON_APP_ID=${APPSMITH_PYLON_APP_ID}
17+
1518
COPY deploy/docker/fs /
1619

1720
RUN <<END

app/client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@
448448
"brace-expansion": "1.1.12",
449449
"form-data": "4.0.4",
450450
"fast-xml-parser": "4.5.4",
451-
"handlebars": "4.7.9"
451+
"handlebars": "4.7.9",
452+
"protobufjs": "^7.5.5"
452453
}
453454
}

app/client/yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29029,9 +29029,9 @@ __metadata:
2902929029
languageName: node
2903029030
linkType: hard
2903129031

29032-
"protobufjs@npm:^7.3.0":
29033-
version: 7.3.2
29034-
resolution: "protobufjs@npm:7.3.2"
29032+
"protobufjs@npm:^7.5.5":
29033+
version: 7.5.5
29034+
resolution: "protobufjs@npm:7.5.5"
2903529035
dependencies:
2903629036
"@protobufjs/aspromise": ^1.1.2
2903729037
"@protobufjs/base64": ^1.1.2
@@ -29045,7 +29045,7 @@ __metadata:
2904529045
"@protobufjs/utf8": ^1.1.0
2904629046
"@types/node": ">=13.7.0"
2904729047
long: ^5.0.0
29048-
checksum: cfb2a744787f26ee7c82f3e7c4b72cfc000e9bb4c07828ed78eb414db0ea97a340c0cc3264d0e88606592f847b12c0351411f10e9af255b7ba864eec44d7705f
29048+
checksum: e316eb0df33a64398ce32056de37435d8ea7ef3e06dff32cda2a7156431c029fe2c120e390b7ff066de7632e996d6d5d0540fb606fef223a8480dff25bee6123
2904929049
languageName: node
2905029050
linkType: hard
2905129051

app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/MustacheHelper.java

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import com.appsmith.external.models.EntityReferenceType;
66
import com.appsmith.external.models.MustacheBindingToken;
77
import lombok.extern.slf4j.Slf4j;
8-
import org.apache.commons.text.StringEscapeUtils;
98
import org.springframework.beans.BeanWrapper;
109
import org.springframework.beans.BeansException;
1110
import org.springframework.beans.PropertyAccessorFactory;
@@ -348,23 +347,32 @@ public static String render(String template, Map<String, String> keyValueMap) {
348347
if (!keyValueMap.containsKey(tokenSubstring)) {
349348
rendered.append(token.getValue());
350349
} else if (bindingValue != null) {
351-
// If the binding value is not null, then append the binding value to the rendered string.
352-
// We are using the token.getValue() here to get the original value of the binding.
353-
// This is because we want to preserve the original formatting of the binding.
354-
// For example, if the binding is {{Input.text}}, we want to preserve the {{}} around it.
355-
rendered.append(bindingValue);
350+
// Handle &quot; and &#34; (HTML-encoded double quotes) inside the binding
351+
// VALUE only, converting them to escaped double quotes (\") so interpolating
352+
// the value into a JSON template keeps the JSON valid.
353+
//
354+
// Scoping to the binding value is deliberate: template literals and
355+
// pass-through content (e.g., binary uploads) must never be mutated by this
356+
// JSON-validity escape. Historically the replacement ran on the fully
357+
// assembled string and corrupted binary data that happened to contain these
358+
// byte sequences. See: https://linear.app/appsmith/issue/V2-3662
359+
rendered.append(escapeHtmlDoubleQuoteEntities(bindingValue));
356360
}
357361
} else {
358362
rendered.append(token.getValue());
359363
}
360364
}
361-
/**
362-
* ReplaceAll is used to escape the double quotes symbol with \" so that
363-
* JSON remains valid.
364-
* &quot; and &#34; both are HTML reserved characters for double quotes (")
365-
*/
366-
return StringEscapeUtils.unescapeHtml4(
367-
rendered.toString().replaceAll("&quot;", "\\\\&quot;").replaceAll("&#34;", "\\\\&#34;"));
365+
return rendered.toString();
366+
}
367+
368+
private static String escapeHtmlDoubleQuoteEntities(String value) {
369+
if (value == null || value.isEmpty()) {
370+
return value;
371+
}
372+
if (value.indexOf('&') < 0) {
373+
return value;
374+
}
375+
return value.replace("&quot;", "\\\"").replace("&#34;", "\\\"");
368376
}
369377

370378
/**

app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/restApiUtils/helpers/DataUtils.java

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,12 @@ private void populateFileTypeBodyBuilder(
305305

306306
final String fileValue = (String) property.getValue();
307307
final String key = property.getKey();
308+
// Normalize once: routing decisions must be structural, not substring-based on raw input.
309+
// Leading whitespace previously misrouted valid JSON payloads to the base64 path. See V2-3662.
310+
final String normalized = fileValue == null ? "" : fileValue.trim();
308311

309-
if (fileValue.contains(BASE64_DELIMITER)) {
310-
processBase64Data(fileValue, key, bodyBuilder, outputMessage);
311-
} else {
312-
List<MultipartFormDataDTO> multipartFormDataDTOs = parseMultipartData(fileValue);
312+
if (normalized.startsWith("{") || normalized.startsWith("[")) {
313+
List<MultipartFormDataDTO> multipartFormDataDTOs = parseMultipartData(normalized);
313314

314315
for (MultipartFormDataDTO dto : multipartFormDataDTOs) {
315316
String dataString = String.valueOf(dto.getData());
@@ -319,6 +320,11 @@ private void populateFileTypeBodyBuilder(
319320
processRegularData(dataString, key, bodyBuilder, outputMessage, dto.getName(), dto.getType());
320321
}
321322
}
323+
} else if (normalized.contains(BASE64_DELIMITER)) {
324+
processBase64Data(normalized, key, bodyBuilder, outputMessage);
325+
} else {
326+
// Fall back to existing behavior for non-JSON, non-base64 strings (error thrown downstream).
327+
parseMultipartData(normalized);
322328
}
323329
}
324330

@@ -394,12 +400,15 @@ private void addPartToBody(
394400
bodyBuilder.asyncPart(key, data, DataBuffer.class).filename(filename).contentType(MediaType.valueOf(mimeType));
395401
}
396402

397-
// Parse JSON multipart data
403+
// Parse JSON multipart data. Trims input so leading whitespace does not cause
404+
// a valid JSON payload to be rejected as invalid multipart data. Consistent with
405+
// objectFromJson() in this class which trims before type detection.
398406
private List<MultipartFormDataDTO> parseMultipartData(String fileValue) throws IOException {
399-
if (fileValue.startsWith("{")) {
400-
return Collections.singletonList(objectMapper.readValue(fileValue, MultipartFormDataDTO.class));
401-
} else if (fileValue.startsWith("[")) {
402-
return Arrays.asList(objectMapper.readValue(fileValue, MultipartFormDataDTO[].class));
407+
final String trimmed = fileValue == null ? "" : fileValue.trim();
408+
if (trimmed.startsWith("{")) {
409+
return Collections.singletonList(objectMapper.readValue(trimmed, MultipartFormDataDTO.class));
410+
} else if (trimmed.startsWith("[")) {
411+
return Arrays.asList(objectMapper.readValue(trimmed, MultipartFormDataDTO[].class));
403412
} else {
404413
throw new AppsmithPluginException(
405414
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, ERROR_INVALID_MULTIPART_DATA);

app/server/appsmith-interfaces/src/test/java/com/appsmith/external/helpers/DataUtilsTest.java

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,95 @@ public void testParseMultipartArrayDataWorks() {
309309
.expectComplete()
310310
.verify();
311311
}
312+
313+
@Test
314+
public void testParseMultipartFileData_withBase64FileArray_returnsExpectedBody() {
315+
List<Property> properties = new ArrayList<>();
316+
String base64DataUrl = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago=";
317+
final Property p1 = new Property(
318+
"fileField",
319+
"[{\"name\": \"test.pdf\", \"type\": \"application/pdf\", \"data\": \"" + base64DataUrl + "\"}]");
320+
p1.setType("file");
321+
properties.add(p1);
322+
323+
final BodyInserter<Object, MockClientHttpRequest> bodyInserter =
324+
(BodyInserter<Object, MockClientHttpRequest>) dataUtils.parseMultipartFileData(properties);
325+
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.POST, URI.create("https://example.com"));
326+
327+
Mono<Void> result = bodyInserter.insert(request, this.context);
328+
StepVerifier.create(result).expectComplete().verify();
329+
StepVerifier.create(DataBufferUtils.join(request.getBody()))
330+
.consumeNextWith(dataBuffer -> {
331+
byte[] resultBytes = new byte[dataBuffer.readableByteCount()];
332+
dataBuffer.read(resultBytes);
333+
DataBufferUtils.release(dataBuffer);
334+
String content = new String(resultBytes, StandardCharsets.UTF_8);
335+
assertTrue(content.contains(
336+
"Content-Disposition: form-data; name=\"fileField\"; filename=\"test.pdf\""));
337+
assertTrue(content.contains("Content-Type: application/pdf"));
338+
})
339+
.expectComplete()
340+
.verify();
341+
}
342+
343+
@Test
344+
public void testParseMultipartFileData_withBinaryFileContainingHtmlEntityBytes_succeeds() {
345+
List<Property> properties = new ArrayList<>();
346+
final Property p1 = new Property(
347+
"fileField",
348+
"[{\"name\": \"test.pdf\", \"type\": \"application/pdf\", "
349+
+ "\"data\": \"binary-prefix\u0026#xA;binary-suffix\"}]");
350+
p1.setType("file");
351+
properties.add(p1);
352+
353+
final BodyInserter<Object, MockClientHttpRequest> bodyInserter =
354+
(BodyInserter<Object, MockClientHttpRequest>) dataUtils.parseMultipartFileData(properties);
355+
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.POST, URI.create("https://example.com"));
356+
357+
Mono<Void> result = bodyInserter.insert(request, this.context);
358+
StepVerifier.create(result).expectComplete().verify();
359+
StepVerifier.create(DataBufferUtils.join(request.getBody()))
360+
.consumeNextWith(dataBuffer -> {
361+
byte[] resultBytes = new byte[dataBuffer.readableByteCount()];
362+
dataBuffer.read(resultBytes);
363+
DataBufferUtils.release(dataBuffer);
364+
String content = new String(resultBytes, StandardCharsets.UTF_8);
365+
assertTrue(content.contains(
366+
"Content-Disposition: form-data; name=\"fileField\"; filename=\"test.pdf\""));
367+
assertTrue(content.contains("Content-Type: application/pdf"));
368+
assertTrue(content.contains("&#xA;"));
369+
})
370+
.expectComplete()
371+
.verify();
372+
}
373+
374+
@Test
375+
public void testParseMultipartFileData_withLeadingWhitespaceJsonArray_routesAsMultipart() {
376+
List<Property> properties = new ArrayList<>();
377+
String base64DataUrl = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago=";
378+
final Property p1 = new Property(
379+
"fileField",
380+
" \n[{\"name\": \"test.pdf\", \"type\": \"application/pdf\", \"data\": \"" + base64DataUrl + "\"}]");
381+
p1.setType("file");
382+
properties.add(p1);
383+
384+
final BodyInserter<Object, MockClientHttpRequest> bodyInserter =
385+
(BodyInserter<Object, MockClientHttpRequest>) dataUtils.parseMultipartFileData(properties);
386+
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.POST, URI.create("https://example.com"));
387+
388+
Mono<Void> result = bodyInserter.insert(request, this.context);
389+
StepVerifier.create(result).expectComplete().verify();
390+
StepVerifier.create(DataBufferUtils.join(request.getBody()))
391+
.consumeNextWith(dataBuffer -> {
392+
byte[] resultBytes = new byte[dataBuffer.readableByteCount()];
393+
dataBuffer.read(resultBytes);
394+
DataBufferUtils.release(dataBuffer);
395+
String content = new String(resultBytes, StandardCharsets.UTF_8);
396+
assertTrue(content.contains(
397+
"Content-Disposition: form-data; name=\"fileField\"; filename=\"test.pdf\""));
398+
assertTrue(content.contains("Content-Type: application/pdf"));
399+
})
400+
.expectComplete()
401+
.verify();
402+
}
312403
}

app/server/appsmith-interfaces/src/test/java/com/appsmith/external/helpers/MustacheHelperTest.java

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -608,15 +608,80 @@ public void render_WhenValueContainsHtmlDoubleQuotes_ReturnsEscapedCharacter() {
608608
}
609609

610610
/**
611-
* This test case validates the unescaping of HTML characters to string
611+
* Verifies that HTML entities other than &quot;/&#34; are NOT decoded.
612+
* Previously, unescapeHtml4() decoded all entities, but this corrupted binary data
613+
* containing entity-like byte sequences (e.g., &#xA; in PDFs). Now only &quot; and
614+
* &#34; are handled for JSON validity.
615+
* See: https://linear.app/appsmith/issue/V2-3662
612616
*/
613617
@Test
614-
public void render_WhenValueContainsHtmlReservedCharacters_ReturnsEscapedCharacters() {
618+
public void render_WhenValueContainsHtmlReservedCharacters_DoesNotDecodeNonQuoteEntities() {
615619
final String rendered = render(
616620
"Testing html lt {{ltSymbol}} and gt {{gtSymbol}} symbols",
617621
Map.of(
618622
"ltSymbol", "&lt;",
619623
"gtSymbol", "&gt;"));
620-
assertThat(rendered).isEqualTo("Testing html lt < and gt > symbols");
624+
assertThat(rendered).isEqualTo("Testing html lt &lt; and gt &gt; symbols");
625+
}
626+
627+
/**
628+
* This test verifies that binary data containing HTML-entity-like byte sequences
629+
* (e.g., &#xA; which is a newline entity) is NOT corrupted by the render method.
630+
* PDF files and other binary content may contain such sequences as raw bytes.
631+
* Regression test for: https://linear.app/appsmith/issue/V2-3662
632+
*/
633+
@Test
634+
public void render_WhenBinaryDataContainsHtmlEntityLikeSequences_DoesNotCorruptData() {
635+
String binaryDataWithEntity = "[{\"name\":\"test.pdf\",\"type\":\"application/pdf\","
636+
+ "\"data\":\"some-binary-prefix\u0026#xA;some-binary-suffix\"}]";
637+
638+
final String rendered = render("{{filePicker.files}}", Map.of("filePicker.files", binaryDataWithEntity));
639+
640+
assertThat(rendered).isEqualTo(binaryDataWithEntity);
641+
assertThat(rendered).contains("&#xA;");
642+
assertThat(rendered).doesNotContain("\n");
643+
}
644+
645+
/**
646+
* Verifies that other HTML numeric character references in binary content
647+
* are not decoded (e.g., &#xD; for carriage return, &#9; for tab).
648+
*/
649+
@Test
650+
public void render_WhenBinaryDataContainsVariousHtmlEntities_DoesNotCorruptData() {
651+
String dataWithEntities = "prefix&#xD;middle&#9;suffix&amp;end&lt;final&gt;done";
652+
653+
final String rendered = render("{{data}}", Map.of("data", dataWithEntities));
654+
655+
assertThat(rendered).isEqualTo(dataWithEntities);
656+
}
657+
658+
/**
659+
* Proves the JSON-quote escape is scoped to binding values only. When the template
660+
* contains a literal &quot; AND the binding value also contains a &quot;, only the
661+
* value is mutated; the literal is preserved byte-for-byte.
662+
* Regression test for: V2-3662 (follow-up)
663+
*/
664+
@Test
665+
public void render_WhenTemplateLiteralAndValueBothContainQuoteEntity_OnlyValueIsEscaped() {
666+
final String rendered = render("literal &quot; then {{data}} end", Map.of("data", "A&quot;B"));
667+
668+
// Template literal preserved
669+
assertThat(rendered).startsWith("literal &quot; then ");
670+
assertThat(rendered).endsWith(" end");
671+
// Binding value escaped for JSON validity
672+
assertThat(rendered).contains("A\\\"B");
673+
assertThat(rendered).isEqualTo("literal &quot; then A\\\"B end");
674+
}
675+
676+
/**
677+
* A literal &quot; sitting in the template text (not inside a mustache binding)
678+
* must pass through unchanged. Only binding values participate in JSON-validity
679+
* escaping.
680+
*/
681+
@Test
682+
public void render_WhenTemplateLiteralContainsQuoteEntity_IsPreserved() {
683+
final String rendered = render("static &quot;literal&quot; {{k}}", Map.of("k", "v"));
684+
685+
assertThat(rendered).isEqualTo("static &quot;literal&quot; v");
621686
}
622687
}

0 commit comments

Comments
 (0)