From da6bfb9f67c870392264b48ff2c93556dd403d5e Mon Sep 17 00:00:00 2001 From: Polliog Date: Fri, 19 Jun 2026 15:02:13 +0200 Subject: [PATCH 01/11] bump nodemailer and undici for dependabot advisories --- CHANGELOG.md | 3 +++ package.json | 1 + packages/backend/package.json | 2 +- pnpm-lock.yaml | 19 ++++++++++--------- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 167f92f1..85e05eba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ A security and correctness release from a comprehensive, multi-engine bug audit - **Session invalidation**: disabling a user, resetting their password, and changing a user's admin role now invalidate the cached session (previously the cached profile stayed valid for up to the cache TTL); the frontend logout now revokes the server-side session token instead of only clearing local state - **Hardening**: emails are stored and compared case-insensitively across registration/login/profile-update; the global `CACHE_TTL` override no longer clamps semantic TTLs (sessions, OIDC state, settings); the webhook envelope id is validated as a UUID; SigmaHQ category sync matches on a directory boundary; OTLP trace/span ids of invalid length are rejected +### Security +- **Dependency security updates, third wave** (Dependabot): two further advisories resolved to their patched releases. The direct dependency `nodemailer` is bumped from `^8.0.9` to `^9.0.1` (GHSA-p6gq-j5cr-w38f, HIGH): the message-level `raw` option bypassed `disableFileAccess`/`disableUrlAccess`, enabling arbitrary file read and full-response SSRF in the delivered message; our SMTP senders only use standard `createTransport`/`sendMail` fields and never pass `raw`, but the dependency is patched regardless. The transitive `undici` (pulled in only by `jsdom` in the frontend test toolchain) is pinned via the root pnpm `overrides` to `>=7.28.0 <8` (GHSA-vmh5-mc38-953g HIGH, TLS certificate validation bypass via dropped `requestTls` in the SOCKS5 `ProxyAgent`; GHSA-pr7r-676h-xcf6 MEDIUM, cross-user information disclosure via shared-cache whitespace bypass), resolving to `7.28.0` and staying on the 7.x line `jsdom@29` expects. No vulnerable version remains in `pnpm-lock.yaml` + ### Fixed - **ClickHouse "Query with id = ... is already running" under concurrent queries** (#213 regression): the request-context propagation derived the ClickHouse `query_id` deterministically from `requestId + operation`, so two same-operation queries running concurrently within one request reused the same id and ClickHouse rejected the second. The `query_id` now keeps the request id + operation as a readable prefix (correlation is also carried in the SQL `log_comment`) and appends a random suffix so every query is unique. ClickHouse-only - **Sigma condition operator precedence**: `parseExpression` folded AND/OR strictly left-to-right, so `a or b and c` evaluated as `(a or b) and c`. AND now binds tighter than OR, matching the Sigma spec diff --git a/package.json b/package.json index c39a145a..e77fb4d9 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "kysely": ">=0.28.17 <0.29", "ws": ">=8.20.1", "uuid": ">=14.0.0", + "undici": ">=7.28.0 <8", "@logtide/types": "0.7.0", "@logtide/browser": "0.7.0", "@logtide/core": "0.7.0" diff --git a/packages/backend/package.json b/packages/backend/package.json index 785e81e5..6b193d34 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -63,7 +63,7 @@ "kysely": "^0.28.17", "ldapts": "^7.2.1", "maxmind": "^5.0.1", - "nodemailer": "^8.0.9", + "nodemailer": "^9.0.1", "openid-client": "^6.4.1", "pg": "^8.16.3", "protobufjs": "^7.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71009251..fd835f8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,7 @@ overrides: kysely: '>=0.28.17 <0.29' ws: '>=8.20.1' uuid: '>=14.0.0' + undici: '>=7.28.0 <8' '@logtide/types': 0.7.0 '@logtide/browser': 0.7.0 '@logtide/core': 0.7.0 @@ -112,8 +113,8 @@ importers: specifier: ^5.0.1 version: 5.0.1 nodemailer: - specifier: ^8.0.9 - version: 8.0.11 + specifier: ^9.0.1 + version: 9.0.1 openid-client: specifier: ^6.4.1 version: 6.8.1 @@ -2548,8 +2549,8 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nodemailer@8.0.11: - resolution: {integrity: sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==} + nodemailer@9.0.1: + resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} engines: {node: '>=6.0.0'} normalize-path@3.0.0: @@ -3216,8 +3217,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@7.27.2: - resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} unist-util-is@6.0.1: @@ -5738,7 +5739,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.27.2 + undici: 7.28.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -5947,7 +5948,7 @@ snapshots: node-releases@2.0.27: {} - nodemailer@8.0.11: {} + nodemailer@9.0.1: {} normalize-path@3.0.0: {} @@ -6642,7 +6643,7 @@ snapshots: undici-types@6.21.0: {} - undici@7.27.2: {} + undici@7.28.0: {} unist-util-is@6.0.1: dependencies: From 333fad746e49242329c0825d0e243c8f5966254a Mon Sep 17 00:00:00 2001 From: Polliog Date: Fri, 19 Jun 2026 20:02:00 +0200 Subject: [PATCH 02/11] make clickhouse materialize backfills one-time --- .../clickhouse/clickhouse-engine.test.ts | 27 +++++ .../engines/clickhouse/clickhouse-engine.ts | 111 +++++++++++++----- 2 files changed, 111 insertions(+), 27 deletions(-) diff --git a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.test.ts b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.test.ts index 163d81b9..f21b5f6c 100644 --- a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.test.ts +++ b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.test.ts @@ -90,6 +90,33 @@ describe('ClickHouseEngine (integration)', () => { }); }); + describe('initialize idempotency', () => { + // Regression: MATERIALIZE is not idempotent. initialize() runs on every process + // boot, so re-issuing ALTER TABLE ... MATERIALIZE each time queued a fresh + // full-table-rewrite mutation per boot and could pin ClickHouse at 100% CPU. + // Repeated initialize() must not queue any new MATERIALIZE mutation. + const countMaterializeMutations = async (table: string) => { + const resultSet = await client.query({ + query: `SELECT count() AS c FROM system.mutations WHERE database = {db:String} AND table = {tbl:String} AND command LIKE '%MATERIALIZE%'`, + query_params: { db: TEST_CONFIG.database, tbl: table }, + format: 'JSONEachRow', + }); + const rows = (await resultSet.json()) as { c: string }[]; + return Number(rows[0]?.c ?? 0); + }; + + it('does not queue new MATERIALIZE mutations on repeated initialize()', async () => { + const logsBefore = await countMaterializeMutations(TABLE_NAME); + const spansBefore = await countMaterializeMutations('spans'); + + await engine.initialize(); + await engine.initialize(); + + expect(await countMaterializeMutations(TABLE_NAME)).toBe(logsBefore); + expect(await countMaterializeMutations('spans')).toBe(spansBefore); + }); + }); + describe('ingest', () => { it('ingests a batch of logs', async () => { const logs = [makeLog(), makeLog({ service: 'worker', level: 'error' })]; diff --git a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts index 9e481e82..25fdd907 100644 --- a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts +++ b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts @@ -223,13 +223,18 @@ export class ClickHouseEngine extends StorageEngine { // Bloom filter on id - lets getByIds skip data granules that don't contain // any of the requested UUIDs without a full project scan. + // MATERIALIZE rewrites every existing part, so only run it the first time the + // index is created. Re-running it on each boot queues a full-table mutation + // that can pin ClickHouse at 100% CPU on large tables. try { - await this.runCommand({ - query: `ALTER TABLE ${t} ADD INDEX IF NOT EXISTS idx_id id TYPE bloom_filter(0.01) GRANULARITY 1`, - }); - await this.runCommand({ - query: `ALTER TABLE ${t} MATERIALIZE INDEX idx_id`, - }); + if (!(await this.chObjectExists('index', t, 'idx_id'))) { + await this.runCommand({ + query: `ALTER TABLE ${t} ADD INDEX IF NOT EXISTS idx_id id TYPE bloom_filter(0.01) GRANULARITY 1`, + }); + await this.runCommand({ + query: `ALTER TABLE ${t} MATERIALIZE INDEX idx_id`, + }); + } } catch { // index may already exist } @@ -255,28 +260,36 @@ export class ClickHouseEngine extends StorageEngine { // column/index may already exist } - // Projection for fast service+level filtered queries + // Projection for fast service+level filtered queries. + // Gate the MATERIALIZE so the one-time backfill of existing parts runs only at + // creation, not on every boot (see idx_id note above). try { - await this.runCommand({ - query: `ALTER TABLE ${t} ADD PROJECTION IF NOT EXISTS proj_service_time (SELECT * ORDER BY project_id, service, level, time)`, - }); - await this.runCommand({ - query: `ALTER TABLE ${t} MATERIALIZE PROJECTION proj_service_time`, - }); + if (!(await this.chObjectExists('projection', t, 'proj_service_time'))) { + await this.runCommand({ + query: `ALTER TABLE ${t} ADD PROJECTION IF NOT EXISTS proj_service_time (SELECT * ORDER BY project_id, service, level, time)`, + }); + await this.runCommand({ + query: `ALTER TABLE ${t} MATERIALIZE PROJECTION proj_service_time`, + }); + } } catch { // projection may already exist } // Materialized column for hostname - extracted from metadata JSON once at ingest time. // Eliminates JSONExtractString() calls on every DISTINCT/filter query row. - // MATERIALIZE backfills existing data parts asynchronously during merges. + // Gate the explicit MATERIALIZE so the one-time backfill of existing parts runs + // only at creation; re-issuing it every boot queues a full-table rewrite mutation. + // New parts get the column at ingest regardless. try { - await this.runCommand({ - query: `ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS hostname String MATERIALIZED JSONExtractString(metadata, 'hostname')`, - }); - await this.runCommand({ - query: `ALTER TABLE ${t} MATERIALIZE COLUMN hostname`, - }); + if (!(await this.chObjectExists('column', t, 'hostname'))) { + await this.runCommand({ + query: `ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS hostname String MATERIALIZED JSONExtractString(metadata, 'hostname')`, + }); + await this.runCommand({ + query: `ALTER TABLE ${t} MATERIALIZE COLUMN hostname`, + }); + } } catch { // column may already exist } @@ -323,14 +336,17 @@ export class ClickHouseEngine extends StorageEngine { }); } catch { /* index may already exist */ } - // Projection for fast service_name filtered span queries + // Projection for fast service_name filtered span queries. + // One-time MATERIALIZE only (see idx_id note above). try { - await this.runCommand({ - query: `ALTER TABLE spans ADD PROJECTION IF NOT EXISTS proj_service_time (SELECT * ORDER BY project_id, service_name, status_code, time)`, - }); - await this.runCommand({ - query: `ALTER TABLE spans MATERIALIZE PROJECTION proj_service_time`, - }); + if (!(await this.chObjectExists('projection', 'spans', 'proj_service_time'))) { + await this.runCommand({ + query: `ALTER TABLE spans ADD PROJECTION IF NOT EXISTS proj_service_time (SELECT * ORDER BY project_id, service_name, status_code, time)`, + }); + await this.runCommand({ + query: `ALTER TABLE spans MATERIALIZE PROJECTION proj_service_time`, + }); + } } catch { /* projection may already exist */ } // Traces table (ReplacingMergeTree for upsert semantics) @@ -745,6 +761,47 @@ export class ClickHouseEngine extends StorageEngine { return this.client; } + /** + * Returns true when a data-skipping index, projection, or materialized column + * already exists on the given table. + * + * Gates the one-time MATERIALIZE backfills in initialize(): MATERIALIZE is NOT + * idempotent, so re-issuing it on every initialize() (which runs on each process + * boot) queues a fresh full-table-rewrite mutation each time and can pin + * ClickHouse at 100% CPU on large tables. We only want to MATERIALIZE the first + * time an object is created. + * + * On any introspection error we return true (assume it exists) so we never + * trigger a surprise full-table MATERIALIZE storm; worst case the optimization + * is not backfilled on pre-existing parts. + */ + private async chObjectExists( + kind: 'index' | 'projection' | 'column', + table: string, + name: string, + ): Promise { + const source = + kind === 'index' + ? 'system.data_skipping_indices' + : kind === 'projection' + ? 'system.projections' + : 'system.columns'; + try { + const resultSet = await this.runQuery( + { + query: `SELECT count() AS c FROM ${source} WHERE database = {db:String} AND table = {tbl:String} AND name = {name:String}`, + query_params: { db: this.config.database, tbl: table, name }, + format: 'JSONEachRow', + }, + 'schema-introspect', + ); + const rows = (await resultSet.json()) as { c: string | number }[]; + return rows.length > 0 && Number(rows[0].c) > 0; + } catch { + return true; + } + } + private async runCommand(args: Parameters[0], op = 'cmd') { const client = this.getClient(); const final = { ...args, query: chCtxComment() + args.query, query_id: (args as any).query_id ?? chQueryId(op) }; From c0e8faa30783ae154dc5b23079255ed87905388d Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 20 Jun 2026 21:34:03 +0200 Subject: [PATCH 03/11] gitignore frontend bug-hunt reports --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 10eb9c2e..f0408e28 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ claude.md /test-vector/ /.env.prod /docs/superpowers/ +/FRONTEND-BUGS.md +/BUGS.md From 9fb2a9a1ce34009fa5e1dfab0f65eedb1938031f Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 20 Jun 2026 21:34:03 +0200 Subject: [PATCH 04/11] fix frontend api/store error handling, races and leaks --- packages/frontend/src/lib/api/admin.ts | 10 +++--- packages/frontend/src/lib/api/auth.ts | 36 ++++++++++++++----- packages/frontend/src/lib/api/exceptions.ts | 32 ++++++++--------- packages/frontend/src/lib/stores/auth.ts | 33 ++++++++++++++--- .../src/lib/stores/custom-dashboards.ts | 15 ++++++++ .../frontend/src/lib/stores/monitoring.ts | 9 +++++ .../src/lib/stores/notification-channels.ts | 5 +-- packages/frontend/src/lib/utils/siem.ts | 4 +-- 8 files changed, 105 insertions(+), 39 deletions(-) diff --git a/packages/frontend/src/lib/api/admin.ts b/packages/frontend/src/lib/api/admin.ts index b341681a..75880776 100644 --- a/packages/frontend/src/lib/api/admin.ts +++ b/packages/frontend/src/lib/api/admin.ts @@ -439,7 +439,7 @@ class AdminAPI { } if (!response.ok) { - const error = await response.json(); + const error = await response.json().catch(() => ({})); throw new Error(error.message || 'API request failed'); } @@ -594,7 +594,7 @@ class AdminAPI { }); if (!response.ok) { - const error = await response.json(); + const error = await response.json().catch(() => ({})); throw new Error(error.error || 'Failed to update user role'); } @@ -691,7 +691,7 @@ class AdminAPI { }); if (!response.ok) { - const error = await response.json(); + const error = await response.json().catch(() => ({})); throw new Error(error.error || 'Failed to update retention policy'); } @@ -720,7 +720,7 @@ class AdminAPI { }); if (!response.ok) { - const error = await response.json(); + const error = await response.json().catch(() => ({})); throw new Error(error.error || 'Failed to update entitlements'); } @@ -795,7 +795,7 @@ class AdminAPI { }); if (!response.ok) { - const error = await response.json(); + const error = await response.json().catch(() => ({})); throw new Error(error.error || 'Failed to update settings'); } diff --git a/packages/frontend/src/lib/api/auth.ts b/packages/frontend/src/lib/api/auth.ts index bdb075fa..3a016b41 100644 --- a/packages/frontend/src/lib/api/auth.ts +++ b/packages/frontend/src/lib/api/auth.ts @@ -63,7 +63,9 @@ export class AuthAPI { }); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Registration failed'); } @@ -80,7 +82,9 @@ export class AuthAPI { }); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Login failed'); } @@ -98,7 +102,9 @@ export class AuthAPI { }); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Logout failed'); } } @@ -114,7 +120,9 @@ export class AuthAPI { }); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to get user info'); } @@ -145,7 +153,9 @@ export class AuthAPI { const response = await fetch(`${getApiBaseUrl()}/auth/providers`); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to get auth providers'); } @@ -166,7 +176,9 @@ export class AuthAPI { ); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to get authorization URL'); } @@ -187,7 +199,9 @@ export class AuthAPI { }); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Login failed'); } @@ -202,7 +216,9 @@ export class AuthAPI { }); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to get identities'); } @@ -218,7 +234,9 @@ export class AuthAPI { }); if (!response.ok) { - const error: ErrorResponse = await response.json(); + const error: ErrorResponse = await response + .json() + .catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to unlink identity'); } } diff --git a/packages/frontend/src/lib/api/exceptions.ts b/packages/frontend/src/lib/api/exceptions.ts index 9f605d15..b1065380 100644 --- a/packages/frontend/src/lib/api/exceptions.ts +++ b/packages/frontend/src/lib/api/exceptions.ts @@ -50,8 +50,8 @@ export async function getExceptionByLogId( } if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get exception'); + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to get exception (HTTP ${response.status})`); } return response.json(); @@ -75,8 +75,8 @@ export async function getExceptionById( } if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get exception'); + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to get exception (HTTP ${response.status})`); } return response.json(); @@ -116,8 +116,8 @@ export async function getErrorGroups( }); if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get error groups'); + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to get error groups (HTTP ${response.status})`); } return response.json(); @@ -147,8 +147,8 @@ export async function getTopErrorGroups(params: { }); if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get top error groups'); + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to get top error groups (HTTP ${response.status})`); } return response.json(); @@ -172,8 +172,8 @@ export async function getErrorGroupById( } if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get error group'); + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to get error group (HTTP ${response.status})`); } return response.json(); @@ -196,8 +196,8 @@ export async function updateErrorGroupStatus( }); if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to update error group status'); + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to update error group status (HTTP ${response.status})`); } return response.json(); @@ -231,8 +231,8 @@ export async function getErrorGroupTrend(params: { ); if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get error group trend'); + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to get error group trend (HTTP ${response.status})`); } return response.json(); @@ -266,8 +266,8 @@ export async function getErrorGroupLogs(params: { ); if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get error group logs'); + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to get error group logs (HTTP ${response.status})`); } return response.json(); diff --git a/packages/frontend/src/lib/stores/auth.ts b/packages/frontend/src/lib/stores/auth.ts index 88fa168c..e4772aef 100644 --- a/packages/frontend/src/lib/stores/auth.ts +++ b/packages/frontend/src/lib/stores/auth.ts @@ -19,17 +19,40 @@ export interface AuthState { const STORAGE_KEY = 'logtide_auth'; +function isValidStoredAuth(data: unknown): data is { user: User; token: string } { + if (typeof data !== 'object' || data === null) { + return false; + } + const candidate = data as { user?: unknown; token?: unknown }; + if (typeof candidate.token !== 'string') { + return false; + } + const user = candidate.user; + if (typeof user !== 'object' || user === null) { + return false; + } + const u = user as { id?: unknown; email?: unknown; name?: unknown }; + return ( + typeof u.id === 'string' && + typeof u.email === 'string' && + typeof u.name === 'string' + ); +} + function loadInitialState(): AuthState { if (browser) { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const data = JSON.parse(stored); - return { - user: data.user, - token: data.token, - loading: false, - }; + if (isValidStoredAuth(data)) { + return { + user: data.user, + token: data.token, + loading: false, + }; + } + localStorage.removeItem(STORAGE_KEY); } } catch (e) { console.error('Failed to load auth state:', e); diff --git a/packages/frontend/src/lib/stores/custom-dashboards.ts b/packages/frontend/src/lib/stores/custom-dashboards.ts index 1c0ee28d..1d8cf0c6 100644 --- a/packages/frontend/src/lib/stores/custom-dashboards.ts +++ b/packages/frontend/src/lib/stores/custom-dashboards.ts @@ -64,6 +64,10 @@ const initialState: DashboardStoreState = { function createDashboardStore() { const { subscribe, set, update } = writable(initialState); + // Monotonic guard so a stale in-flight fetch cannot write into a + // dashboard that has since been switched away from. + let panelFetchSeq = 0; + function getState(): DashboardStoreState { return get({ subscribe }); } @@ -175,6 +179,9 @@ function createDashboardStore() { const dashboard = state.activeDashboard; if (!dashboard || dashboard.panels.length === 0) return; + const fetchSeq = ++panelFetchSeq; + const fetchedDashboardId = dashboard.id; + // Mark all panels as loading update((s) => { const next: Record = { ...s.panelData }; @@ -196,6 +203,11 @@ function createDashboardStore() { ); const now = Date.now(); update((s) => { + // Ignore the response if a newer fetch started or the active + // dashboard changed while this request was in flight. + if (fetchSeq !== panelFetchSeq || s.activeDashboard?.id !== fetchedDashboardId) { + return s; + } const next: Record = { ...s.panelData }; for (const [panelId, entry] of Object.entries(result.panels)) { next[panelId] = { @@ -210,6 +222,9 @@ function createDashboardStore() { } catch (e) { const message = e instanceof Error ? e.message : 'Failed to load panel data'; update((s) => { + if (fetchSeq !== panelFetchSeq || s.activeDashboard?.id !== fetchedDashboardId) { + return s; + } const next: Record = { ...s.panelData }; for (const p of dashboard.panels) { next[p.id] = { diff --git a/packages/frontend/src/lib/stores/monitoring.ts b/packages/frontend/src/lib/stores/monitoring.ts index e1f4f6ff..7da77195 100644 --- a/packages/frontend/src/lib/stores/monitoring.ts +++ b/packages/frontend/src/lib/stores/monitoring.ts @@ -39,15 +39,21 @@ const initialState: MonitoringState = { function createMonitoringStore() { const { subscribe, set, update } = writable(initialState); + let loadSeq = 0; + let detailSeq = 0; + return { subscribe, async load(organizationId: string, projectId?: string): Promise { + const seq = ++loadSeq; update((s) => ({ ...s, loading: true, error: null })); try { const { monitors } = await listMonitors(organizationId, projectId); + if (seq !== loadSeq) return; update((s) => ({ ...s, monitors, loading: false })); } catch (err) { + if (seq !== loadSeq) return; update((s) => ({ ...s, loading: false, @@ -57,6 +63,7 @@ function createMonitoringStore() { }, async loadDetail(id: string, organizationId: string): Promise { + const seq = ++detailSeq; update((s) => ({ ...s, detailLoading: true, detailError: null })); try { const [monitorRes, resultsRes, uptimeRes] = await Promise.all([ @@ -64,6 +71,7 @@ function createMonitoringStore() { getMonitorResults(id, organizationId, 100), getMonitorUptime(id, organizationId, 90), ]); + if (seq !== detailSeq) return; update((s) => ({ ...s, selectedMonitor: monitorRes.monitor, @@ -72,6 +80,7 @@ function createMonitoringStore() { detailLoading: false, })); } catch (err) { + if (seq !== detailSeq) return; update((s) => ({ ...s, detailLoading: false, diff --git a/packages/frontend/src/lib/stores/notification-channels.ts b/packages/frontend/src/lib/stores/notification-channels.ts index 80c2837c..2cd91cca 100644 --- a/packages/frontend/src/lib/stores/notification-channels.ts +++ b/packages/frontend/src/lib/stores/notification-channels.ts @@ -109,9 +109,10 @@ function createNotificationChannelsStore() { try { const defaults = await notificationChannelsAPI.getDefaults(organizationId); - update((s) => ({ ...s, defaults, defaultsLoading: false })); + update((s) => ({ ...s, defaults, defaultsLoading: false, error: null })); } catch (error) { - update((s) => ({ ...s, defaultsLoading: false })); + const errorMessage = error instanceof Error ? error.message : 'Failed to load defaults'; + update((s) => ({ ...s, defaultsLoading: false, error: errorMessage })); } }, diff --git a/packages/frontend/src/lib/utils/siem.ts b/packages/frontend/src/lib/utils/siem.ts index 0ffd8f95..f6a2127f 100644 --- a/packages/frontend/src/lib/utils/siem.ts +++ b/packages/frontend/src/lib/utils/siem.ts @@ -15,7 +15,7 @@ export function getStatusLabel(status: string): string { export function formatDate(dateStr: string): string { const date = new Date(dateStr); - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', @@ -26,7 +26,7 @@ export function formatDate(dateStr: string): string { export function formatShortDate(dateStr: string): string { const date = new Date(dateStr); - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', From a2c6d092a4c52c722bc7867e3f1111cc769b8e3b Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 20 Jun 2026 21:34:03 +0200 Subject: [PATCH 05/11] fix frontend component bugs from bug hunt --- .../src/lib/components/AppLayout.svelte | 12 ++++-- .../lib/components/CreateApiKeyDialog.svelte | 5 ++- .../src/lib/components/SigmaSyncDialog.svelte | 7 ++-- .../lib/components/alerts/AlertPreview.svelte | 9 ++++- .../DashboardContainer.svelte | 16 +++++--- .../exceptions/ExceptionDetailsDialog.svelte | 11 +++++- .../notification-channels/ChannelsList.svelte | 17 ++------- .../CreateChannelDialog.svelte | 37 +++++++++++++++---- .../siem/dashboard/MitreHeatmap.svelte | 29 +++++++++------ .../siem/dashboard/SeverityPieChart.svelte | 6 ++- .../lib/components/ui/sonner/sonner.svelte | 4 +- 11 files changed, 100 insertions(+), 53 deletions(-) diff --git a/packages/frontend/src/lib/components/AppLayout.svelte b/packages/frontend/src/lib/components/AppLayout.svelte index 5ce5e5af..8fbf43b0 100644 --- a/packages/frontend/src/lib/components/AppLayout.svelte +++ b/packages/frontend/src/lib/components/AppLayout.svelte @@ -296,15 +296,21 @@ shortcutsStore.install(); // First-time hint toast + let hintTimer: ReturnType | null = null; if (!shortcutsStore.hasShownHint()) { const mod = getPlatform().modSymbol; - setTimeout(() => { + // Mark as shown immediately so a fast unmount before the toast fires + // does not cause the hint to reappear on the next mount. + shortcutsStore.markHintShown(); + hintTimer = setTimeout(() => { toastStore.info(`Pro tip: Press ${mod}+K to open command palette, or ? for shortcuts`, 8000); - shortcutsStore.markHintShown(); }, 3000); } - return () => shortcutsStore.uninstall(); + return () => { + if (hintTimer) clearTimeout(hintTimer); + shortcutsStore.uninstall(); + }; }); diff --git a/packages/frontend/src/lib/components/CreateApiKeyDialog.svelte b/packages/frontend/src/lib/components/CreateApiKeyDialog.svelte index 0fec9e51..d4874a41 100644 --- a/packages/frontend/src/lib/components/CreateApiKeyDialog.svelte +++ b/packages/frontend/src/lib/components/CreateApiKeyDialog.svelte @@ -37,8 +37,9 @@ let dsn = $derived.by(() => { if (!generatedApiKey) return ''; - const host = apiUrlValue.replace('https://', '').replace('http://', ''); - return `https://${generatedApiKey}@${host}`; + const scheme = apiUrlValue.startsWith('http://') ? 'http' : 'https'; + const host = apiUrlValue.replace(/^https?:\/\//, ''); + return `${scheme}://${generatedApiKey}@${host}`; }); function parseOrigins(raw: string): string[] | null { diff --git a/packages/frontend/src/lib/components/SigmaSyncDialog.svelte b/packages/frontend/src/lib/components/SigmaSyncDialog.svelte index aeb5f8a6..0d4041b6 100644 --- a/packages/frontend/src/lib/components/SigmaSyncDialog.svelte +++ b/packages/frontend/src/lib/components/SigmaSyncDialog.svelte @@ -188,10 +188,9 @@

