Skip to content

harden(deploy): add CSP + security headers, drop external Google Fonts#1

Merged
BorisTyshkevich merged 6 commits into
mainfrom
harden/csp-and-self-contained-fonts
Jun 19, 2026
Merged

harden(deploy): add CSP + security headers, drop external Google Fonts#1
BorisTyshkevich merged 6 commits into
mainfrom
harden/csp-and-self-contained-fonts

Conversation

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator

Two defense-in-depth fixes from the security review. No src/ logic changes — the 100% coverage gate is untouched (319 tests pass).

1. Truly self-contained — drop external Google Fonts

build/template.html no longer pulls Inter / JetBrains Mono from fonts.googleapis.com/gstatic.com. The --ui/--mono stacks in styles.css already fall back to system fonts, so the UI is unchanged in spirit and the served page now makes zero third-party requests (privacy, air-gap, and the "self-contained single file" claim all become literally true). The favicon stays an inline data: URI.

2. Strict CSP + hardening headers

The /sql response (deploy/http_handlers.xml) now sends:

  • Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'self'; connect-src 'self' <issuer-origins>; base-uri 'none'; frame-ancestors 'none'
    • 'unsafe-inline' is required because the JS+CSS are inlined into the single HTML file; the real protection is connect-src, which bounds where the sessionStorage id_token/refresh_token can be sent if an XSS ever lands.
  • X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer (on both the SPA and config responses).

connect-src is templated, not guessed

http_handlers.xml is normally a static drop-in, but the OIDC origins are deployment-specific. So install.sh now:

  • fetches the issuer's OIDC discovery doc, extracts the token + authorization endpoint origins (+ issuer origin), dedupes, and rewrites connect-src into a rendered dist/http_handlers.xml;
  • fails soft to the committed Google default (with a warning) if discovery is unreachable, so the committed file stays valid for manual installs;
  • gains a --dry-run flag that renders config.json + http_handlers.xml and prints them with no ClickHouse contact.

Non-Google manual installs: edit one connect-src line (documented in README + deploy/http_handlers.xml comment).

Verification

  • npm test → 319 pass; npm run buildgrep googleapis|gstatic dist/sql.html = 0 matches.
  • xmllint clean on committed + rendered XML.
  • Discovery parse + sed rewrite verified against Google- and Auth0-shaped discovery docs (Google → 2 origins, Auth0 → 1; 'self' and trailing ; preserved).
  • Fail-soft path exercised (offline issuer → Google default + warning).

🤖 Generated with Claude Code

https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef

Isolator acm and others added 6 commits June 19, 2026 17:41
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
…pkce transients

Security-review follow-ups (#4/#6/#7):

- sqlString (core/format.js): escape `\` -> `\\` before doubling `'`. CH honors
  backslash escapes in string literals, so a value ending in `\` escaped the
  closing quote and could break out (second-order via loadColumns' system.tables
  names). Now closed.
- bootstrap (main.js): handle an IdP error redirect (?error=access_denied&…)
  instead of dropping silently to the login screen — surfaces
  error_description||error, and the URL cleanup now strips the error params too.
- setTokens (ui/app.js): clear the one-shot oauth_verifier + oauth_state from
  sessionStorage once tokens are held (refresh path is a harmless no-op).

Tests added in the same change; 323 pass, per-file coverage gate holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef
The editor overlays a <textarea> on a highlight <pre>. With a fractional line
box (line-height 1.7 * 13px = 22.1px) the textarea's internal text layout and
the block <pre> rounded lines differently, so the native selection drifted
upward from the painted glyphs, growing with line number (visible by ~line 37).

Use an integer px line-height (22px) on .sql-editor so both lay out lines
identically before `zoom: 1.2` scales them together, and match .sql-gutter > div
height so the line numbers stay row-aligned. Verified in-browser: selection on a
deep line now brackets the glyphs exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef
In TSV/JSON output mode the result is a plain <div>, so ⌘A fell through to the
browser's whole-page select-all. Make the raw (.raw-text-view) and JSON
(.json-view) panes focusable (tabindex=0) and, when ⌘/Ctrl+A fires with focus
inside one, select that node's contents via Selection.selectAllChildren so it
can be copied. Focus elsewhere (e.g. the editor textarea) still falls through to
the native select-all, so ⌘A in the SQL area keeps selecting the whole query.

Verified in-browser: ⌘A in a focused TSV pane selects exactly the TSV text; ⌘A
in the editor is not intercepted. 325 tests pass; shortcuts/results at 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef
Each data-column header gets a right-edge resize handle. Dragging it freezes
every column at its current width, switches the table to table-layout:fixed, and
tracks the cursor to set that column's width (content ellipsizes when narrowed).
Widths are stored on the per-query result object so they survive the frequent
re-renders (sort, streaming chunks, view switches) and reset on a new query.

Details:
- zoom-safe: the client-px delta is divided by a per-element scale
  (getBoundingClientRect().width / offsetWidth) so the edge tracks the cursor
  under the global `zoom: 1.2` (verified in-browser: 70 CSS px → 84 device px).
- the handle swallows its own click so resizing/clicking it never triggers the
  column sort; the row-number column is not resizable.

Verified in-browser: dragging narrows a column with ellipsis clipping while
others keep their widths. 329 tests pass; results.js at 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef
History had no in-app way to clear it (only the saved panel had per-item
delete). Add a "Clear history" button in the History panel header (wires the
existing clearHistory) and a per-row delete × (new deleteHistory state op,
mirroring deleteSaved). Both persist via localStorage like the rest of the
panel. The row-level delete stops propagation so it doesn't also load the query.

Verified in-browser: Clear history empties asb:history + the list; per-row
delete removes one entry. 332 tests pass; state.js + saved-history.js at 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef
@BorisTyshkevich BorisTyshkevich merged commit cd27dca into main Jun 19, 2026
2 checks passed
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