|
13 | 13 | import java.io.IOException; |
14 | 14 | import java.io.InputStream; |
15 | 15 | import java.io.OutputStream; |
| 16 | +import java.net.InetAddress; |
16 | 17 | import java.net.ServerSocket; |
17 | 18 | import java.net.Socket; |
18 | 19 | import java.net.SocketTimeoutException; |
|
21 | 22 | import java.nio.charset.StandardCharsets; |
22 | 23 | import java.sql.Connection; |
23 | 24 | import java.sql.ResultSet; |
| 25 | +import java.time.Duration; |
24 | 26 | import java.util.HashMap; |
25 | 27 | import java.util.Map; |
26 | | -import java.util.stream.IntStream; |
| 28 | +import java.util.concurrent.atomic.AtomicReference; |
27 | 29 | import net.snowflake.client.AbstractDriverIT; |
28 | 30 | import net.snowflake.client.api.datasource.SnowflakeDataSource; |
29 | 31 | import net.snowflake.client.api.datasource.SnowflakeDataSourceFactory; |
@@ -262,69 +264,67 @@ public void testInvalidSSOUrl() { |
262 | 264 | } |
263 | 265 |
|
264 | 266 | /** |
265 | | - * SNOW-3704231: the localhost callback server used to assume a single socket read returned the |
266 | | - * whole HTTP request. Browsers routinely open speculative/preconnect sockets to the callback port |
267 | | - * and close them without sending any data, so {@code in.read(buf)} returns -1 (EOF) and {@code |
268 | | - * new String(buf, 0, -1)} threw StringIndexOutOfBoundsException before the real request was ever |
269 | | - * processed. The server must ignore such empty connections and keep listening. |
270 | | - * |
271 | | - * @throws Throwable if any error occurs |
272 | | - */ |
273 | | - @Test |
274 | | - public void testAuthenticateIgnoresEmptyPreconnectSocket() throws Throwable { |
275 | | - final SFLoginInput loginInput = initMockLoginInput(); |
276 | | - |
277 | | - try (MockedStatic<HttpUtil> mockedHttpUtil = mockStatic(HttpUtil.class)) { |
278 | | - mockSsoUrlResponse(mockedHttpUtil); |
279 | | - |
280 | | - // First connection sends no data (browser preconnect closed immediately) -> read() == -1. |
281 | | - // Second connection carries the real token. |
282 | | - Socket emptySocket = mockSocket(new ByteArrayInputStream(new byte[0])); |
283 | | - Socket tokenSocket = |
284 | | - mockSocket( |
285 | | - toStream( |
286 | | - String.format( |
287 | | - "GET /?token=%s&confirm=1 HTTP/1.1\r\n" + "USER-AGENT: snowflake client", |
288 | | - FakeSessionUtilExternalBrowser.MOCK_SAML_TOKEN))); |
289 | | - |
290 | | - SessionUtilExternalBrowser sub = |
291 | | - new SequencedFakeSessionUtilExternalBrowser( |
292 | | - loginInput, mockServerSocket(emptySocket, tokenSocket)); |
293 | | - sub.authenticate(); |
294 | | - assertEquals(FakeSessionUtilExternalBrowser.MOCK_SAML_TOKEN, sub.getToken()); |
295 | | - } |
296 | | - } |
297 | | - |
298 | | - /** |
299 | | - * SNOW-3704231: large SSO requests (big tokens plus Sec-Fetch and Sec-GPC headers) fill the |
300 | | - * request line and headers with several kilobytes of data. This confirms the callback server |
301 | | - * still parses the token out of such a large request. |
302 | | - * |
303 | | - * @throws Throwable if any error occurs |
| 267 | + * SNOW-3704231: end-to-end over real OS sockets. Reproduces the customer's HAR: a browser |
| 268 | + * preconnect socket that closes without sending data, followed by a large (~3.8 KB) request |
| 269 | + * delivered in two TCP fragments. Exercises the real ServerSocket/Socket + BufferedReader decode |
| 270 | + * path, not Mockito mocks. |
304 | 271 | */ |
305 | 272 | @Test |
306 | | - public void testAuthenticateHandlesLargeRequest() throws Throwable { |
| 273 | + public void testAuthenticateOverRealSocket() throws Throwable { |
307 | 274 | final SFLoginInput loginInput = initMockLoginInput(); |
| 275 | + // A real (non-zero) timeout so the test fails fast instead of hanging if the fix regresses. |
| 276 | + when(loginInput.getBrowserResponseTimeout()).thenReturn(Duration.ofSeconds(30)); |
308 | 277 |
|
309 | | - try (MockedStatic<HttpUtil> mockedHttpUtil = mockStatic(HttpUtil.class)) { |
| 278 | + try (MockedStatic<HttpUtil> mockedHttpUtil = mockStatic(HttpUtil.class); |
| 279 | + ServerSocket serverSocket = new ServerSocket(0, 0, InetAddress.getByName("localhost"))) { |
310 | 280 | mockSsoUrlResponse(mockedHttpUtil); |
| 281 | + int port = serverSocket.getLocalPort(); |
311 | 282 |
|
312 | | - // A large token (mimicking a real SSO token) plus large privacy headers, ~4 KB total. |
313 | | - String largeToken = IntStream.range(0, 3000).mapToObj(i -> "A").reduce("", String::concat); |
314 | | - String request = |
| 283 | + String largeToken = new String(new char[3800]).replace('\0', 'A'); |
| 284 | + byte[] requestBytes = |
315 | 285 | String.format( |
316 | | - "GET /?token=%s&confirm=1 HTTP/1.1\r\n" |
317 | | - + "USER-AGENT: snowflake client\r\n" |
318 | | - + "Sec-Fetch-Site: none\r\n" |
319 | | - + "Sec-GPC: 1\r\n" |
320 | | - + "\r\n", |
321 | | - largeToken); |
322 | | - |
323 | | - Socket socket = mockSocket(toStream(request)); |
| 286 | + "GET /?token=%s&confirm=1 HTTP/1.1\r\n" |
| 287 | + + "USER-AGENT: snowflake client\r\n" |
| 288 | + + "Sec-Fetch-Site: none\r\n" |
| 289 | + + "Sec-GPC: 1\r\n" |
| 290 | + + "\r\n", |
| 291 | + largeToken) |
| 292 | + .getBytes(StandardCharsets.UTF_8); |
| 293 | + |
| 294 | + AtomicReference<Throwable> clientError = new AtomicReference<>(); |
| 295 | + Thread browser = |
| 296 | + new Thread( |
| 297 | + () -> { |
| 298 | + try { |
| 299 | + // 1) Speculative/preconnect socket that closes without sending data (the crash). |
| 300 | + new Socket("localhost", port).close(); |
| 301 | + // 2) Real request split into two TCP fragments to force a partial first read. |
| 302 | + try (Socket s = new Socket("localhost", port)) { |
| 303 | + OutputStream out = s.getOutputStream(); |
| 304 | + int half = requestBytes.length / 2; |
| 305 | + out.write(requestBytes, 0, half); |
| 306 | + out.flush(); |
| 307 | + Thread.sleep(100); |
| 308 | + out.write(requestBytes, half, requestBytes.length - half); |
| 309 | + out.flush(); |
| 310 | + // Drain the driver's HTTP response so it can finish cleanly. |
| 311 | + s.getInputStream().read(new byte[4096]); |
| 312 | + } |
| 313 | + } catch (Throwable t) { |
| 314 | + clientError.set(t); |
| 315 | + } |
| 316 | + }); |
| 317 | + browser.setDaemon(true); |
| 318 | + browser.start(); |
324 | 319 |
|
325 | 320 | SessionUtilExternalBrowser sub = |
326 | | - new SequencedFakeSessionUtilExternalBrowser(loginInput, mockServerSocket(socket)); |
| 321 | + new SequencedFakeSessionUtilExternalBrowser(loginInput, serverSocket); |
327 | 322 | sub.authenticate(); |
| 323 | + |
| 324 | + browser.join(10_000); |
| 325 | + if (clientError.get() != null) { |
| 326 | + throw new AssertionError("Browser client thread failed", clientError.get()); |
| 327 | + } |
328 | 328 | assertEquals(largeToken, sub.getToken()); |
329 | 329 | } |
330 | 330 | } |
@@ -352,25 +352,6 @@ private static void mockSsoUrlResponse(MockedStatic<HttpUtil> mockedHttpUtil) th |
352 | 352 | new HashMap<>())); |
353 | 353 | } |
354 | 354 |
|
355 | | - private static InputStream toStream(String data) { |
356 | | - return new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); |
357 | | - } |
358 | | - |
359 | | - private static Socket mockSocket(InputStream in) throws IOException { |
360 | | - Socket socket = mock(Socket.class); |
361 | | - when(socket.getInputStream()).thenReturn(in); |
362 | | - when(socket.getOutputStream()) |
363 | | - .thenReturn(new FakeSessionUtilExternalBrowser.NullOutputStream()); |
364 | | - return socket; |
365 | | - } |
366 | | - |
367 | | - private static ServerSocket mockServerSocket(Socket first, Socket... rest) throws IOException { |
368 | | - ServerSocket serverSocket = mock(ServerSocket.class); |
369 | | - when(serverSocket.getLocalPort()).thenReturn(12345); |
370 | | - when(serverSocket.accept()).thenReturn(first, rest); |
371 | | - return serverSocket; |
372 | | - } |
373 | | - |
374 | 355 | /** |
375 | 356 | * Mock HttpUtil and SFLoginInput |
376 | 357 | * |
@@ -431,11 +412,7 @@ public void testExternalBrowserTimeout() throws Exception { |
431 | 412 | } |
432 | 413 | } |
433 | 414 |
|
434 | | -/** |
435 | | - * SessionUtilExternalBrowser whose ServerSocket is supplied by the test, so that the sequence of |
436 | | - * accepted connections (e.g. an empty preconnect socket followed by the real request) can be |
437 | | - * controlled. |
438 | | - */ |
| 415 | +/** SessionUtilExternalBrowser with a test-supplied ServerSocket to control accepted connections. */ |
439 | 416 | class SequencedFakeSessionUtilExternalBrowser extends SessionUtilExternalBrowser { |
440 | 417 | private final ServerSocket serverSocket; |
441 | 418 |
|
|
0 commit comments