Skip to content

SNOW-3704231: Fix externalbrowser auth crash on empty localhost callback socket#2687

Open
sfc-gh-rkowalski wants to merge 3 commits into
masterfrom
SNOW-3704231-externalbrowser-empty-callback
Open

SNOW-3704231: Fix externalbrowser auth crash on empty localhost callback socket#2687
sfc-gh-rkowalski wants to merge 3 commits into
masterfrom
SNOW-3704231-externalbrowser-empty-callback

Conversation

@sfc-gh-rkowalski

@sfc-gh-rkowalski sfc-gh-rkowalski commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Overview

SNOW-3704231

JDBC authenticator=externalbrowser SSO fails with StringIndexOutOfBoundsException in SessionUtilExternalBrowser.authenticate. The browser completes SSO and redirects to http://localhost:<port>/?token=..., but the localhost callback server crashes before sending a response, so the login never completes. Blocks browser-based SSO from DBeaver and custom Java apps on the customer's Linux workstation (SnowSQL, which uses a different callback, works).

Root cause

The callback loop assumed a single socket read returned the whole HTTP request:

int strLen = in.read(buf);
String[] rets = new String(buf, 0, strLen).split("\r\n");

This breaks in two ways:

  1. Empty / preconnect connection (the reported crash). BufferedReader.read() returns -1 at EOF — i.e. when a socket is accepted but closed without sending any data. new String(buf, 0, -1) then throws exactly the exception from the ticket:

    java.lang.StringIndexOutOfBoundsException: Range [0, 0 + -1) out of bounds for length 16384
      at net.snowflake.client.internal.core.SessionUtilExternalBrowser.authenticate(...)
    

    Modern browsers routinely open speculative/preconnect sockets to the callback port and close them without sending data. The loop accept()s that empty connection first and crashes before the real request (carrying the token) is ever processed.

  2. Fragmented request. A large SSO request (big token plus Sec-Fetch/Sec-GPC headers, ~3900 bytes in the HAR) can arrive across multiple TCP reads. A single read then returns only the first fragment, so the truncated request line fails to parse (Invalid HTTP request. No token is given from the browser).

Fix

A new readRequest(BufferedReader) helper:

  • Returns an empty string when the peer closed without sending data → the loop logs at debug and keeps listening (fixes Explicitly check for SELECT, so it is not bundled with other DDL statements. #1).
  • Reassembles fragments across multiple reads (fixes Use BIGINT to represent NUMBER(38,0) as DECIMAL only range 2^31 #2). It reads until the end-of-headers marker (\r\n\r\n) and, when the request carries a body, until the full Content-Length bytes have arrived — so a POST callback whose token body lands in a TCP segment separate from the headers (the console-login SAML flow) is no longer truncated. Content-Length is a byte count, so the check compares the body's UTF-8 byte length, not its char count. With no body (Content-Length absent/zero, e.g. a GET callback or CORS preflight) it stops at the blank line so it never blocks while the browser awaits the response.

Reads on the accepted socket are bounded with socket.setSoTimeout(getBrowserResponseTimeout()) so a client that stalls mid-request surfaces the existing timeout error instead of hanging, and the accept loop now uses try-with-resources to close the socket.

readRequest/getContentLength are instance (not static) methods on purpose: SSOConnectionTest drives the flow inside mockStatic(SessionUtilExternalBrowser.class), which would stub a static helper to return null.

Test

Two end-to-end tests in SessionUtilExternalBrowserTest drive the real authenticate() loop against a real ServerSocket, with a background "browser" thread writing over actual TCP (a shared driveRealSocketCallback helper) — the real ServerSocket/Socket + BufferedReader UTF-8 decode path, not Mockito socket mocks:

  • testAuthenticateOverRealSocket reproduces the HAR: a preconnect socket closed without data, followed by a ~3.8 KB GET request delivered in two fragments. Fails on the pre-fix code with the exact ticket exception (StringIndexOutOfBoundsException ... length 16384) and passes with the fix.
  • testAuthenticatePostBodyOverRealSocket sends a POST with the token in the body, delivered in a fragment separate from the headers. Fails on the pre-fix code with No token is given from the browser (the read stopped at \r\n\r\n before the body arrived) and passes with the fix.

CHANGELOG.md updated.

Pre-review self checklist

  • PR branch is updated with all the changes from master branch
  • The code is correctly formatted (run mvn -P check-style validate)
  • New public API is not unnecessary exposed (run mvn verify and inspect target/japicmp/japicmp.html)
  • The pull request name is prefixed with SNOW-XXXX:
  • Code is in compliance with internal logging requirements

@sfc-gh-rkowalski sfc-gh-rkowalski force-pushed the SNOW-3704231-externalbrowser-empty-callback branch from b7b43ba to 48396cd Compare July 6, 2026 17:29
@sfc-gh-rkowalski sfc-gh-rkowalski force-pushed the SNOW-3704231-externalbrowser-empty-callback branch 2 times, most recently from ef4b3c1 to 07b5bc5 Compare July 6, 2026 18:10
@sfc-gh-rkowalski sfc-gh-rkowalski marked this pull request as ready for review July 7, 2026 15:10
@sfc-gh-rkowalski sfc-gh-rkowalski requested a review from a team as a code owner July 7, 2026 15:10
…ack socket

The localhost SSO callback loop assumed a single socket read returned the
whole HTTP request:

    int strLen = in.read(buf);
    String[] rets = new String(buf, 0, strLen).split("\r\n");

BufferedReader.read() returns -1 at EOF, i.e. when a socket is accepted but
closed without sending any data. new String(buf, 0, -1) then throws
StringIndexOutOfBoundsException before the real request could be handled.
Browsers routinely open speculative/preconnect sockets to the callback port
and close them without sending data, so the loop crashed on that empty
connection instead of processing the following request carrying the token.

Guard against strLen < 0 and keep listening for the real request.

Adds unit tests covering the empty preconnect connection and a large SSO
request.
Address review follow-ups on the externalbrowser callback reader:

- Cap the reassembled request at MAX_REQUEST_LENGTH (1 MiB) so a
  misbehaving local client that advertises a huge Content-Length or never
  sends the end-of-headers marker can no longer buffer unboundedly toward
  OOM. The old single-read code was implicitly bounded to 16 KiB.
- Skip the per-read UTF-8 re-encode when no body is expected
  (Content-Length absent/zero) and gate the exact byte count behind the
  cheap char-count check, avoiding the O(n^2) substring+getBytes on the
  GET/preflight path.
- Drop the unreachable length guard in bodyByteLength (bodyStart is always
  within the accumulated request) and the redundant Math.max(0, ...) clamp
  in getContentLength.
- Extract the duplicated large-token test fixture into a LARGE_TOKEN
  constant.
@sfc-gh-rkowalski sfc-gh-rkowalski force-pushed the SNOW-3704231-externalbrowser-empty-callback branch from 58ad4d8 to 78941c2 Compare July 8, 2026 08:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant