TextStringBuilderClearTest.java
/*
* 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.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 {
/** 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
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 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
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"));
}
}