Skip to content

Commit 48396cd

Browse files
SNOW-3704231: Reassemble fragmented localhost callback requests
1 parent 4384654 commit 48396cd

3 files changed

Lines changed: 82 additions & 91 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- Fixed flaky `DatabaseMetaDataIT.testGetPrimarykeys` and `SnowflakeDriverIT.testConstraints` (eventually-consistent PK/FK constraint metadata after DDL) by retrying the constraint metadata lookups until visible. Replaced the live, concurrent stress tests `DatabaseMetaDataLatestIT.test[No]PatternSearchAllowedForPrimaryAndForeignKeys` (which timed out on slow CI runners) with deterministic coverage in the new `DatabaseMetadataWiremockLatestIT`, which validates the `getTables`/`getPrimaryKeys`/`getImportedKeys` SHOW-result mapping and the `enablePatternSearch` pattern-vs-literal behavior using stubbed responses (no live account or metadata-propagation dependency). Test-only change with no driver behavior impact (snowflakedb/snowflake-jdbc#2673).
1111
- Fixed the Loader API (`StreamLoader.setVectorColumns`) throwing `Loader$ConnectionError: ... Result set has been closed` when the `getColumns` metadata lookup returned no rows. The result set returned by `getColumns` closes itself once `next()` runs out of matching rows, so the unchecked `rs.next()` followed by `rs.getString(...)` raised "Result set has been closed"; the result is now read only when `rs.next()` returns a row. This intermittently aborted concurrent loads (e.g. `FlatfileReadMultithreadIT`) when the `SHOW COLUMNS` metadata query raced with concurrent DML on the same table (snowflakedb/snowflake-jdbc#2674).
1212
- Fixed `uploadStream`/`downloadStream` failing with SQL compilation errors when the stage reference contains non-ASCII characters (e.g. Japanese schema names). Stage references in internally generated PUT/GET commands are now wrapped in single quotes when they contain characters that require quoting per Snowflake SQL syntax (SNOW-3713887).
13+
- Fixed `authenticator=externalbrowser` login crashing with `StringIndexOutOfBoundsException` when the browser opened an empty preconnect socket or delivered a large SSO request across multiple reads; the localhost callback server now ignores empty connections and reassembles fragmented requests (snowflakedb/snowflake-jdbc#2687).
1314

1415
- v4.3.1
1516
- Fixed GCS-backed internal stage PUT failing with opaque `invalid_gcs_credentials` in SPCS pods on GCP: the GCS SDK's Application Default Credentials (ADC) probe was reaching out to `metadata.google.internal` which is unreachable inside SPCS; explicit credentials are now always set when a `GCS_ACCESS_TOKEN` is present, suppressing the ADC probe entirely. Also fixed `GCSAccessStrategyAwsSdk` rejecting custom GCS endpoints that lack an `https://` scheme prefix (e.g. bare `storage.me-central2.rep.googleapis.com`), mirroring the existing handling in `GCSDefaultAccessStrategy`. The catch-all in `setupGCSClient` now chains and logs the original exception instead of swallowing it (snowflakedb/snowflake-jdbc#2664).

src/main/java/net/snowflake/client/internal/core/SessionUtilExternalBrowser.java

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -295,28 +295,22 @@ void authenticate() throws SFException, SnowflakeSQLException {
295295
}
296296

297297
while (true) {
298-
Socket socket = ssocket.accept(); // start accepting the request
299-
try {
298+
try (Socket socket = ssocket.accept()) {
299+
// Bound reads so a client that connects then stalls mid-request cannot hang the server.
300+
socket.setSoTimeout(getBrowserResponseTimeout());
300301
BufferedReader in =
301302
new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF8_CHARSET));
302-
char[] buf = new char[16384];
303-
int strLen = in.read(buf);
304-
if (strLen < 0) {
305-
// The browser frequently opens speculative/preconnect sockets to the localhost
306-
// callback server and closes them without sending any data. The read then returns -1
307-
// (EOF). Previously this produced `new String(buf, 0, -1)` and crashed with
308-
// StringIndexOutOfBoundsException before the real request could be handled. Ignore such
309-
// empty connections and keep listening. See SNOW-3704231.
303+
String request = readRequest(in);
304+
if (request.isEmpty()) {
305+
// Browser preconnect/speculative socket closed without sending data. See SNOW-3704231.
310306
logger.debug("Received empty request on localhost callback socket. Ignoring.");
311307
continue;
312308
}
313-
String[] rets = new String(buf, 0, strLen).split("\r\n");
309+
String[] rets = request.split("\r\n");
314310
if (!processOptions(rets, socket)) {
315311
processSamlToken(rets, socket);
316312
break;
317313
}
318-
} finally {
319-
socket.close();
320314
}
321315
}
322316
} catch (SocketTimeoutException e) {
@@ -337,6 +331,25 @@ void authenticate() throws SFException, SnowflakeSQLException {
337331
}
338332
}
339333

334+
/**
335+
* Reads a full HTTP request from the localhost callback socket, reassembling fragments until the
336+
* end-of-headers marker ({@code \r\n\r\n}) or EOF. Returns an empty string if the peer closed the
337+
* connection without sending data (e.g. a browser preconnect socket). See SNOW-3704231.
338+
*/
339+
private static String readRequest(BufferedReader in) throws IOException {
340+
char[] buf = new char[16384];
341+
StringBuilder request = new StringBuilder();
342+
int strLen;
343+
while ((strLen = in.read(buf)) >= 0) {
344+
request.append(buf, 0, strLen);
345+
// Stop at end of headers; the browser holds the connection open awaiting our response.
346+
if (request.indexOf("\r\n\r\n") >= 0) {
347+
break;
348+
}
349+
}
350+
return request.toString();
351+
}
352+
340353
private boolean processOptions(String[] rets, Socket socket) throws IOException {
341354
String targetLine = null;
342355
String userAgent = null;

src/test/java/net/snowflake/client/internal/core/SessionUtilExternalBrowserTest.java

Lines changed: 55 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import java.io.IOException;
1414
import java.io.InputStream;
1515
import java.io.OutputStream;
16+
import java.net.InetAddress;
1617
import java.net.ServerSocket;
1718
import java.net.Socket;
1819
import java.net.SocketTimeoutException;
@@ -21,9 +22,10 @@
2122
import java.nio.charset.StandardCharsets;
2223
import java.sql.Connection;
2324
import java.sql.ResultSet;
25+
import java.time.Duration;
2426
import java.util.HashMap;
2527
import java.util.Map;
26-
import java.util.stream.IntStream;
28+
import java.util.concurrent.atomic.AtomicReference;
2729
import net.snowflake.client.AbstractDriverIT;
2830
import net.snowflake.client.api.datasource.SnowflakeDataSource;
2931
import net.snowflake.client.api.datasource.SnowflakeDataSourceFactory;
@@ -262,69 +264,67 @@ public void testInvalidSSOUrl() {
262264
}
263265

264266
/**
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.
304271
*/
305272
@Test
306-
public void testAuthenticateHandlesLargeRequest() throws Throwable {
273+
public void testAuthenticateOverRealSocket() throws Throwable {
307274
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));
308277

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"))) {
310280
mockSsoUrlResponse(mockedHttpUtil);
281+
int port = serverSocket.getLocalPort();
311282

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 =
315285
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();
324319

325320
SessionUtilExternalBrowser sub =
326-
new SequencedFakeSessionUtilExternalBrowser(loginInput, mockServerSocket(socket));
321+
new SequencedFakeSessionUtilExternalBrowser(loginInput, serverSocket);
327322
sub.authenticate();
323+
324+
browser.join(10_000);
325+
if (clientError.get() != null) {
326+
throw new AssertionError("Browser client thread failed", clientError.get());
327+
}
328328
assertEquals(largeToken, sub.getToken());
329329
}
330330
}
@@ -352,25 +352,6 @@ private static void mockSsoUrlResponse(MockedStatic<HttpUtil> mockedHttpUtil) th
352352
new HashMap<>()));
353353
}
354354

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-
374355
/**
375356
* Mock HttpUtil and SFLoginInput
376357
*
@@ -431,11 +412,7 @@ public void testExternalBrowserTimeout() throws Exception {
431412
}
432413
}
433414

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. */
439416
class SequencedFakeSessionUtilExternalBrowser extends SessionUtilExternalBrowser {
440417
private final ServerSocket serverSocket;
441418

0 commit comments

Comments
 (0)