Commit: {syncResult.commitHash.substring( - 0, - 7, - )}{syncResult.commitHash + ? syncResult.commitHash.substring(0, 7) + : "-"}

diff --git a/packages/frontend/src/lib/components/alerts/AlertPreview.svelte b/packages/frontend/src/lib/components/alerts/AlertPreview.svelte index cca52a64..14d11e90 100644 --- a/packages/frontend/src/lib/components/alerts/AlertPreview.svelte +++ b/packages/frontend/src/lib/components/alerts/AlertPreview.svelte @@ -56,7 +56,10 @@ const timeRangeOptions: PreviewRange[] = ["1d", "7d", "14d", "30d"]; + let loadSeq = 0; + async function loadPreview() { + const requestId = ++loadSeq; loading = true; error = null; @@ -71,12 +74,16 @@ previewRange: timeRange, }); + if (requestId !== loadSeq) return; data = response.preview; } catch (e) { + if (requestId !== loadSeq) return; error = e instanceof Error ? e.message : "Failed to load preview"; toastStore.error(error); } finally { - loading = false; + if (requestId === loadSeq) { + loading = false; + } } } diff --git a/packages/frontend/src/lib/components/custom-dashboards/DashboardContainer.svelte b/packages/frontend/src/lib/components/custom-dashboards/DashboardContainer.svelte index cff4022a..3bf56a59 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/DashboardContainer.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/DashboardContainer.svelte @@ -131,11 +131,17 @@ const colW = colWidthPx(); const rowStride = ROW_HEIGHT_PX + ROW_GAP_PX; - // Convert pixel delta into "stored" 12-col units regardless of viewport. - // This way resizing on a tablet (6 visible cols) still updates the - // logical width in the canonical 12-col reference space. - const visibleDeltaCols = Math.round(dx / (colW + COL_GAP_PX)); - const storedDeltaCols = Math.round((visibleDeltaCols / effectiveCols) * 12); + // Width resize only makes sense on the canonical 12-col desktop grid. + // Below 12 effective columns the visible->stored conversion snaps the + // logical width by whole rows (a single visible column maps to 12/effectiveCols + // stored units), which makes resizing erratic and unusable on tablet/mobile. + // In those breakpoints we keep the stored width unchanged and allow only + // height resizing. + let storedDeltaCols = 0; + if (effectiveCols >= 12) { + const visibleDeltaCols = Math.round(dx / (colW + COL_GAP_PX)); + storedDeltaCols = visibleDeltaCols; + } const deltaRows = Math.round(dy / rowStride); const newW = Math.min(12, Math.max(resizeState.minW, resizeState.startW + storedDeltaCols)); diff --git a/packages/frontend/src/lib/components/exceptions/ExceptionDetailsDialog.svelte b/packages/frontend/src/lib/components/exceptions/ExceptionDetailsDialog.svelte index e14ba261..db519286 100644 --- a/packages/frontend/src/lib/components/exceptions/ExceptionDetailsDialog.svelte +++ b/packages/frontend/src/lib/components/exceptions/ExceptionDetailsDialog.svelte @@ -93,8 +93,15 @@ function viewErrorGroup() { if (exception) { - // Navigate to error group page - goto(`/dashboard/errors?fingerprint=${exception.exception.fingerprint}&organizationId=${organizationId}`); + // Navigate to the error groups list filtered to this exception. + // The list page reads the `search` param (ILIKE on exception type/message); + // it does not read a `fingerprint` param, so use the exception type as the + // search term to land on the matching group. + const params = new URLSearchParams({ + organizationId, + search: exception.exception.exceptionType, + }); + goto(`/dashboard/errors?${params.toString()}`); onClose(); } } diff --git a/packages/frontend/src/lib/components/notification-channels/ChannelsList.svelte b/packages/frontend/src/lib/components/notification-channels/ChannelsList.svelte index b0533fff..88614f12 100644 --- a/packages/frontend/src/lib/components/notification-channels/ChannelsList.svelte +++ b/packages/frontend/src/lib/components/notification-channels/ChannelsList.svelte @@ -42,18 +42,9 @@ import TestTube from '@lucide/svelte/icons/test-tube'; import Bell from '@lucide/svelte/icons/bell'; - let channels = $state([]); - let loading = $state(false); - - let currentOrg = $state<{ id: string } | null>(null); - organizationStore.subscribe((state) => { - currentOrg = state.currentOrganization; - }); - - notificationChannelsStore.subscribe((state) => { - channels = state.channels; - loading = state.loading; - }); + let currentOrg = $derived($organizationStore.currentOrganization); + let channels = $derived($notificationChannelsStore.channels); + let loading = $derived($notificationChannelsStore.loading); // Load channels when org changes $effect(() => { @@ -119,7 +110,7 @@ } function formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString(); + return new Date(dateStr).toLocaleDateString('en-US'); } function getConfigSummary(channel: NotificationChannel): string { diff --git a/packages/frontend/src/lib/components/notification-channels/CreateChannelDialog.svelte b/packages/frontend/src/lib/components/notification-channels/CreateChannelDialog.svelte index fe862a9f..701dc07d 100644 --- a/packages/frontend/src/lib/components/notification-channels/CreateChannelDialog.svelte +++ b/packages/frontend/src/lib/components/notification-channels/CreateChannelDialog.svelte @@ -59,6 +59,10 @@ let webhookAuthToken = $state(''); let webhookAuthUser = $state(''); let webhookAuthPass = $state(''); + // Whether the channel being edited already has a stored secret. Secrets are + // never sent back to the client, so on edit we keep the secret fields empty + // and only submit a new value when the user types one. + let hasStoredAuthSecret = $state(false); const isEditing = $derived(!!channel); @@ -74,6 +78,7 @@ webhookAuthToken = ''; webhookAuthUser = ''; webhookAuthPass = ''; + hasStoredAuthSecret = false; testResult = null; } @@ -90,15 +95,21 @@ webhookUrl = config.url; webhookMethod = config.method === 'PUT' ? 'PUT' : 'POST'; webhookHeaders = config.headers ? JSON.stringify(config.headers, null, 2) : ''; + // Never re-hydrate stored secrets into the DOM. Track that a secret + // exists so we can show a placeholder, and leave the secret fields + // empty; a blank value on submit means "keep the existing secret". if (config.auth?.type === 'bearer') { webhookAuthType = 'bearer'; - webhookAuthToken = config.auth.token; + webhookAuthToken = ''; + hasStoredAuthSecret = true; } else if (config.auth?.type === 'basic') { webhookAuthType = 'basic'; webhookAuthUser = config.auth.username; - webhookAuthPass = config.auth.password; + webhookAuthPass = ''; + hasStoredAuthSecret = true; } else { webhookAuthType = 'none'; + hasStoredAuthSecret = false; } } } @@ -132,14 +143,24 @@ } } - if (webhookAuthType === 'bearer' && webhookAuthToken) { - config.auth = { type: 'bearer', token: webhookAuthToken }; + if (webhookAuthType === 'bearer') { + // Only send a token when the user typed a new one. On edit, a + // blank field means "keep the existing secret" (the stored secret + // is never sent to the client), so we omit auth and let the + // backend preserve it. + if (webhookAuthToken) { + config.auth = { type: 'bearer', token: webhookAuthToken }; + } } else if (webhookAuthType === 'basic' && webhookAuthUser) { - config.auth = { + const basicAuth: { type: 'basic'; username: string; password?: string } = { type: 'basic', username: webhookAuthUser, - password: webhookAuthPass, }; + // Same rule for the password: only send a new one when typed. + if (webhookAuthPass) { + basicAuth.password = webhookAuthPass; + } + config.auth = basicAuth; } return config; @@ -412,7 +433,7 @@ @@ -436,7 +457,7 @@ diff --git a/packages/frontend/src/lib/components/siem/dashboard/MitreHeatmap.svelte b/packages/frontend/src/lib/components/siem/dashboard/MitreHeatmap.svelte index 7ea2a848..e5f6ce3a 100644 --- a/packages/frontend/src/lib/components/siem/dashboard/MitreHeatmap.svelte +++ b/packages/frontend/src/lib/components/siem/dashboard/MitreHeatmap.svelte @@ -1,5 +1,4 @@ Date: Sat, 20 Jun 2026 21:34:04 +0200 Subject: [PATCH 06/11] fix frontend route bugs from bug hunt --- .../src/routes/auth/callback/+page.svelte | 7 +++ .../routes/dashboard/admin/+layout@.svelte | 52 ++++++++++++++++++- .../admin/organizations/[id]/+page.svelte | 38 +++++++++++++- .../admin/system-health/+page.svelte | 7 ++- .../routes/dashboard/admin/usage/+page.svelte | 46 +++++++++++++--- .../dashboard/admin/users/[id]/+page.svelte | 36 ++++++++++++- .../src/routes/dashboard/alerts/+page.svelte | 51 +++++++++++++----- .../src/routes/dashboard/errors/+page.svelte | 36 +++++++++++-- .../routes/dashboard/errors/[id]/+page.svelte | 15 +++--- .../src/routes/dashboard/metrics/+page.svelte | 5 +- .../routes/dashboard/monitoring/+page.svelte | 45 ++++++++++++---- .../dashboard/monitoring/[id]/+page.svelte | 26 +++++++++- .../projects/[id]/settings/+page.svelte | 6 +-- .../src/routes/dashboard/search/+page.svelte | 45 ++++++++++++++-- .../dashboard/security/incidents/+page.svelte | 36 ++++++++++--- .../dashboard/settings/audit-log/+page.svelte | 2 +- .../dashboard/settings/general/+page.svelte | 1 + .../dashboard/settings/members/+page.svelte | 2 +- .../settings/pii-masking/+page.svelte | 12 +++++ .../src/routes/dashboard/traces/+page.svelte | 39 ++++++++++---- .../routes/dashboard/traces/[id]/+page.svelte | 22 +++++--- .../frontend/src/routes/login/+page.svelte | 11 +++- .../[orgSlug]/[projectSlug]/+page.svelte | 38 +++++++++----- 23 files changed, 483 insertions(+), 95 deletions(-) diff --git a/packages/frontend/src/routes/auth/callback/+page.svelte b/packages/frontend/src/routes/auth/callback/+page.svelte index 0e4d33ad..e2a4cefd 100644 --- a/packages/frontend/src/routes/auth/callback/+page.svelte +++ b/packages/frontend/src/routes/auth/callback/+page.svelte @@ -24,6 +24,13 @@ const expires = page.url.searchParams.get('expires'); const isNewUser = page.url.searchParams.get('new_user') === 'true'; + // Scrub the token (and other sensitive params) from the URL/history + // immediately so it never leaks into browser history, the referrer, or + // any pageview logging that captures window.location.href. + if (typeof history !== 'undefined') { + history.replaceState(null, '', '/auth/callback'); + } + if (!token) { error = 'No authentication token received. Please try logging in again.'; loading = false; diff --git a/packages/frontend/src/routes/dashboard/admin/+layout@.svelte b/packages/frontend/src/routes/dashboard/admin/+layout@.svelte index 81559af8..eba8c83d 100644 --- a/packages/frontend/src/routes/dashboard/admin/+layout@.svelte +++ b/packages/frontend/src/routes/dashboard/admin/+layout@.svelte @@ -15,9 +15,57 @@ import { cn } from "$lib/utils"; import Footer from "$lib/components/Footer.svelte"; import type { Snippet } from "svelte"; + import { authStore } from "$lib/stores/auth"; + import { UsersAPI } from "$lib/api/users"; + import { goto } from "$app/navigation"; + import { browser } from "$app/environment"; + import { untrack } from "svelte"; + import { get } from "svelte/store"; let { children }: { children: Snippet } = $props(); + // Centralized admin guard for the whole /dashboard/admin section. + // This layout resets the layout chain (the trailing "@"), so it does not + // inherit the dashboard auth guard; enforce authentication + is_admin here + // so individual admin pages cannot accidentally omit the check. + let adminResolved = $state(false); + + const usersAPI = new UsersAPI(() => get(authStore).token); + + $effect(() => { + if (!browser) return; + + if (!$authStore.token) { + untrack(() => goto("/login")); + return; + } + + if (!$authStore.user) return; + + if ($authStore.user.is_admin === undefined) { + untrack(() => { + usersAPI + .getCurrentUser() + .then(({ user }) => { + const currentUser = get(authStore).user; + if (currentUser) { + authStore.updateUser({ ...currentUser, ...user }); + } + if (user.is_admin) { + adminResolved = true; + } else { + goto("/dashboard"); + } + }) + .catch(() => goto("/dashboard")); + }); + } else if ($authStore.user.is_admin === false) { + untrack(() => goto("/dashboard")); + } else { + adminResolved = true; + } + }); + const navigation = [ { name: "Dashboard", @@ -215,7 +263,9 @@
- {@render children()} + {#if adminResolved} + {@render children()} + {/if}