Skip to content

Commit 2a03dca

Browse files
Isolator acmclaude
authored andcommitted
harden(deploy): add CSP + security headers, drop external Google Fonts
Two defense-in-depth fixes from the security review: 1. Self-contained fonts. Remove the Google Fonts <link>s from build/template.html; the --ui/--mono CSS stacks already fall back to system fonts. The served page now makes zero third-party requests (privacy + air-gap + truly self-contained). 2. Security headers. deploy/http_handlers.xml now sends a strict CSP (default-src 'none'; connect-src 'self' <issuer-origins>; frame-ancestors 'none'; base-uri 'none'; img-src data:; script/style 'unsafe-inline' since the bundle is inlined), plus nosniff and Referrer-Policy: no-referrer. connect-src is the real win — it bounds where the sessionStorage tokens can be sent if an XSS ever lands. install.sh resolves the issuer's OIDC discovery and rewrites connect-src to the real issuer + token-endpoint origins (fail-soft to the Google default if discovery is unreachable), writing the rendered file to dist/http_handlers.xml. New --dry-run flag renders config.json + http_handlers.xml and prints them with no ClickHouse contact. README + DEPLOYMENT docs updated. No src/ changes; 319 tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef
1 parent f6f5389 commit 2a03dca

5 files changed

Lines changed: 110 additions & 20 deletions

File tree

README.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ A zero-dependency, OAuth-gated **SQL browser for any ClickHouse cluster** —
44
schema explorer, tabbed SQL editor with syntax highlighting, streaming results
55
with table / JSON / chart views, saved queries, history, and shareable links.
66
It ships as a **single self-contained HTML file served from ClickHouse itself**
7-
(no Node server, no CDN, no runtime dependencies).
7+
(no Node server, no CDN, no external fonts, no runtime dependencies) — the page
8+
makes **zero third-party requests** and renders in the OS's native UI font.
89

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

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

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

91+
### Security headers
92+
93+
`deploy/http_handlers.xml` sends a strict **Content-Security-Policy** plus
94+
`X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` on the SPA
95+
response. The CSP is `default-src 'none'` with everything re-allowed explicitly:
96+
97+
- `script-src`/`style-src 'unsafe-inline'` — the JS and CSS are inlined into the
98+
single HTML file, so they can't be matched by `'self'`. (No `eval`, no remote
99+
scripts; the real protection below is `connect-src`.)
100+
- `connect-src 'self' <issuer-origins>` — the one that matters: it bounds where
101+
the page can send data, so an injected script can't exfiltrate the
102+
`sessionStorage` tokens to an attacker. `'self'` covers ClickHouse queries +
103+
`config.json`; the IdP origins cover OIDC discovery and the token endpoint.
104+
- `img-src data:`, `frame-ancestors 'none'` (anti-clickjacking), `base-uri 'none'`.
105+
106+
`install.sh` fills `connect-src` automatically: it fetches your issuer's OIDC
107+
discovery document and rewrites the host list to your real issuer + token-endpoint
108+
origins (falling back to the Google default if discovery is unreachable). For a
109+
**manual install with a non-Google IdP**, edit the `connect-src` line in
110+
`deploy/http_handlers.xml` to list your issuer + token-endpoint origins.
111+
112+
Preview the rendered artifacts without touching ClickHouse:
113+
114+
```bash
115+
./deploy/install.sh --dry-run --client-id <id> [--issuer https://your-idp]
116+
```
117+
88118
## Layout
89119

