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
@BorisTyshkevich BorisTyshkevich deleted the harden/csp-and-self-contained-fonts branch June 22, 2026 16:43
BorisTyshkevich added a commit that referenced this pull request Jun 23, 2026
…t, qualified fallback, doc-cache, single tokenize (#26, #27)

Five findings from a follow-up manual review; verified live on otel.

Correctness:
- Hover docs now resolve the hovered word from the string/comment-masked text
  (host.maskedValue()), so hovering a function/keyword inside a string or comment
  no longer pops a phantom doc card — consistent with signature help (#1).
- Signature help strips the optional-param brackets ClickHouse uses
  (`name(a, b[, c])`) before splitting on commas, so args render cleanly and the
  active-arg highlight aligns (was showing `offset[` / `length]` for substring) (#2).
- completionContext only flags `qualified` when a real identifier precedes the
  dot; a bare dot (`.col`, `count().c`) now falls back to normal completion
  instead of an empty dropdown (#4).
- loadEntityDoc returns null on a query FAILURE vs '' for genuinely-no-doc, and
  entityDoc caches the latter but drops the former — a transient error no longer
  permanently suppresses a function's hover/footer doc for the session (#8).

Performance:
- The keystroke path tokenizes the buffer ONCE and shares the token list between
  the syntax highlighter and the literal mask, instead of two full passes per
  keystroke. maskFromTokens()/renderTokensInto() consume a token list; the editor
  memoizes it by (text, refData) so it re-tokenizes when server keyword/func sets
  arrive after connect (#5).

Tests cover each: hover-in-literal, bracketed-param signature split, bare-dot
fallback, failed-doc retry, and re-highlight-on-refData-change (the shared-token
cache invalidation). All gated layers stay 100/100/100/100 (ui glue within its
functions>=95 / branches>=90 floor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu
BorisTyshkevich added a commit that referenced this pull request Jun 25, 2026
- run(): a typed EXPLAIN now honors exactly what was typed — a plain EXPLAIN
  always opens the verbatim Explain view instead of inheriting a stale rich view
  from a previous run/tab. Drop the unused app.state.explainView. (review #1)
- dot.parseDot: blank quoted label strings before edge scanning and only keep
  edges between declared processors, so a `->` inside a label (e.g. a lambda
  `x -> x + 1`) no longer fabricates phantom nodes/edges. (review #2)
- panzoom.zoomBox: guard a degenerate zero-size viewBox (defensive).
- overlay: Esc stops propagation so it doesn't also reach the app key handler.
- remove now-dead isExplain from core/format.js (superseded by parseExplain).
- document the ontime DOT fixture's capture provenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu
BorisTyshkevich added a commit that referenced this pull request Jun 25, 2026
…line/Estimate) + Explain button (#37)

* feat(explain): EXPLAIN result views (Explain/Indexes/Projections/Pipeline/Estimate) + Explain button

Replace the one-dimensional raw EXPLAIN dump with five views in the data pane:
- Explain (default) runs the user's EXPLAIN verbatim as clean TabSeparatedRaw,
  so arbitrary/complex parameters are honored.
- Indexes / Projections derive `indexes=1` / `projections=1` from the inner query.
- Pipeline derives `EXPLAIN PIPELINE graph=1` and renders the Graphviz DOT as a
  zero-dep SVG layered graph (pure parse+layout in src/core/dot.js).
- Estimate derives `EXPLAIN ESTIMATE` and renders structured rows as a table.

On Run we auto-select a rich view only when the typed statement is exactly that
canonical form; anything else stays on the verbatim Explain tab. Switching a view
re-runs the derived query and never edits the editor SQL.

New "Explain" toolbar button (between Format and Save) explains any query without
editing it. No new runtime dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu

* docs(explain): README EXPLAIN-views section; neutral empty-Estimate message

Reword the empty-Estimate placeholder (a trivial count() is answered from
metadata, so ESTIMATE returns no part rows even on a MergeTree table).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu

* feat(explain): vertical pipeline graph, drop EXPLAIN header stats, e2e graph test

- Pipeline graph now lays out top→bottom: sequential stages stack vertically,
  parallel processors in a stage sit side-by-side horizontally. Edges flow
  bottom→top-centre; self-loops are filtered.
- EXPLAIN views drop the ms/rows/bytes header stats (not meaningful for a plan)
  to give the five tabs room.
- Add a Playwright e2e test that renders a real `EXPLAIN PIPELINE graph = 1`
  capture from the antalya ontime dataset (fact/dim join + aggregated subquery
  join, 37 processors / 40 edges) and asserts the vertical-with-parallel-lanes
  SVG. Fixture: tests/e2e/fixtures/ontime-pipeline-graph.dot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu

* feat(explain): fullscreen pan/zoom overlay for the pipeline graph

Add an Expand button (results header, Pipeline view) that opens the graph in a
fullscreen overlay with wheel-zoom around the cursor, drag-pan, +/- and
Fit-to-screen controls, and Esc/✕/backdrop close. Makes complex pipelines (which
the current layout draws wide) navigable; better automatic layout is deferred to
a follow-up PR.

- src/core/panzoom.js: pure viewBox algebra (fit/zoom/pan), 100% covered.
- src/ui/explain-graph.js: extract buildPipelineSvg; openPipelineFullscreen.
- src/ui/results.js: Expand button; icons.js: expand + minus glyphs.
- e2e: pipeline.html exposes __openFullscreen; spec drives real wheel/drag/Esc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu

* fix(explain): address code-review findings

- run(): a typed EXPLAIN now honors exactly what was typed — a plain EXPLAIN
  always opens the verbatim Explain view instead of inheriting a stale rich view
  from a previous run/tab. Drop the unused app.state.explainView. (review #1)
- dot.parseDot: blank quoted label strings before edge scanning and only keep
  edges between declared processors, so a `->` inside a label (e.g. a lambda
  `x -> x + 1`) no longer fabricates phantom nodes/edges. (review #2)
- panzoom.zoomBox: guard a degenerate zero-size viewBox (defensive).
- overlay: Esc stops propagation so it doesn't also reach the app key handler.
- remove now-dead isExplain from core/format.js (superseded by parseExplain).
- document the ontime DOT fixture's capture provenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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