Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Configure the driver using properties passed to `DriverManager.getConnection()`:
| `sessionType` | `SessionType` | `MULTI` | `SINGLE` or `MULTI` concurrent connections |
| `forceNew` | `boolean` | `false` | Force creation of a new session instead of reusing an existing one |
| `wsUri` | `String` | _(none)_ | Connect directly to a WebSocket URI (advanced) |
| `clientChain` | `String` | _(none)_ | Upstream client chain to attribute the connection to (see [Client attribution](#client-attribution)) |

### Result Options

Expand Down Expand Up @@ -141,6 +142,34 @@ API as-is. Omit the property to use your organization's configured default.

</details>

## Client attribution

Every request the driver makes — session creation, session polling, and the
session WebSocket upgrade — carries an `X-Wherobots-Client` header identifying
the driver:

```
X-Wherobots-Client: client=jdbc;ver=0.4.0;plat=mac-os-x
```

The header is an ordered, comma-separated list of hops; the leftmost hop is the
origin client and each component appends its own hop on the right. It is
advisory telemetry used for attribution and analytics, and never affects
authentication or authorization.

If you are embedding the driver in another Wherobots client and already have a
chain to attribute the connection to, pass it as `clientChain` and the driver
appends its own hop to the right of it:

```java
props.put("clientChain", "client=claude_web, client=mcp;ver=0.9");
// X-Wherobots-Client: client=claude_web, client=mcp;ver=0.9, client=jdbc;ver=0.4.0;plat=mac-os-x
```

The value is sanitized — characters that would corrupt the header grammar are
replaced — and dropped entirely if it would push the header past its 512-byte
bound, so a bad `clientChain` can never break a connection.

## Using with DataGrip

1. Download the latest driver JAR from [Maven
Expand Down
202 changes: 202 additions & 0 deletions lib/src/main/java/com/wherobots/db/jdbc/ClientHeader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package com.wherobots.db.jdbc;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;

/**
* Helpers for the shared, cross-client {@code X-Wherobots-Client} attribution
* header.
* <p>
* The header is an ordered, append-only, comma-separated list of hops, where
* each hop has the form {@code client=<token>} optionally followed by
* {@code ;key=value} parameters ({@code ver}, {@code plat}, {@code cmd}). The
* <em>leftmost</em> hop is the origin client and each component appends its own
* hop <em>on the right</em>. This driver's hop is
* {@code client=jdbc;ver=<driver version>;plat=<os name>}.
* </p>
* <p>
* The driver is normally an origin client and emits a single hop. A caller that
* is itself acting on behalf of an upstream Wherobots client (a BI tool
* integration, a service embedding the driver) can pass that upstream chain
* through the {@code clientChain} connection property; it is sanitized and kept
* to the left of this driver's hop.
* </p>
* <p>
* The header is <strong>advisory only</strong>: it is client-asserted, used for
* attribution and analytics, and must never influence authentication or
* authorization.
* </p>
*/
public final class ClientHeader {

/** Canonical name of the shared client-chain header. */
public static final String HEADER_NAME = "X-Wherobots-Client";

/** This driver's stable token in the shared client vocabulary. */
public static final String CLIENT_TOKEN = "jdbc";

/**
* Maximum size of the rendered header value, in UTF-8 bytes. A larger value
* is treated as malformed by the server-side parser, which then attributes
* the request to {@code unknown} — so we never emit one.
*/
public static final int MAX_HEADER_BYTES = 512;

/** Recorded when the driver version cannot be determined. */
static final String UNKNOWN_VERSION = "unknown";

/**
* Individual tokens are kept well under 64 characters, per the header
* convention.
*/
private static final int MAX_PARAM_LENGTH = 63;

/**
* Characters allowed in a parameter value we render ourselves. Anything
* else — including the {@code ,} and {@code ;} delimiters and any control
* character — collapses to a single hyphen.
*/
private static final Pattern UNSAFE_PARAM_CHARS = Pattern.compile("[^A-Za-z0-9._+-]+");

/**
* Characters allowed in a caller-supplied upstream chain. The chain carries
* its own {@code ,} / {@code ;} / {@code =} grammar, so those are kept;
* everything outside this set (notably CR, LF and other control characters,
* which would allow header injection) becomes an underscore.
*/
private static final Pattern UNSAFE_CHAIN_CHARS = Pattern.compile("[^A-Za-z0-9._+:/@=;, -]");

private static final Pattern REPEATED_WHITESPACE = Pattern.compile("\\s+");

private ClientHeader() {}

/**
* Returns a copy of {@code headers} carrying a well-formed
* {@code X-Wherobots-Client} value.
* <p>
* Any existing entry for the header — matched case-insensitively, since HTTP
* header names are case-insensitive — is treated as the upstream chain and
* collapsed into the canonical key, so the request carries exactly one such
* header with this driver's hop appended on the right.
* </p>
*
* @param headers the outgoing headers; may be null or immutable, and is
* never modified
* @return a new mutable map with the attribution header set
*/
public static Map<String, String> withHop(Map<String, String> headers) {
Map<String, String> result = new LinkedHashMap<>();
String upstream = null;
if (headers != null) {
for (Map.Entry<String, String> entry : headers.entrySet()) {
if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(HEADER_NAME)) {
upstream = entry.getValue();
Comment thread
salty-hambot[bot] marked this conversation as resolved.
Outdated
continue;
}
result.put(entry.getKey(), entry.getValue());
}
}
result.put(HEADER_NAME, value(upstream));
return result;
}

/**
* Renders the full header value: the sanitized upstream chain, if any,
* followed by this driver's own hop.
*
* @param upstreamChain an upstream chain to prepend, or null
* @return a header value that always fits within {@link #MAX_HEADER_BYTES}
*/
public static String value(String upstreamChain) {
String hop = hop(driverVersion(), System.getProperty("os.name"));
String chain = sanitizeChain(upstreamChain);
if (chain.isEmpty()) {
return hop;
}

String combined = chain + ", " + hop;
if (utf8Length(combined) > MAX_HEADER_BYTES) {
// Truncating mid-chain would corrupt the grammar, and an oversized
// value makes the whole header unparseable — so drop the untrusted
// upstream chain and keep our own attributable hop.
return hop;
}
return combined;
}

/**
* Renders this driver's single hop, {@code client=jdbc;ver=<version>} plus
* {@code ;plat=<platform>} when the platform is known. An unavailable
* version degrades to {@code ver=unknown} rather than dropping the hop.
*/
static String hop(String version, String platform) {
String ver = sanitizeParam(version);
String plat = sanitizeParam(platform == null ? null : platform.toLowerCase(Locale.ROOT));

StringBuilder hop = new StringBuilder("client=").append(CLIENT_TOKEN);
hop.append(";ver=").append(ver.isEmpty() ? UNKNOWN_VERSION : ver);
if (!plat.isEmpty()) {
hop.append(";plat=").append(plat);
}
return hop.toString();
}

/**
* The driver version as recorded in the JAR manifest, or null when the
* driver runs from a plain class directory (tests, IDE runs) and there is no
* manifest to read.
*/
static String driverVersion() {
Package pkg = ClientHeader.class.getPackage();
return pkg == null ? null : pkg.getImplementationVersion();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: getImplementationVersion() reads whatever manifest defined the package — so if someone shades the driver into their own jar we'll get the wrong value. we could bake the version in as a build-time generated constant instead. getUserAgentHeader() has the same thing today, so possibly a follow-up rather than this PR.

}

/**
* Makes a caller-supplied chain safe to send: drops characters that could
* corrupt the grammar or inject a header, normalizes whitespace, and removes
* empty hops. A chain that cannot fit in the header at all yields an empty
* string.
*/
static String sanitizeChain(String chain) {
if (chain == null) {
return "";
}

String cleaned = UNSAFE_CHAIN_CHARS.matcher(chain).replaceAll("_");
List<String> hops = new ArrayList<>();
for (String hop : cleaned.split(",")) {
String normalized = REPEATED_WHITESPACE.matcher(hop).replaceAll(" ").trim();
if (!normalized.isEmpty()) {
hops.add(normalized);
}
}

String joined = String.join(", ", hops);
return utf8Length(joined) > MAX_HEADER_BYTES ? "" : joined;
}

private static String sanitizeParam(String value) {
if (value == null) {
return "";
}
String sanitized = UNSAFE_PARAM_CHARS.matcher(value.trim()).replaceAll("-");
// Leading/trailing separators carry no information and read as noise.
sanitized = sanitized.replaceAll("^-+", "");
if (sanitized.length() > MAX_PARAM_LENGTH) {
sanitized = sanitized.substring(0, MAX_PARAM_LENGTH);
}
// Stripped after truncation as well as before it: the cut can land
// immediately after a replaced character and expose a trailing `-`
// that was in the middle of the value a moment ago.
return sanitized.replaceAll("-+$", "");
}

private static int utf8Length(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ public class WherobotsJdbcDriver implements Driver {
public static final String SHUTDOWN_AFTER_INACTIVE_SECONDS_PROP = "shutdownAfterInactiveSeconds";
public static final String WS_URI_PROP = "wsUri";

/**
* An optional upstream {@code X-Wherobots-Client} chain to attribute this
* connection to. Set it only when the driver is embedded in another
* Wherobots client that already received or produced a chain; the driver
* appends its own {@code client=jdbc} hop to the right of it. The value is
* sanitized and dropped entirely if it would make the header exceed its
* size bound. This is advisory telemetry and never affects authentication.
*
* @see ClientHeader
*/
public static final String CLIENT_CHAIN_PROP = "clientChain";

// Results format; one of {@link DataFormat}
public static final String FORMAT_PROP = "format";

Expand Down Expand Up @@ -82,6 +94,22 @@ public Map<String, String> getUserAgentHeader() {
return Map.of("User-Agent", userAgent);
}

/**
* Carries the caller-supplied upstream client chain, if any, into the
* outgoing headers. The driver's own hop is appended to it — and the value
* is sanitized — when the session request headers are built, so an absent
* or blank property simply yields no upstream chain.
*
* @see ClientHeader#withHop(Map)
*/
Map<String, String> getClientChainHeader(Properties info) {
String chain = info.getProperty(CLIENT_CHAIN_PROP);
if (StringUtils.isBlank(chain)) {
return Collections.emptyMap();
}
return Map.of(ClientHeader.HEADER_NAME, chain);
}

@Override
public Connection connect(String url, Properties info) throws SQLException {
String host = DEFAULT_ENDPOINT;
Expand Down Expand Up @@ -129,6 +157,7 @@ public Connection connect(String url, Properties info) throws SQLException {

Map<String, String> headers = new HashMap<>(getAuthHeaders(info));
headers.putAll(getUserAgentHeader());
headers.putAll(getClientChainHeader(info));
WherobotsSession session;

String wsUriString = info.getProperty(WS_URI_PROP);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.fasterxml.jackson.core.type.TypeReference;
import com.wherobots.db.AppStatus;
import com.wherobots.db.SessionType;
import com.wherobots.db.jdbc.ClientHeader;
import com.wherobots.db.jdbc.serde.JsonUtil;
import io.github.resilience4j.core.IntervalFunction;
import io.github.resilience4j.core.functions.CheckedSupplier;
Expand Down Expand Up @@ -78,6 +79,16 @@ public static WherobotsSession create(String host, String runtime, String region
boolean forceNew, Integer shutdownAfterInactiveSeconds,
Map<String, String> headers)
throws SQLException {
// Append this driver's hop to the shared X-Wherobots-Client chain once,
// here, so every request built below — the session creation POST, the
// session polling GETs, and the WebSocket upgrade — carries it.
Map<String, String> requestHeaders = ClientHeader.withHop(headers);
// Without this, the only way to see what attribution actually went out
// is a packet capture. The chain is advisory, client-asserted metadata
// — no credentials pass through it — so it is safe to log.
logger.debug("{}: {}", ClientHeader.HEADER_NAME,
requestHeaders.get(ClientHeader.HEADER_NAME));

Comment thread
salty-hambot[bot] marked this conversation as resolved.
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
Expand All @@ -91,9 +102,9 @@ public static WherobotsSession create(String host, String runtime, String region
Retry retry = RetryRegistry.of(config).retry("session");

try {
URI sessionIdUri = new SqlSessionSupplier(client, headers, host, runtime, region, version, sessionType, forceNew, shutdownAfterInactiveSeconds).get();
URI wsUri = Retry.decorateCheckedSupplier(retry, new SessionWsUriSupplier(client, headers, sessionIdUri)).get();
return create(wsUri, headers);
URI sessionIdUri = new SqlSessionSupplier(client, requestHeaders, host, runtime, region, version, sessionType, forceNew, shutdownAfterInactiveSeconds).get();
URI wsUri = Retry.decorateCheckedSupplier(retry, new SessionWsUriSupplier(client, requestHeaders, sessionIdUri)).get();
return connect(wsUri, requestHeaders);
} catch (SQLException e) {
Comment thread
salty-hambot[bot] marked this conversation as resolved.
throw e;
} catch (Throwable t) {
Expand All @@ -111,6 +122,15 @@ public static WherobotsSession create(String host, String runtime, String region
*/
public static WherobotsSession create(URI wsUri, Map<String, String> headers)
throws SQLException {
return connect(wsUri, ClientHeader.withHop(headers));
}

/**
* Opens the session WebSocket with headers that already carry this driver's
* attribution hop.
*/
private static WherobotsSession connect(URI wsUri, Map<String, String> headers)
throws SQLException {
logger.info("Connecting to SQL Session at {} ...", wsUri);
try {
return new WherobotsSession(wsUri, headers);
Expand Down
Loading
Loading