90120
```

build/template.html

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1">
66
<title>Altinity SQL Browser</title>
77
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTUwIDE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtMTkgNzIgODItNDhMNzUgOSAxOSA0MnptNSAzIDIzIDE0VjYyem00OSAzNEwxOSA3OHY2MnptMjgtNTIgMjktMTctMjQtMTMtMjggMTd6bTMgN3Y2MGwyOCAxNlY4MHptMi00IDI2IDE1VjQ1eiIgZmlsbD0iIzAwNzlBRCIvPjwvc3ZnPg==">
8-
<link rel="preconnect" href="https://fonts.googleapis.com">
9-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10-
<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">
118
<style>/*__STYLES__*/</style>
129
</head>
1310
<body>

deploy/http_handlers.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@
3030
<content_type>text/html; charset=UTF-8</content_type>
3131
<http_response_headers>
3232
<Cache-Control>no-store</Cache-Control>
33+
<!-- connect-src OIDC origins: install.sh rewrites the https:// hosts from
34+
the issuer (via OIDC discovery). For a manual install with a non-Google
35+
IdP, replace the two https:// hosts below with your issuer + token
36+
endpoint origins. script-src/style-src need 'unsafe-inline' because the
37+
JS + CSS are inlined into this single HTML file; the real protection is
38+
connect-src, which bounds where the sessionStorage tokens can be sent. -->
39+
<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>
40+
<X-Content-Type-Options>nosniff</X-Content-Type-Options>
41+
<Referrer-Policy>no-referrer</Referrer-Policy>
3342
</http_response_headers>
3443
<response_content>file://sql.html</response_content>
3544
</handler>
@@ -43,6 +52,8 @@
4352
<content_type>application/json; charset=UTF-8</content_type>
4453
<http_response_headers>
4554
<Cache-Control>no-store</Cache-Control>
55+
<X-Content-Type-Options>nosniff</X-Content-Type-Options>
56+
<Referrer-Policy>no-referrer</Referrer-Policy>
4657
</http_response_headers>
4758
<response_content>file://sql-config.json</response_content>
4859
</handler>

deploy/install.sh

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
# Install the Altinity SQL Browser onto a ClickHouse cluster:
33
# 1. build the single-file SPA (dist/sql.html)
44
# 2. render config.json from the OAuth args
5-
# 3. upload both into ClickHouse user_files/ (sql.html, sql-config.json)
6-
# 4. print the http_handlers config to enable /sql
5+
# 3. render dist/http_handlers.xml (CSP connect-src filled from OIDC discovery)
6+
# 4. upload the SPA + config into ClickHouse user_files/ (sql.html, sql-config.json)
7+
# 5. print the next step to enable /sql with the rendered http_handlers.xml
78
#
89
# The password is read from the CLICKHOUSE_PASSWORD env var or prompted — never
910
# passed on the command line (it would leak via `ps`/shell history).
@@ -17,13 +18,14 @@
1718
# [--audience <aud>] \ # audience-gated CH → also sends access_token
1819
# [--ch-auth basic] \ # OSS CH + ch-jwt-verify → JWT as Basic password
1920
# [--cluster my_cluster] \ # single-shard multi-replica only
20-
# [--secure]
21+
# [--secure] \
22+
# [--dry-run] # build + render config.json + http_handlers.xml, print, no CH contact
2123
set -euo pipefail
2224

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

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

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

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

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

91+
echo "==> Rendering http_handlers.xml (CSP connect-src from OIDC discovery)"
92+
# The CSP connect-src must allow same-origin ('self', for ClickHouse + config.json)
93+
# plus the IdP origins the browser fetches: OIDC discovery (issuer origin) and the
94+
# token endpoint (exchange + refresh). The OAuth /authorize step is a top-level
95+
# navigation, not a fetch, so it needs no connect-src entry. Resolve the real
96+
# origins from the issuer's discovery document; fail soft to the Google default.
97+
ISSUER_ORIGIN="$(printf '%s' "$ISSUER" | grep -oiE '^https?://[^/]+' || true)"
98+
CONNECT_HOSTS="https://accounts.google.com https://oauth2.googleapis.com"
99+
DISC_URL="${ISSUER%/}/.well-known/openid-configuration"
100+
if DISC_JSON="$(curl -fsS --max-time 10 "$DISC_URL" 2>/dev/null)"; then
101+
# Pull the origin (scheme://host[:port]) of token/authorization endpoints, add
102+
# the issuer origin, dedupe. Tolerates whitespace variations in the JSON.
103+
EP_ORIGINS="$(printf '%s' "$DISC_JSON" \
104+
| grep -oE '"(token_endpoint|authorization_endpoint)"[[:space:]]*:[[:space:]]*"[^"]+"' \
105+
| grep -oiE 'https?://[^/"]+' || true)"
106+
CONNECT_HOSTS="$(printf '%s\n%s\n' "$ISSUER_ORIGIN" "$EP_ORIGINS" \
107+
| sed '/^$/d' | sort -u | paste -sd' ' -)"
108+
echo " connect-src origins: $CONNECT_HOSTS"
109+
else
110+
echo "WARN: could not fetch $DISC_URL — using the Google default connect-src." >&2
111+
echo " If your IdP is not Google, edit connect-src in dist/http_handlers.xml." >&2
112+
fi
113+
# Rewrite only the connect-src host list in the committed template; everything else
114+
# (the rest of the CSP, the other headers) is copied verbatim.
115+
HANDLERS_OUT="$ROOT/dist/http_handlers.xml"
116+
sed -E "s#(connect-src 'self')[^;]*#\1 ${CONNECT_HOSTS}#" \
117+
"$ROOT/deploy/http_handlers.xml" > "$HANDLERS_OUT"
118+
119+
if [[ "$DRY_RUN" == 1 ]]; then
120+
echo
121+
echo "==> DRY RUN — no ClickHouse contact. Rendered artifacts:"
122+
echo "--- config.json ---"
123+
cat "$CONFIG_FILE"
124+
echo "--- dist/http_handlers.xml ---"
125+
cat "$HANDLERS_OUT"
126+
exit 0
127+
fi
128+
87129
# Upload raw bytes via FORMAT RawBLOB on stdin — no base64, no command-line
88130
# length limit, written as the clickhouse process so perms are correct.
89131
upload() { # upload <local-file> <user_files-filename>
@@ -113,9 +155,10 @@ cat <<EOF
113155
114156
==> Assets uploaded to ClickHouse user_files/.
115157
116-
Final step — enable the HTTP routes. Add deploy/http_handlers.xml to the
117-
server config.d/ (or push it as an ACM cluster setting named
118-
"config.d/sql-browser.xml") and reload ClickHouse. Then open:
158+
Final step — enable the HTTP routes. Add the rendered dist/http_handlers.xml
159+
(its CSP connect-src is filled in for your issuer) to the server config.d/ (or
160+
push it as an ACM cluster setting named "config.d/sql-browser.xml") and reload
161+
ClickHouse. Then open:
119162
120163
http${SECURE:+s}://$CH_HOST/sql
121164

docs/DEPLOYMENT.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,19 @@ env var or prompted — never placed on the command line.
2929
3030
## 3. HTTP routes
3131

32-
Add `deploy/http_handlers.xml` to ClickHouse `config.d/` (or push it through
32+
Add the http_handlers fragment to ClickHouse `config.d/` (or push it through
3333
your control plane as `config.d/sql-browser.xml`) and reload. It adds static
3434
rules for `/sql` and `/sql/config.json` and keeps `<defaults/>` so the dynamic
35-
query handler at `/` still works.
35+
query handler at `/` still works. The SPA rule also sends a strict
36+
Content-Security-Policy (`default-src 'none'`, `frame-ancestors 'none'`, and a
37+
`connect-src` scoped to same-origin + your IdP) plus `nosniff` and
38+
`Referrer-Policy: no-referrer` — see README "Security headers".
39+
40+
`deploy/http_handlers.xml` is the committed default (Google `connect-src`).
41+
`install.sh` renders `dist/http_handlers.xml` with `connect-src` filled in for
42+
your `--issuer`; deploy that rendered file. For a manual install with a
43+
non-Google IdP, edit the `connect-src` line to your issuer + token-endpoint
44+
origins.
3645

3746
## 4. Make ClickHouse accept the JWT
3847

0 commit comments

Comments
 (0)