This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-text.git
The following commit(s) were added to refs/heads/master by this push:
new 6f2a0329 Clear residual buffer chars in TextStringBuilder shrink and
clear paths (#746)
6f2a0329 is described below
commit 6f2a03296e216d389542d0200086ffc16ad0e647
Author: Javid Khan <[email protected]>
AuthorDate: Tue Jun 2 16:08:56 2026 +0530
Clear residual buffer chars in TextStringBuilder shrink and clear paths
(#746)
* clear residual buffer chars in TextStringBuilder shrink and clear paths
* Add tests for TextStringBuilder buffer-tail clearing
---
.../org/apache/commons/text/TextStringBuilder.java | 6 +
.../commons/text/TextStringBuilderClearTest.java | 191 +++++++++++++++++++++
2 files changed, 197 insertions(+)
diff --git a/src/main/java/org/apache/commons/text/TextStringBuilder.java
b/src/main/java/org/apache/commons/text/TextStringBuilder.java
index 2977fa32..426b278c 100644
--- a/src/main/java/org/apache/commons/text/TextStringBuilder.java
+++ b/src/main/java/org/apache/commons/text/TextStringBuilder.java
@@ -1568,6 +1568,7 @@ public class TextStringBuilder implements CharSequence,
Appendable, Serializable
*/
public TextStringBuilder clear() {
size = 0;
+ Arrays.fill(buffer, '\0');
return this;
}
@@ -1756,6 +1757,7 @@ public class TextStringBuilder implements CharSequence,
Appendable, Serializable
private void deleteImpl(final int startIndex, final int endIndex, final
int len) {
System.arraycopy(buffer, endIndex, buffer, startIndex, size -
endIndex);
size -= len;
+ Arrays.fill(buffer, size, size + len, '\0');
}
/**
@@ -2820,6 +2822,9 @@ public class TextStringBuilder implements CharSequence,
Appendable, Serializable
if (insertLen != removeLen) {
ensureCapacityInternal(newSize);
System.arraycopy(buffer, endIndex, buffer, startIndex + insertLen,
size - endIndex);
+ if (size > newSize) {
+ Arrays.fill(buffer, newSize, size, '\0');
+ }
size = newSize;
}
if (insertLen > 0) {
@@ -2962,6 +2967,7 @@ public class TextStringBuilder implements CharSequence,
Appendable, Serializable
throw new StringIndexOutOfBoundsException(length);
}
if (length < size) {
+ Arrays.fill(buffer, length, size, '\0');
size = length;
} else if (length > size) {
ensureCapacityInternal(length);
diff --git
a/src/test/java/org/apache/commons/text/TextStringBuilderClearTest.java
b/src/test/java/org/apache/commons/text/TextStringBuilderClearTest.java
new file mode 100644
index 00000000..07d0af98
--- /dev/null
+++ b/src/test/java/org/apache/commons/text/TextStringBuilderClearTest.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * https://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 org.apache.commons.text;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.commons.lang3.CharUtils;
+import org.apache.commons.lang3.SerializationUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that {@link TextStringBuilder} shrink and clear paths do not leave
stale chars in the backing buffer.
+ * <p>
+ * readFrom(Reader) reads directly into the internal char[] buffer, so a
Reader that is also an attacker can observe stale chars in that buffer beyond
the
+ * logical content. The buffer is non-transient, so stale chars also survive
serialization.
+ * </p>
+ * <p>
+ * Post-patch: clear(), delete(), replace() and setLength() NUL out the freed
region so stale chars are not exposed.
+ * </p>
+ */
+public class TextStringBuilderClearTest {
+
+ /**
+ * A Reader that, upon reading, inspects the char array it has been given
access to (positions beyond offset+len that may contain stale data), records
them,
+ * then supplies its normal data.
+ */
+ static class SpyReader extends Reader {
+
+ private boolean done;
+ private char[] observedExtra;
+ private final char[] supply;
+
+ SpyReader(final String supply) {
+ this.supply = supply.toCharArray();
+ }
+
+ @Override
+ public void close() {
+ // empty
+ }
+
+ boolean observedStaleChars(final String marker) {
+ if (observedExtra == null) {
+ return false;
+ }
+ return new String(observedExtra).contains(marker);
+ }
+
+ @Override
+ public int read(final char[] cbuf, final int off, final int len) {
+ if (done) {
+ return -1;
+ }
+ done = true;
+ // Record chars in the buffer beyond where we will write
+ final int toWrite = Math.min(supply.length, len);
+ final int staleStart = off + toWrite;
+ final int staleLen = cbuf.length - staleStart;
+ if (staleLen > 0) {
+ observedExtra = new char[staleLen];
+ System.arraycopy(cbuf, staleStart, observedExtra, 0, staleLen);
+ }
+ System.arraycopy(supply, 0, cbuf, off, toWrite);
+ return toWrite;
+ }
+ }
+
+ /** Search for a string encoded as UTF-16BE (2 bytes per char) in a byte
array. */
+ private static boolean containsUtf16Be(final byte[] haystack, final String
needle) {
+ final byte[] needleBytes = needle.getBytes(StandardCharsets.UTF_16BE);
+ outer: for (int i = 0; i <= haystack.length - needleBytes.length; i++)
{
+ for (int j = 0; j < needleBytes.length; j++) {
+ if (haystack[i + j] != needleBytes[j]) {
+ continue outer;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Test
+ public void testDeserializedBuilderHasNoStaleBufferContent() throws
Exception {
+ final TextStringBuilder sb = new
TextStringBuilder("secret_password_xyzzy");
+ sb.clear();
+ sb.append("safe");
+ final byte[] serialized = SerializationUtils.serialize(sb);
+ final TextStringBuilder sb2;
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(serialized))) {
+ sb2 = (TextStringBuilder) ois.readObject();
+ }
+ final String bufContent = new String(sb2.getBuffer());
+ assertFalse(bufContent.contains("secret_password"), "Deserialized
buffer must not contain stale chars: " + bufContent);
+ }
+
+ @Test
+ void testDeleteShrinkLeavesNoResidue() {
+ final String string = "SECRET_PASSWORD_DATA";
+ final int len = string.length();
+ final TextStringBuilder sb = new TextStringBuilder(string);
+ assertEquals(len, sb.length());
+ sb.delete(6, len);
+ assertEquals(6, sb.length());
+ assertEquals("SECRET", sb.toString());
+ final char[] buf = sb.getBuffer();
+ for (int i = 6; i < len; i++) {
+ assertEquals(CharUtils.NUL, buf[i]);
+ }
+ }
+
+ @Test
+ public void testReadFromReaderDoesNotExposeStaleInternalBuffer() throws
IOException {
+ final TextStringBuilder sb = new TextStringBuilder();
+ sb.append("SECRET_DATA_SHOULD_NOT_LEAK_ABCDEFGHIJ");
+ sb.clear();
+ try (SpyReader spy = new SpyReader("hi")) {
+ sb.readFrom(spy);
+ assertFalse(spy.observedStaleChars("_DATA_SHOULD_NOT_LEAK"));
+ }
+ }
+
+ @Test
+ void testReplaceShrinkLeavesNoResidue() {
+ final String string = "SECRET_PASSWORD_DATA";
+ final TextStringBuilder sb = new TextStringBuilder(string);
+ assertEquals(20, sb.length());
+ // Shrink: replace [0,20) with "X" => removeLen=20, insertLen=1,
newSize=1.
+ sb.replace(0, 20, "X");
+ assertEquals(1, sb.length());
+ assertEquals("X", sb.toString());
+ final char[] buf = sb.getBuffer();
+ assertTrue(buf.length >= 20);
+ for (int i = 1; i < 20; i++) {
+ assertEquals(CharUtils.NUL, buf[i]);
+ }
+ }
+
+ @Test
+ void testSetLengthShrinkLeavesNoResidue() {
+ final String string = "CONFIDENTIAL_TOKEN_VALUE";
+ final int len = string.length();
+ final TextStringBuilder sb = new TextStringBuilder(string);
+ assertEquals(len, sb.length());
+ sb.setLength(5);
+ assertEquals(5, sb.length());
+ assertEquals("CONFI", sb.toString());
+ final char[] buf = sb.getBuffer();
+ assertTrue(buf.length >= len);
+ for (int i = 5; i < len; i++) {
+ assertEquals(CharUtils.NUL, buf[i]);
+ }
+ }
+
+ @Test
+ public void testStaleCharsNotLeakedAfterClear() {
+ final TextStringBuilder sb = new
TextStringBuilder("secret_password_xyzzy_leak");
+ sb.clear();
+ sb.append("ok");
+ assertFalse(containsUtf16Be(SerializationUtils.serialize(sb),
"xyzzy_leak"));
+ }
+
+ @Test
+ public void testStaleCharsNotLeakedAfterTruncate() {
+ final TextStringBuilder sb = new
TextStringBuilder("top_secret_key_material");
+ sb.delete(6, sb.length());
+ assertFalse(containsUtf16Be(SerializationUtils.serialize(sb),
"secret_key_material"));
+ }
+}