View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements. See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License. You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.lang3.text;
19  
20  import static org.junit.jupiter.api.Assertions.assertEquals;
21  import static org.junit.jupiter.api.Assertions.assertFalse;
22  import static org.junit.jupiter.api.Assertions.assertTrue;
23  
24  import java.io.ByteArrayInputStream;
25  import java.io.IOException;
26  import java.io.ObjectInputStream;
27  import java.io.Reader;
28  import java.nio.charset.StandardCharsets;
29  
30  import org.apache.commons.lang3.CharUtils;
31  import org.apache.commons.lang3.SerializationUtils;
32  import org.junit.jupiter.api.Test;
33  
34  /**
35   * Tests {@link StrBuilder#clear()} zero-copy APIs expose backing char buffer.
36   * <p>
37   * readFrom(Readable) Reader branch: reads directly into the internal char[] buffer, so a Reader that is also an attacker can observe stale chars in that buffer
38   * beyond the logical content.
39   * </p>
40   *
41   * <p>
42   * Pre-patch: A Reader can inspect chars beyond the current write position.
43   * </p>
44   * <p>
45   * Post-patch: readFrom uses a temporary buffer so stale internal chars are not exposed.
46   * </p>
47   */
48  @SuppressWarnings("deprecation")
49  public class StrBuilderClearTest {
50  
51      /**
52       * 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,
53       * then supplies its normal data.
54       */
55      static class SpyReader extends Reader {
56  
57          private boolean done;
58          private char[] observedExtra;
59          private final char[] supply;
60  
61          SpyReader(final String supply) {
62              this.supply = supply.toCharArray();
63          }
64  
65          @Override
66          public void close() {
67              // empty
68          }
69  
70          boolean observedStaleChars(final String marker) {
71              if (observedExtra == null) {
72                  return false;
73              }
74              return new String(observedExtra).contains(marker);
75          }
76  
77          @Override
78          public int read(final char[] cbuf, final int off, final int len) {
79              if (done) {
80                  return -1;
81              }
82              done = true;
83              // Record chars in the buffer beyond where we will write
84              final int toWrite = Math.min(supply.length, len);
85              final int staleStart = off + toWrite;
86              final int staleLen = cbuf.length - staleStart;
87              if (staleLen > 0) {
88                  observedExtra = new char[staleLen];
89                  System.arraycopy(cbuf, staleStart, observedExtra, 0, staleLen);
90              }
91              System.arraycopy(supply, 0, cbuf, off, toWrite);
92              return toWrite;
93          }
94      }
95  
96      /** Search for a string encoded as UTF-16BE (2 bytes per char) in a byte array. */
97      private static boolean containsUtf16Be(final byte[] haystack, final String needle) throws IOException {
98          final byte[] needleBytes = needle.getBytes(StandardCharsets.UTF_16BE);
99          outer: for (int i = 0; i <= haystack.length - needleBytes.length; i++) {
100             for (int j = 0; j < needleBytes.length; j++) {
101                 if (haystack[i + j] != needleBytes[j]) {
102                     continue outer;
103                 }
104             }
105             return true;
106         }
107         return false;
108     }
109 
110     @Test
111     public void testDeserializedStrBuilderHasNoStaleBufferContent() throws Exception {
112         final StrBuilder sb = new StrBuilder("secret_password_xyzzy");
113         sb.clear();
114         sb.append("safe");
115         final byte[] serialized = SerializationUtils.serialize(sb);
116         final StrBuilder sb2;
117         // Deserialize and inspect the buffer
118         try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serialized))) {
119             sb2 = (StrBuilder) ois.readObject();
120         }
121         final char[] buf2 = sb2.buffer;
122         final String bufContent = new String(buf2);
123         assertFalse(bufContent.contains("secret_password"), "Deserialized StrBuilder buffer must not contain stale chars: " + bufContent);
124     }
125 
126     @Test
127     public void testReadFromReaderDoesNotExposeStaleInternalBuffer() throws IOException {
128         final StrBuilder sb = new StrBuilder();
129         // Write a long "secret" string to fill the internal buffer
130         sb.append("SECRET_DATA_SHOULD_NOT_LEAK_ABCDEFGHIJ");
131         // Now clear it (logical size goes to 0, but internal buffer still holds old chars)
132         sb.clear();
133         // sb now has logical size 0 but internal buffer still contains "SECRET_DATA..."
134         // Now readFrom a SpyReader that only supplies short data but inspects the buffer.
135         // The spy observes what's in the buffer BEYOND the bytes it writes.
136         // We write "hi" (2 chars), so stale content starts at position 2:
137         // "CRET_DATA_SHOULD_NOT_LEAK_ABCDEFGHIJ..." is at positions 2+.
138         try (SpyReader spy = new SpyReader("hi")) {
139             sb.readFrom(spy);
140             // Post-patch: stale chars must NOT be visible to the Reader
141             assertFalse(spy.observedStaleChars("_DATA_SHOULD_NOT_LEAK"));
142         }
143     }
144 
145     @Test
146     void testReplaceImplLeavesResidue() throws Exception {
147         final String string = "SECRET_PASSWORD_DATA";
148         final StrBuilder sb = new StrBuilder(string);
149         assertEquals(20, sb.length());
150         // Shrink: replace [0,20) with "X" => removeLen=20, insertLen=1, newSize=1.
151         sb.replace(0, 20, "X");
152         assertEquals(1, sb.length());
153         assertEquals("X", sb.toString());
154         final char[] buf = sb.getBuffer();
155         assertTrue(buf.length >= 20);
156         // Tail [1..20) should be cleared but isn't on baseline => residue persists.
157         // Probe offset 5: original was '_' (underscore from "SECRET_..."). After
158         // arraycopy(buf, endIndex=20, buf, startIndex+insertLen=1, size-endIndex=0)
159         // the shift is a no-op, so buf[5] retains the original 'T' from "SECRET_".
160         // Either way it is non-NUL.
161         assertEquals(CharUtils.NUL, buf[5]);
162         // Dump the visible residue at the logical-unused tail.
163         for (int i = 1; i < 20; i++) {
164             assertEquals(CharUtils.NUL, buf[i]);
165         }
166     }
167 
168     @Test
169     void testSetLengthShrinkLeavesResidue() throws Exception {
170         final String string = "CONFIDENTIAL_TOKEN_VALUE";
171         final int len = string.length();
172         final StrBuilder sb = new StrBuilder(string);
173         assertEquals(len, sb.length());
174         // setLength(5) shrinks: size = 5, but [5..24) is NOT cleared.
175         sb.setLength(5);
176         assertEquals(5, sb.length());
177         assertEquals("CONFI", sb.toString());
178         final char[] buf = sb.getBuffer();
179         assertTrue(buf.length >= len);
180         // Probe offset 10: original was 'L' (CONFIDENTIA*L*_TOKEN_VALUE).
181         assertEquals(CharUtils.NUL, buf[10]);
182         for (int i = 5; i < len; i++) {
183             assertEquals(CharUtils.NUL, buf[i]);
184         }
185     }
186 
187     @Test
188     public void testStaleCharsNotLeakedAfterClear() throws Exception {
189         final StrBuilder sb = new StrBuilder("secret_password_xyzzy_leak");
190         // clear() resets logical size to 0 but leaves chars in buffer
191         sb.clear();
192         // append something shorter than the original
193         sb.append("ok");
194         // Stale content is serialized as UTF-16BE char[] data.
195         // "xyzzy_leak" was at positions 15+, well beyond "ok" (len=2), so must not appear.
196         assertFalse(containsUtf16Be(SerializationUtils.serialize(sb), "xyzzy_leak"));
197     }
198 
199     @Test
200     public void testStaleCharsNotLeakedAfterTruncate() throws Exception {
201         final StrBuilder sb = new StrBuilder("top_secret_key_material");
202         // truncate to a short length – tail remains in buffer
203         sb.delete(6, sb.length());
204         // sb now logically contains "top_se"
205         assertFalse(containsUtf16Be(SerializationUtils.serialize(sb), "secret_key_material"));
206     }
207 }