Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ A zero-dependency, OAuth-gated **SQL browser for any ClickHouse cluster** —
schema explorer, tabbed SQL editor with syntax highlighting, streaming results
with table / JSON / chart views, saved queries, history, and shareable links.
It ships as a **single self-contained HTML file served from ClickHouse itself**
(no Node server, no CDN, no runtime dependencies).
(no Node server, no CDN, no external fonts, no runtime dependencies) — the page
makes **zero third-party requests** and renders in the OS's native UI font.

Refactored from a single-file SPA into a fully modular, test-first codebase
held at **100% test coverage**.
Expand Down Expand Up @@ -52,11 +53,13 @@ client_id) and the browser sends that as the bearer — so ClickHouse's
`expected_audience` must be the **client_id**, not an API audience. Passing
`--audience` switches to the **access_token** path. See `docs/CLICKHOUSE-OAUTH.md`.

The installer builds `dist/sql.html`, renders `config.json`, and uploads both
into ClickHouse `user_files/`. Then:
The installer builds `dist/sql.html`, renders `config.json`, renders
`dist/http_handlers.xml` (with the CSP `connect-src` filled in for your issuer —
see "Security headers" below), and uploads the SPA + config into ClickHouse
`user_files/`. Then:

1. Add `deploy/http_handlers.xml` to the server's `config.d/` (or push it as an
ACM cluster setting `config.d/sql-browser.xml`) and reload ClickHouse.
1. Add the rendered `dist/http_handlers.xml` to the server's `config.d/` (or push
it as an ACM cluster setting `config.d/sql-browser.xml`) and reload ClickHouse.
2. Register the redirect URI `https://<ch-host>/sql` with your OAuth IdP.
3. Make sure ClickHouse accepts the bearer JWT — either a CH
`<token_processors>` entry validating your IdP's JWKS, or a delegated
Expand Down Expand Up @@ -85,6 +88,33 @@ on your IdP and threat model. Common, all valid, variants:
The code treats `client_secret` as optional, so any of these is a config-only
choice.

### Security headers

`deploy/http_handlers.xml` sends a strict **Content-Security-Policy** plus
`X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` on the SPA
response. The CSP is `default-src 'none'` with everything re-allowed explicitly:

- `script-src`/`style-src 'unsafe-inline'` — the JS and CSS are inlined into the
single HTML file, so they can't be matched by `'self'`. (No `eval`, no remote
scripts; the real protection below is `connect-src`.)
- `connect-src 'self' <issuer-origins>` — the one that matters: it bounds where
the page can send data, so an injected script can't exfiltrate the
`sessionStorage` tokens to an attacker. `'self'` covers ClickHouse queries +
`config.json`; the IdP origins cover OIDC discovery and the token endpoint.
- `img-src data:`, `frame-ancestors 'none'` (anti-clickjacking), `base-uri 'none'`.

`install.sh` fills `connect-src` automatically: it fetches your issuer's OIDC
discovery document and rewrites the host list to your real issuer + token-endpoint
origins (falling back to the Google default if discovery is unreachable). For a
**manual install with a non-Google IdP**, edit the `connect-src` line in
`deploy/http_handlers.xml` to list your issuer + token-endpoint origins.

Preview the rendered artifacts without touching ClickHouse:

```bash
./deploy/install.sh --dry-run --client-id <id> [--issuer https://your-idp]
```

## Layout

```
Expand Down
3 changes: 0 additions & 3 deletions build/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Altinity SQL Browser</title>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTUwIDE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtMTkgNzIgODItNDhMNzUgOSAxOSA0MnptNSAzIDIzIDE0VjYyem00OSAzNEwxOSA3OHY2MnptMjgtNTIgMjktMTctMjQtMTMtMjggMTd6bTMgN3Y2MGwyOCAxNlY4MHptMi00IDI2IDE1VjQ1eiIgZmlsbD0iIzAwNzlBRCIvPjwvc3ZnPg==">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>/*__STYLES__*/</style>
</head>
<body>
Expand Down
11 changes: 11 additions & 0 deletions deploy/http_handlers.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@
<content_type>text/html; charset=UTF-8</content_type>
<http_response_headers>
<Cache-Control>no-store</Cache-Control>
<!-- connect-src OIDC origins: install.sh rewrites the https:// hosts from
the issuer (via OIDC discovery). For a manual install with a non-Google
IdP, replace the two https:// hosts below with your issuer + token
endpoint origins. script-src/style-src need 'unsafe-inline' because the
JS + CSS are inlined into this single HTML file; the real protection is
connect-src, which bounds where the sessionStorage tokens can be sent. -->
<Content-Security-Policy>default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'self'; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com; base-uri 'none'; frame-ancestors 'none'</Content-Security-Policy>
<X-Content-Type-Options>nosniff</X-Content-Type-Options>
<Referrer-Policy>no-referrer</Referrer-Policy>
</http_response_headers>
<response_content>file://sql.html</response_content>
</handler>
Expand All @@ -43,6 +52,8 @@
<content_type>application/json; charset=UTF-8</content_type>
<http_response_headers>
<Cache-Control>no-store</Cache-Control>
<X-Content-Type-Options>nosniff</X-Content-Type-Options>
<Referrer-Policy>no-referrer</Referrer-Policy>
</http_response_headers>
<response_content>file://sql-config.json</response_content>
</handler>
Expand Down
63 changes: 53 additions & 10 deletions deploy/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
# Install the Altinity SQL Browser onto a ClickHouse cluster:
# 1. build the single-file SPA (dist/sql.html)
# 2. render config.json from the OAuth args
# 3. upload both into ClickHouse user_files/ (sql.html, sql-config.json)
# 4. print the http_handlers config to enable /sql
# 3. render dist/http_handlers.xml (CSP connect-src filled from OIDC discovery)
# 4. upload the SPA + config into ClickHouse user_files/ (sql.html, sql-config.json)
# 5. print the next step to enable /sql with the rendered http_handlers.xml
#
# The password is read from the CLICKHOUSE_PASSWORD env var or prompted — never
# passed on the command line (it would leak via `ps`/shell history).
Expand All @@ -17,13 +18,14 @@
# [--audience <aud>] \ # audience-gated CH → also sends access_token
# [--ch-auth basic] \ # OSS CH + ch-jwt-verify → JWT as Basic password
# [--cluster my_cluster] \ # single-shard multi-replica only
# [--secure]
# [--secure] \
# [--dry-run] # build + render config.json + http_handlers.xml, print, no CH contact
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

CH_HOST="" CH_USER="default" ISSUER="https://accounts.google.com"
CLIENT_ID="" AUDIENCE="" CLUSTER="" SECURE=0 CH_AUTH=""
CLIENT_ID="" AUDIENCE="" CLUSTER="" SECURE=0 CH_AUTH="" DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--ch-host) CH_HOST="$2"; shift 2 ;;
Expand All @@ -34,14 +36,16 @@ while [[ $# -gt 0 ]]; do
--ch-auth) CH_AUTH="$2"; shift 2 ;;
--cluster) CLUSTER="$2"; shift 2 ;;
--secure) SECURE=1; shift ;;
--dry-run) DRY_RUN=1; shift ;; # build + render config.json + http_handlers.xml, print, no ClickHouse contact
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done

[[ -n "$CH_HOST" ]] || { echo "--ch-host is required" >&2; exit 2; }
[[ -n "$CLIENT_ID" ]] || { echo "--client-id is required" >&2; exit 2; }
# --ch-host is only needed to reach ClickHouse; a --dry-run just renders artifacts.
[[ -n "$CH_HOST" || "$DRY_RUN" == 1 ]] || { echo "--ch-host is required" >&2; exit 2; }

if [[ -z "${CLICKHOUSE_PASSWORD:-}" ]]; then
if [[ "$DRY_RUN" != 1 && -z "${CLICKHOUSE_PASSWORD:-}" ]]; then
read -r -s -p "ClickHouse password for $CH_USER@$CH_HOST: " CLICKHOUSE_PASSWORD
echo
fi
Expand All @@ -52,7 +56,7 @@ CH=(clickhouse-client --host "$CH_HOST" --user "$CH_USER")

# user_files is node-local, and clusterAllReplicas cannot write to a multi-shard
# Distributed target, so a --cluster install only works on a single shard.
if [[ -n "$CLUSTER" ]]; then
if [[ "$DRY_RUN" != 1 && -n "$CLUSTER" ]]; then
SHARDS=$("${CH[@]}" --query "SELECT max(shard_num) FROM system.clusters WHERE cluster = '${CLUSTER}'" 2>/dev/null || true)
if [[ "$SHARDS" =~ ^[0-9]+$ ]] && (( SHARDS > 1 )); then
echo "ERROR: cluster '${CLUSTER}' has ${SHARDS} shards. clusterAllReplicas can't" >&2
Expand Down Expand Up @@ -84,6 +88,44 @@ CONFIG_FILE="$(mktemp)"
trap 'rm -f "$CONFIG_FILE"' EXIT
printf '%s\n' "$CONFIG_JSON" > "$CONFIG_FILE"

echo "==> Rendering http_handlers.xml (CSP connect-src from OIDC discovery)"
# The CSP connect-src must allow same-origin ('self', for ClickHouse + config.json)
# plus the IdP origins the browser fetches: OIDC discovery (issuer origin) and the
# token endpoint (exchange + refresh). The OAuth /authorize step is a top-level
# navigation, not a fetch, so it needs no connect-src entry. Resolve the real
# origins from the issuer's discovery document; fail soft to the Google default.
ISSUER_ORIGIN="$(printf '%s' "$ISSUER" | grep -oiE '^https?://[^/]+' || true)"
CONNECT_HOSTS="https://accounts.google.com https://oauth2.googleapis.com"
DISC_URL="${ISSUER%/}/.well-known/openid-configuration"
if DISC_JSON="$(curl -fsS --max-time 10 "$DISC_URL" 2>/dev/null)"; then
# Pull the origin (scheme://host[:port]) of token/authorization endpoints, add
# the issuer origin, dedupe. Tolerates whitespace variations in the JSON.
EP_ORIGINS="$(printf '%s' "$DISC_JSON" \
| grep -oE '"(token_endpoint|authorization_endpoint)"[[:space:]]*:[[:space:]]*"[^"]+"' \
| grep -oiE 'https?://[^/"]+' || true)"
CONNECT_HOSTS="$(printf '%s\n%s\n' "$ISSUER_ORIGIN" "$EP_ORIGINS" \
| sed '/^$/d' | sort -u | paste -sd' ' -)"
echo " connect-src origins: $CONNECT_HOSTS"
else
echo "WARN: could not fetch $DISC_URL — using the Google default connect-src." >&2
echo " If your IdP is not Google, edit connect-src in dist/http_handlers.xml." >&2
fi
# Rewrite only the connect-src host list in the committed template; everything else
# (the rest of the CSP, the other headers) is copied verbatim.
HANDLERS_OUT="$ROOT/dist/http_handlers.xml"
sed -E "s#(connect-src 'self')[^;]*#\1 ${CONNECT_HOSTS}#" \
"$ROOT/deploy/http_handlers.xml" > "$HANDLERS_OUT"

if [[ "$DRY_RUN" == 1 ]]; then
echo
echo "==> DRY RUN — no ClickHouse contact. Rendered artifacts:"
echo "--- config.json ---"
cat "$CONFIG_FILE"
echo "--- dist/http_handlers.xml ---"
cat "$HANDLERS_OUT"
exit 0
fi

# Upload raw bytes via FORMAT RawBLOB on stdin — no base64, no command-line
# length limit, written as the clickhouse process so perms are correct.
upload() { # upload <local-file> <user_files-filename>
Expand Down Expand Up @@ -113,9 +155,10 @@ cat <<EOF

==> Assets uploaded to ClickHouse user_files/.

Final step — enable the HTTP routes. Add deploy/http_handlers.xml to the
server config.d/ (or push it as an ACM cluster setting named
"config.d/sql-browser.xml") and reload ClickHouse. Then open:
Final step — enable the HTTP routes. Add the rendered dist/http_handlers.xml
(its CSP connect-src is filled in for your issuer) to the server config.d/ (or
push it as an ACM cluster setting named "config.d/sql-browser.xml") and reload
ClickHouse. Then open:

http${SECURE:+s}://$CH_HOST/sql

Expand Down
13 changes: 11 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,19 @@ env var or prompted — never placed on the command line.

## 3. HTTP routes

Add `deploy/http_handlers.xml` to ClickHouse `config.d/` (or push it through
Add the http_handlers fragment to ClickHouse `config.d/` (or push it through
your control plane as `config.d/sql-browser.xml`) and reload. It adds static
rules for `/sql` and `/sql/config.json` and keeps `<defaults/>` so the dynamic
query handler at `/` still works.
query handler at `/` still works. The SPA rule also sends a strict
Content-Security-Policy (`default-src 'none'`, `frame-ancestors 'none'`, and a
`connect-src` scoped to same-origin + your IdP) plus `nosniff` and
`Referrer-Policy: no-referrer` — see README "Security headers".

`deploy/http_handlers.xml` is the committed default (Google `connect-src`).
`install.sh` renders `dist/http_handlers.xml` with `connect-src` filled in for
your `--issuer`; deploy that rendered file. For a manual install with a
non-Google IdP, edit the `connect-src` line to your issuer + token-endpoint
origins.

## 4. Make ClickHouse accept the JWT

Expand Down
5 changes: 4 additions & 1 deletion src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ export function timeAgo(ts, now = Date.now()) {

/** Quote + escape a string as a ClickHouse SQL string literal. */
export function sqlString(s) {
return "'" + String(s).replace(/'/g, "''") + "'";
// Escape the backslash first (CH honors backslash escapes in string literals,
// so a trailing `\` would otherwise escape the closing quote and break out),
// then double the single quote.
return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "''") + "'";
}

/**
Expand Down
12 changes: 10 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ export async function bootstrap(app, env) {
const u = new URL(loc.href);
const code = u.searchParams.get('code');
const stateParam = u.searchParams.get('state');
const errorParam = u.searchParams.get('error');
let callbackError = null;

if (code && stateParam) {
if (errorParam) {
// The IdP bounced back with an error (e.g. ?error=access_denied) instead of
// a code — surface it rather than dropping silently onto the login screen.
callbackError = 'Sign-in failed: ' + (u.searchParams.get('error_description') || errorParam);
} else if (code && stateParam) {
if (stateParam !== ss.getItem('oauth_state')) {
callbackError = 'OAuth state mismatch — please try again.';
} else {
Expand All @@ -36,7 +41,10 @@ export async function bootstrap(app, env) {
callbackError = 'OAuth token exchange failed: ' + ((e && e.message) || e);
}
}
['code', 'state', 'scope', 'authuser', 'prompt'].forEach((k) => u.searchParams.delete(k));
}
if (errorParam || (code && stateParam)) {
['code', 'state', 'scope', 'authuser', 'prompt', 'error', 'error_description', 'error_uri']
.forEach((k) => u.searchParams.delete(k));
const qs = u.searchParams.toString();
hist.replaceState(null, '', loc.origin + loc.pathname + (qs ? '?' + qs : '') + loc.hash);
}
Expand Down
6 changes: 6 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,9 @@ export function clearHistory(state, save = saveJSON) {
state.history = [];
save(KEYS.history, state.history);
}

/** Delete one history entry by id. */
export function deleteHistory(state, id, save = saveJSON) {
state.history = state.history.filter((h) => h.id !== id);
save(KEYS.history, state.history);
}
41 changes: 39 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -255,15 +255,34 @@ body {
}
.saved-row:hover .del { display: inline-flex; }
.saved-row .del:hover { color: var(--fg); background: var(--bg-hover); }
.list-head {
display: flex; justify-content: flex-end;
padding: 5px 8px; border-bottom: 1px solid var(--border-faint);
}
.clear-btn {
border: none; background: transparent; color: var(--fg-faint);
font-size: 10.5px; cursor: pointer; padding: 3px 7px; border-radius: 4px;
}
.clear-btn:hover { color: var(--error-fg); background: var(--bg-hover); }
.history-row {
position: relative;
padding: 8px 10px; cursor: pointer; user-select: none;
border-bottom: 1px solid var(--border-faint);
display: flex; flex-direction: column; gap: 3px;
}
.history-row:hover { background: var(--bg-hover); }
.history-row .del {
position: absolute; top: 6px; right: 8px;
width: 18px; height: 18px; border: none; background: transparent;
color: var(--fg-faint); cursor: pointer; border-radius: 3px;
display: none; align-items: center; justify-content: center; padding: 0;
}
.history-row:hover .del { display: inline-flex; }
.history-row .del:hover { color: var(--fg); background: var(--bg-hover); }
.history-row .sql {
font-size: 11px; font-family: var(--mono); color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding-right: 20px;
}
.history-row .meta {
display: flex; gap: 10px;
Expand Down Expand Up @@ -348,6 +367,8 @@ body {
color: var(--fg); white-space: pre;
background: var(--bg);
}
/* Focusable (tabindex) so a click scopes ⌘A to the pane's text; no focus ring. */
.raw-text-view:focus, .json-view:focus { outline: none; }

/* ------------ chart placeholder ------------ */
.chart-view {
Expand Down Expand Up @@ -532,7 +553,12 @@ body {
position: relative;
display: flex; width: 100%; height: 100%;
font-family: var(--mono);
font-size: 13px; line-height: 1.7;
/* Integer px line-height (not unitless): the editor overlays a <textarea> on a
highlight <pre>, and a fractional line box (1.7 * 13px = 22.1px) was rounded
differently by the textarea's internal layout vs the block <pre>, so the
native selection drifted upward from the painted glyphs, growing with line
number. An integer line-height makes both lay out identically. */
font-size: 13px; line-height: 22px;
background: var(--bg-editor); color: var(--fg);
overflow: hidden;
}
Expand All @@ -546,7 +572,7 @@ body {
overflow: hidden;
font-variant-numeric: tabular-nums;
}
.sql-gutter > div { height: 1.7em; }
.sql-gutter > div { height: 22px; }
.sql-area { position: relative; flex: 1; overflow: hidden; }
.sql-pre {
position: absolute; inset: 0; margin: 0;
Expand Down Expand Up @@ -668,6 +694,17 @@ table.res-table th {
padding: 7px 12px;
cursor: pointer;
}
table.res-table th .col-resize-h {
position: absolute; top: 0; right: 0; z-index: 2;
width: 7px; height: 100%;
cursor: col-resize;
}
table.res-table th .col-resize-h:hover { background: var(--accent); opacity: .45; }
/* Once any column is resized the table is laid out fixed so the per-column px
widths are honored exactly; clip overflow so a narrowed column ellipsizes
instead of spilling. */
table.res-table.fixed { table-layout: fixed; }
table.res-table.fixed th, table.res-table.fixed td { overflow: hidden; text-overflow: ellipsis; }
table.res-table th .h-inner { display: flex; align-items: center; gap: 5px; }
table.res-table th .h-name { color: var(--fg); }
table.res-table th .h-type { font-size: 9.5px; color: var(--fg-faint); font-weight: 400; }
Expand Down
4 changes: 4 additions & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export function createApp(env = {}) {
app.refreshToken = refresh;
ss.setItem('oauth_refresh_token', refresh);
}
// The PKCE verifier + CSRF state are one-shot — done with them once we hold
// tokens. (The refresh path also lands here; they're already gone → no-op.)
ss.removeItem('oauth_verifier');
ss.removeItem('oauth_state');
}
function clearTokens() {
app.token = null;
Expand Down
Loading
Loading