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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e05eba..5a17a7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.2] - 2026-06-22 + +A frontend correctness and security release from a comprehensive multi-agent frontend bug hunt (UI, logic, reactivity, leaks and security), plus a hardening of how the browser authenticates the live-streaming endpoints. The headline item is single-use stream tickets: the session token no longer travels in WebSocket/SSE URLs (where reverse proxies log it). One additive database migration (`049_stream_tickets`); otherwise a drop-in upgrade. + +### Security +- **Session token no longer placed in WebSocket/SSE URLs**: browser `WebSocket` and `EventSource` cannot send an `Authorization` header, so the log live-tail (`/api/v1/logs/ws`), the SIEM events stream (`/api/v1/siem/events`) and the trace live-tail (`/api/v1/traces/stream`) previously carried the long-lived session token in the URL query string, where it is logged by proxies and servers. The client now mints a short-lived, single-use **stream ticket** via an authenticated `POST /api/v1/stream-tickets` and passes that ticket instead. Tickets live in the relational database (not Redis, so the mechanism is portable across the BullMQ and graphile queue backends), expire in 30s and are consumed on first use. The legacy `?token=` path still works for backward compatibility +- **Webhook channel secrets no longer rehydrated into the DOM**: editing a notification channel no longer pre-fills the bearer token / basic-auth password inputs with the stored secret; the fields stay empty with a "leave blank to keep current" hint and are only sent when the user types a new value +- **OIDC callback strips the session token from the URL** after reading it, so it no longer lingers in browser history, the referrer or logs +- **Admin pages enforce a client-side admin guard**: several admin views (user detail, usage, organization detail) loaded and could mutate data on mount without checking the admin role; they now redirect non-admins, and the admin section layout has a guard of its own +- **Removed a debug `console.log`** that leaked log message content and api-key metadata to the browser console on the error-detail page + +### Added +- **Global 401 handler**: a single fetch interceptor installed at app startup clears local auth state and redirects to the login page (preserving the current path so the user lands back there after signing in) on any authenticated `/api/v1` 401 that is not an auth endpoint. Previously a revoked or expired session was only detected on a full dashboard remount, so a logged-out user could keep clicking around getting silent failures +- **`POST /api/v1/stream-tickets`** endpoint and `stream_tickets` table (migration 049) backing the stream-ticket auth described above + +### Fixed +- **Stale-response races**: overlapping loads triggered by fast filter/pagination changes could let an older in-flight response overwrite fresher results. Added local request-sequence guards on the log search, traces list, error groups, SIEM incidents, monitor detail/list, custom-dashboard panels and alert-preview views +- **API client error handling**: error-branch `response.json()` calls are guarded so a non-JSON error body (reverse-proxy 502/HTML, empty `204`) no longer throws a `SyntaxError` that masks the real HTTP failure (auth, admin and exceptions clients) +- **Locale-stable formatting**: user-facing dates and numbers now use explicit `en-US` formatting across the status pages, members, project settings, traces, metrics, search and notification-channel views; alert-history timestamps no longer label UTC values as if they were local time +- **Lifecycle and memory leaks**: component store subscriptions are auto-managed, a first-run shortcut-hint `setTimeout` is cleared on unmount, and chart instances are disposed, so navigating away no longer leaves timers, listeners or subscriptions behind +- **Svelte 5 reactivity and assorted UI fixes**: the trace detail page reloads when navigating between traces; the api-key DSN preserves an `http://` scheme for non-TLS deployments; the "View Error Group" action navigates with a param the target page actually reads; SigmaSync no longer crashes when a commit hash is absent; the toaster follows the app theme; the delete-organization confirm is disabled while in flight; PII masking rules require a regex or field names; numeric monitor inputs guard against `NaN`; and the audit-log resource cell no longer renders a literal escape sequence +- **ClickHouse**: materialized-view backfills now run once instead of on every startup + +### Notes +- Left intentionally unchanged: storing the session token in `localStorage` (a disputed, low-severity finding). Moving it to an httpOnly cookie would trade XSS token-theft for CSRF surface and a full auth-model overhaul without a clear net win; the high-leverage XSS defenses (CSP, output sanitization, auditing the few `{@html}` sites) are tracked separately + ## [1.0.1] - 2026-06-19 A security and correctness release from a comprehensive, multi-engine bug audit of the 1.0 line. The headline items are two cross-tenant data-exposure fixes that were live in 1.0.0, alongside a broad sweep of detection, ingestion, storage, alerting and frontend correctness fixes. No database migrations; this is a drop-in upgrade. The storage-layer fixes were validated against real ClickHouse and MongoDB (and TimescaleDB), and CI now runs the MongoDB reservoir integration suite. diff --git a/README.md b/README.md index 5a229f74..8c8ae6cd 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ Coverage Docker Artifact Hub - Version + Version License Status
-> **๐ŸŒŠ LogTide 1.0.1 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards. +> **๐ŸŒŠ LogTide 1.0.2 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards. --- @@ -124,7 +124,7 @@ We host it for you. Perfect for testing. [**Sign up at logtide.dev**](https://lo --- -## โœจ Core Features (v1.0.1) +## โœจ Core Features (v1.0.2) ### Monitoring, Pipelines & Dashboards * ๐Ÿฉบ **Uptime Monitoring & Status Pages:** HTTP/TCP/heartbeat monitors with configurable thresholds, auto-created SIEM incidents on failure, scheduled maintenances, and public Uptime-Kuma-style status pages per project. diff --git a/package.json b/package.json index e77fb4d9..00232a3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "logtide", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "LogTide - Self-hosted log management platform", "author": "LogTide Team", diff --git a/packages/backend/migrations/049_stream_tickets.sql b/packages/backend/migrations/049_stream_tickets.sql new file mode 100644 index 00000000..ee1534f0 --- /dev/null +++ b/packages/backend/migrations/049_stream_tickets.sql @@ -0,0 +1,16 @@ +-- migrations/049_stream_tickets.sql +-- Short-lived, single-use tickets for browser streaming endpoints (WebSocket +-- live-tail and SSE). EventSource/WebSocket cannot send Authorization headers, +-- so the browser used to put the long-lived session token in the URL query +-- string, where reverse proxies and servers log it. Instead the client now +-- mints a short-lived ticket via an authenticated request and passes the ticket +-- in the stream URL; it is consumed (deleted) on first use. + +CREATE TABLE IF NOT EXISTS stream_tickets ( + ticket TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_stream_tickets_expires_at ON stream_tickets (expires_at); diff --git a/packages/backend/package.json b/packages/backend/package.json index 6b193d34..05198cc3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/backend", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "LogTide Backend API", "type": "module", diff --git a/packages/backend/src/database/types.ts b/packages/backend/src/database/types.ts index b68939d2..f2725c6f 100644 --- a/packages/backend/src/database/types.ts +++ b/packages/backend/src/database/types.ts @@ -106,6 +106,13 @@ export interface SessionsTable { created_at: Generated; } +export interface StreamTicketsTable { + ticket: string; + user_id: string; + expires_at: Timestamp; + created_at: Generated; +} + export interface OrganizationsTable { id: Generated; name: string; @@ -1116,6 +1123,7 @@ export interface Database { logs: LogsTable; users: UsersTable; sessions: SessionsTable; + stream_tickets: StreamTicketsTable; organizations: OrganizationsTable; organization_members: OrganizationMembersTable; organization_invitations: OrganizationInvitationsTable; diff --git a/packages/backend/src/modules/auth/plugin.ts b/packages/backend/src/modules/auth/plugin.ts index b498f43e..f4241af7 100644 --- a/packages/backend/src/modules/auth/plugin.ts +++ b/packages/backend/src/modules/auth/plugin.ts @@ -5,6 +5,7 @@ import { apiKeysService } from '../api-keys/service.js'; import { usersService } from '../users/service.js'; import { settingsService } from '../settings/service.js'; import { bootstrapService } from '../bootstrap/service.js'; +import { streamTicketService } from '../streaming/stream-ticket-service.js'; declare module 'fastify' { interface FastifyRequest { @@ -94,6 +95,33 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { const apiKey = request.headers['x-api-key'] as string; const authHeader = request.headers['authorization'] as string; const tokenParam = (request.query as any)?.token as string | undefined; + const ticketParam = (request.query as any)?.ticket as string | undefined; + + // 0. Try a single-use stream ticket first (for WebSocket/SSE - the browser + // cannot send headers, and this avoids putting the session token in the URL). + if (ticketParam) { + const userId = await streamTicketService.consumeTicket(ticketParam); + if (!userId) { + reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid or expired stream ticket', + }); + return; + } + + const user = await usersService.getUserById(userId); + if (!user) { + reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid or expired stream ticket', + }); + return; + } + + request.authenticated = true; + (request as any).user = user; + return; + } // 1. Try token from query param first (for SSE - EventSource can't send headers) if (tokenParam) { diff --git a/packages/backend/src/modules/query/websocket.ts b/packages/backend/src/modules/query/websocket.ts index 4e3ba3d3..6bee987f 100644 --- a/packages/backend/src/modules/query/websocket.ts +++ b/packages/backend/src/modules/query/websocket.ts @@ -35,36 +35,44 @@ const websocketRoutes: FastifyPluginAsync = async (fastify) => { token?: string; }; - // Verify authentication token - if (!token) { - socket.close(1008, 'Authentication token required'); - return; - } - if (!projectId) { socket.close(1008, 'ProjectId required'); return; } - // Verify session token (reuse session validation logic) and that the - // authenticated user actually has access to the requested project. The REST + // The auth plugin runs onRequest for this upgrade and authenticates via the + // single-use stream ticket (?ticket=) or a legacy session token (?token=), + // attaching the user to the request. Prefer that. Fall back to validating a + // session token directly only if no user was attached. Either way, verify the + // authenticated user actually has access to the requested project: the REST // log/trace/metric routes all gate on verifyProjectAccess; without the same // check here, any authenticated user could live-tail any project's logs by // passing a foreign projectId (cross-tenant leak). try { - const session = await db - .selectFrom('sessions') - .innerJoin('users', 'users.id', 'sessions.user_id') - .select(['users.id as userId', 'sessions.expires_at']) - .where('sessions.token', '=', token) - .executeTakeFirst(); - - if (!session || new Date(session.expires_at) < new Date()) { - socket.close(1008, 'Invalid or expired authentication token'); - return; + let userId: string | undefined = (req as any).user?.id; + + if (!userId) { + if (!token) { + socket.close(1008, 'Authentication required'); + return; + } + + const session = await db + .selectFrom('sessions') + .innerJoin('users', 'users.id', 'sessions.user_id') + .select(['users.id as userId', 'sessions.expires_at']) + .where('sessions.token', '=', token) + .executeTakeFirst(); + + if (!session || new Date(session.expires_at) < new Date()) { + socket.close(1008, 'Invalid or expired authentication token'); + return; + } + + userId = session.userId; } - const hasAccess = await verifyProjectAccess(projectId, session.userId); + const hasAccess = await verifyProjectAccess(projectId, userId); if (!hasAccess) { socket.close(1008, 'Access denied for the requested project'); return; diff --git a/packages/backend/src/modules/siem/sse-events.ts b/packages/backend/src/modules/siem/sse-events.ts index 0a4796ee..6ab6399d 100644 --- a/packages/backend/src/modules/siem/sse-events.ts +++ b/packages/backend/src/modules/siem/sse-events.ts @@ -6,6 +6,7 @@ import { UsersService } from '../users/service.js'; import { db } from '../../database/index.js'; import { settingsService } from '../settings/service.js'; import { bootstrapService } from '../bootstrap/service.js'; +import { streamTicketService } from '../streaming/stream-ticket-service.js'; const siemService = new SiemService(db); const organizationsService = new OrganizationsService(); @@ -43,11 +44,14 @@ export async function registerSiemSseRoutes(fastify: FastifyInstance) { schema: { querystring: { type: 'object', - required: ['organizationId', 'token'], + required: ['organizationId'], properties: { organizationId: { type: 'string', format: 'uuid' }, projectId: { type: 'string', format: 'uuid' }, incidentId: { type: 'string', format: 'uuid' }, + // Either a single-use stream ticket (preferred) or a legacy session + // token must be provided; EventSource cannot send an auth header. + ticket: { type: 'string' }, token: { type: 'string' }, }, }, @@ -59,7 +63,8 @@ export async function registerSiemSseRoutes(fastify: FastifyInstance) { organizationId: z.string().uuid(), projectId: z.string().uuid().optional(), incidentId: z.string().uuid().optional(), - token: z.string().min(1), + ticket: z.string().min(1).optional(), + token: z.string().min(1).optional(), }); const query = schema.parse(request.query); @@ -76,14 +81,27 @@ export async function registerSiemSseRoutes(fastify: FastifyInstance) { error: 'Auth-free mode enabled but default user not configured', }); } - } else { - // Standard mode: validate session token + } else if (query.ticket) { + // Preferred: single-use stream ticket (keeps the session token out of the URL) + const userId = await streamTicketService.consumeTicket(query.ticket); + user = userId ? await usersService.getUserById(userId) : null; + if (!user) { + return reply.status(401).send({ + error: 'Invalid or expired stream ticket', + }); + } + } else if (query.token) { + // Legacy: validate session token from the query string user = await usersService.validateSession(query.token); if (!user) { return reply.status(401).send({ error: 'Invalid or expired session token', }); } + } else { + return reply.status(401).send({ + error: 'A stream ticket or session token is required', + }); } // Verify user is member of organization diff --git a/packages/backend/src/modules/streaming/stream-ticket-routes.ts b/packages/backend/src/modules/streaming/stream-ticket-routes.ts new file mode 100644 index 00000000..0ca93c60 --- /dev/null +++ b/packages/backend/src/modules/streaming/stream-ticket-routes.ts @@ -0,0 +1,45 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { settingsService } from '../settings/service.js'; +import { bootstrapService } from '../bootstrap/service.js'; +import { streamTicketService } from './stream-ticket-service.js'; + +/** + * POST /api/v1/stream-tickets + * + * Mint a short-lived, single-use ticket for the authenticated user. The client + * passes the returned ticket (instead of the session token) in WebSocket/SSE + * stream URLs, so the long-lived session token never appears in a URL. + * + * Registered after the auth plugin, so the request is already authenticated via + * the normal Authorization: Bearer header. + */ +const streamTicketRoutes: FastifyPluginAsync = async (fastify) => { + fastify.post( + '/api/v1/stream-tickets', + { + config: { rateLimit: { max: 60, timeWindow: '1 minute' } }, + }, + async (request: any, reply) => { + let userId: string | undefined = request.user?.id; + + // Auth-free mode: the auth plugin marks the request authenticated without + // attaching a user, so fall back to the configured default user. + if (!userId) { + const authMode = await settingsService.getAuthMode(); + if (authMode === 'none') { + const defaultUser = await bootstrapService.getDefaultUser(); + userId = defaultUser?.id; + } + } + + if (!userId) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + + const { ticket, expiresInSeconds } = await streamTicketService.createTicket(userId); + return reply.send({ ticket, expiresInSeconds }); + } + ); +}; + +export default streamTicketRoutes; diff --git a/packages/backend/src/modules/streaming/stream-ticket-service.ts b/packages/backend/src/modules/streaming/stream-ticket-service.ts new file mode 100644 index 00000000..1719d688 --- /dev/null +++ b/packages/backend/src/modules/streaming/stream-ticket-service.ts @@ -0,0 +1,62 @@ +import { randomBytes } from 'crypto'; +import { db } from '../../database/index.js'; + +/** + * Short-lived, single-use tickets for browser streaming endpoints. + * + * Browser WebSocket and EventSource APIs cannot set request headers, so they + * cannot send the session token as `Authorization: Bearer`. Putting the + * long-lived session token in the URL query string leaks it into reverse-proxy + * and server access logs. Instead the client makes an authenticated request to + * mint a ticket and passes the ticket (not the session token) in the stream URL. + * + * Tickets are stored in the relational database (not Redis) so the mechanism + * works regardless of the configured queue backend (BullMQ or graphile-worker). + */ + +// Tickets are meant to be redeemed immediately after minting; keep the window short. +const TICKET_TTL_MS = 30_000; + +export const streamTicketService = { + /** + * Create a single-use ticket bound to the given user. Best-effort prunes + * expired tickets so the table stays small. + */ + async createTicket(userId: string): Promise<{ ticket: string; expiresInSeconds: number }> { + const ticket = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + TICKET_TTL_MS); + + await db + .insertInto('stream_tickets') + .values({ ticket, user_id: userId, expires_at: expiresAt }) + .execute(); + + // Best-effort cleanup of expired tickets (ignore failures). + try { + await db.deleteFrom('stream_tickets').where('expires_at', '<', new Date()).execute(); + } catch { + // non-fatal + } + + return { ticket, expiresInSeconds: Math.floor(TICKET_TTL_MS / 1000) }; + }, + + /** + * Atomically consume a ticket. Returns the bound userId if the ticket exists + * and has not expired, otherwise null. The ticket is deleted whether or not it + * was valid for that value, so it can never be redeemed twice. + */ + async consumeTicket(ticket: string): Promise { + if (!ticket) return null; + + const row = await db + .deleteFrom('stream_tickets') + .where('ticket', '=', ticket) + .returning(['user_id', 'expires_at']) + .executeTakeFirst(); + + if (!row) return null; + if (new Date(row.expires_at) < new Date()) return null; + return row.user_id; + }, +}; diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index 5edc324c..a20173fb 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -53,6 +53,7 @@ import internalLoggingPlugin from './plugins/internal-logging-plugin.js'; import { initializeInternalLogging, shutdownInternalLogging } from './utils/internal-logger.js'; import websocketPlugin from './plugins/websocket.js'; import websocketRoutes from './modules/query/websocket.js'; +import streamTicketRoutes from './modules/streaming/stream-ticket-routes.js'; import { enrichmentService } from './modules/siem/enrichment-service.js'; import { validateStorageConfig } from './database/storage-config.js'; import { shutdownReservoir } from './database/reservoir.js'; @@ -197,6 +198,7 @@ export async function build(opts = {}) { await fastify.register(authPlugin); await fastify.register(contextPlugin); + await fastify.register(streamTicketRoutes); await fastify.register(ingestionRoutes); await fastify.register(queryRoutes); await fastify.register(correlationRoutes, { prefix: '/api' }); diff --git a/packages/backend/src/tests/modules/streaming/stream-tickets.test.ts b/packages/backend/src/tests/modules/streaming/stream-tickets.test.ts new file mode 100644 index 00000000..4ca414d4 --- /dev/null +++ b/packages/backend/src/tests/modules/streaming/stream-tickets.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import type { FastifyInstance } from 'fastify'; +import { build } from '../../../server.js'; +import { db } from '../../../database/index.js'; +import { streamTicketService } from '../../../modules/streaming/stream-ticket-service.js'; +import { createTestContext } from '../../helpers/factories.js'; +import { createTestSession } from '../../helpers/auth.js'; + +/** + * Stream tickets keep the long-lived session token out of WebSocket/SSE URLs. + * These cover the service (single-use, expiry) and the end-to-end auth chain: + * minting a ticket with a Bearer token, then redeeming it on a protected route + * via the ?ticket= query param handled by the auth plugin. + */ +describe('Stream tickets', () => { + let app: FastifyInstance; + let userId: string; + let projectId: string; + let sessionToken: string; + + beforeEach(async () => { + const ctx = await createTestContext(); + userId = ctx.user.id; + projectId = ctx.project.id; + const session = await createTestSession(userId); + sessionToken = session.token; + + app = await build(); + await app.ready(); + }); + + describe('streamTicketService', () => { + it('mints a ticket that can be consumed exactly once', async () => { + const { ticket } = await streamTicketService.createTicket(userId); + expect(ticket).toMatch(/^[a-f0-9]{64}$/); + + const first = await streamTicketService.consumeTicket(ticket); + expect(first).toBe(userId); + + // Single use: the same ticket cannot be redeemed again. + const second = await streamTicketService.consumeTicket(ticket); + expect(second).toBeNull(); + }); + + it('rejects an unknown ticket', async () => { + const result = await streamTicketService.consumeTicket('does-not-exist'); + expect(result).toBeNull(); + }); + + it('rejects an expired ticket', async () => { + const ticket = 'expired'.padEnd(64, '0'); + await db + .insertInto('stream_tickets') + .values({ ticket, user_id: userId, expires_at: new Date(Date.now() - 1000) }) + .execute(); + + const result = await streamTicketService.consumeTicket(ticket); + expect(result).toBeNull(); + }); + }); + + describe('POST /api/v1/stream-tickets', () => { + it('mints a ticket for an authenticated session', async () => { + const response = await request(app.server) + .post('/api/v1/stream-tickets') + .set('Authorization', `Bearer ${sessionToken}`) + .expect(200); + + expect(response.body.ticket).toMatch(/^[a-f0-9]{64}$/); + expect(response.body.expiresInSeconds).toBeGreaterThan(0); + + // The minted ticket resolves to the authenticated user. + const resolved = await streamTicketService.consumeTicket(response.body.ticket); + expect(resolved).toBe(userId); + }); + + it('rejects an unauthenticated request', async () => { + await request(app.server).post('/api/v1/stream-tickets').expect(401); + }); + }); + + describe('ticket auth on protected routes', () => { + it('authenticates a request via ?ticket= and does not leak the session token', async () => { + const { ticket } = await streamTicketService.createTicket(userId); + + // A protected route (logs query) accepts the ticket in place of credentials. + await request(app.server) + .get('/api/v1/logs') + .query({ projectId, ticket }) + .expect(200); + }); + + it('rejects an invalid ticket on a protected route', async () => { + await request(app.server) + .get('/api/v1/logs') + .query({ projectId, ticket: 'invalid-ticket' }) + .expect(401); + }); + + it('rejects reuse of a ticket on a protected route (single use)', async () => { + const { ticket } = await streamTicketService.createTicket(userId); + + await request(app.server).get('/api/v1/logs').query({ projectId, ticket }).expect(200); + // The auth plugin consumed the ticket on the first request. + await request(app.server).get('/api/v1/logs').query({ projectId, ticket }).expect(401); + }); + }); +}); diff --git a/packages/backend/src/utils/internal-logger.ts b/packages/backend/src/utils/internal-logger.ts index 4572ddba..26c300f0 100644 --- a/packages/backend/src/utils/internal-logger.ts +++ b/packages/backend/src/utils/internal-logger.ts @@ -58,7 +58,7 @@ export async function initializeInternalLogging(): Promise { dsn, service: process.env.SERVICE_NAME || 'logtide-backend', environment: process.env.NODE_ENV || 'development', - release: process.env.npm_package_version || '1.0.1', + release: process.env.npm_package_version || '1.0.2', batchSize: 5, // Smaller batch for internal logs to see them faster flushInterval: 5000, maxBufferSize: 1000, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 3b666d06..07c68938 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/frontend", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "LogTide Frontend Dashboard", "type": "module", diff --git a/packages/frontend/src/hooks.client.ts b/packages/frontend/src/hooks.client.ts index 44ce2978..f8f923d9 100644 --- a/packages/frontend/src/hooks.client.ts +++ b/packages/frontend/src/hooks.client.ts @@ -9,7 +9,7 @@ if (dsn) { dsn, service: 'logtide-frontend-client', environment: env.PUBLIC_NODE_ENV || 'production', - release: env.PUBLIC_APP_VERSION || '1.0.1', + release: env.PUBLIC_APP_VERSION || '1.0.2', debug: env.PUBLIC_NODE_ENV === 'development', browser: { // Core Web Vitals (LCP, INP, CLS, TTFB) diff --git a/packages/frontend/src/hooks.server.ts b/packages/frontend/src/hooks.server.ts index 2481d1dc..31b94ef5 100644 --- a/packages/frontend/src/hooks.server.ts +++ b/packages/frontend/src/hooks.server.ts @@ -82,7 +82,7 @@ export const handle = dsn dsn, service: 'logtide-frontend', environment: privateEnv?.NODE_ENV || 'production', - release: process.env.npm_package_version || '1.0.1', }) as unknown as Handle, + release: process.env.npm_package_version || '1.0.2', }) as unknown as Handle, requestLogHandle, configHandle ) 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/api/fetch-interceptor.ts b/packages/frontend/src/lib/api/fetch-interceptor.ts new file mode 100644 index 00000000..32dd038a --- /dev/null +++ b/packages/frontend/src/lib/api/fetch-interceptor.ts @@ -0,0 +1,87 @@ +import { goto } from '$app/navigation'; +import { authStore } from '$lib/stores/auth'; +import { getAuthToken } from '$lib/utils/auth'; + +/** + * Global 401 handler. + * + * The ~30 API client wrappers all call window.fetch directly and there is no + * shared HTTP client to hook into. Rather than route every client through a new + * wrapper, we install a single fetch interceptor once at app startup: when any + * authenticated API request comes back 401 (revoked or expired session), we + * clear the local auth state and bounce the user to the login page, preserving + * where they were so they land back there after signing in. + * + * Without this, a dead session was only detected on a full dashboard remount, so + * a user could keep clicking around a logged-out app getting silent failures. + */ + +let installed = false; +// Guards against a burst of concurrent 401s (e.g. several parallel requests) +// all triggering a logout + navigation at once. +let handling = false; + +function urlOf(input: RequestInfo | URL): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + if (input instanceof Request) return input.url; + return String(input); +} + +function isApiRequest(url: string): boolean { + return url.includes('/api/v1/'); +} + +// Auth flows (login, register, OIDC, LDAP, admin auth) manage their own 401s and +// must not be treated as an expired session. +function isAuthEndpoint(url: string): boolean { + return url.includes('/auth/'); +} + +function handleUnauthorized(): void { + if (handling) return; + handling = true; + + authStore.clearAuth(); + + const onLoginPage = window.location.pathname.startsWith('/login'); + if (!onLoginPage) { + const current = window.location.pathname + window.location.search; + const target = `/login?redirect=${encodeURIComponent(current)}`; + // Prefer SvelteKit navigation; fall back to a hard redirect if it fails. + Promise.resolve(goto(target)).catch(() => { + window.location.href = target; + }); + } + + // Allow future handling once this logout cycle has settled. + window.setTimeout(() => { + handling = false; + }, 1000); +} + +export function installAuthFetchInterceptor(): void { + if (installed || typeof window === 'undefined') return; + installed = true; + + const originalFetch = window.fetch.bind(window); + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const response = await originalFetch(input, init); + + try { + if ( + response.status === 401 && + getAuthToken() && + isApiRequest(urlOf(input)) && + !isAuthEndpoint(urlOf(input)) + ) { + handleUnauthorized(); + } + } catch { + // Never let interceptor logic break a fetch call. + } + + return response; + }; +} diff --git a/packages/frontend/src/lib/api/logs.ts b/packages/frontend/src/lib/api/logs.ts index cc68d9cf..8faceb2a 100644 --- a/packages/frontend/src/lib/api/logs.ts +++ b/packages/frontend/src/lib/api/logs.ts @@ -1,5 +1,6 @@ import { getApiBaseUrl, getApiUrl } from '$lib/config'; import { getAuthToken } from '$lib/utils/auth'; +import { requestStreamTicket } from './stream-tickets'; import type { LogLevel, MetadataFilterInput } from '@logtide/shared'; interface LogEntry { @@ -165,17 +166,17 @@ export class LogsAPI { return response.json(); } - createLogsWebSocket(filters: { service?: string; level?: string; hostname?: string; projectId: string }): WebSocket { + async createLogsWebSocket(filters: { service?: string; level?: string; hostname?: string; projectId: string }): Promise { const params = new URLSearchParams(); params.append('projectId', filters.projectId); if (filters.service) params.append('service', filters.service); if (filters.level) params.append('level', filters.level); if (filters.hostname) params.append('hostname', filters.hostname); - const token = this.getToken(); - if (token) { - params.append('token', token); - } + // Pass a short-lived single-use ticket instead of the session token so the + // long-lived token never appears in the WebSocket URL (and thus in proxy logs). + const ticket = await requestStreamTicket(this.getToken()); + params.append('ticket', ticket); const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // getApiUrl() may be empty (same-origin reverse proxy) or a relative path, in diff --git a/packages/frontend/src/lib/api/stream-tickets.ts b/packages/frontend/src/lib/api/stream-tickets.ts new file mode 100644 index 00000000..8e2392da --- /dev/null +++ b/packages/frontend/src/lib/api/stream-tickets.ts @@ -0,0 +1,26 @@ +import { getApiBaseUrl } from '$lib/config'; + +/** + * Mint a short-lived, single-use stream ticket for the current session. + * + * Browser WebSocket and EventSource APIs cannot set an Authorization header, so + * stream URLs historically carried the session token in the query string, where + * reverse proxies and servers log it. Instead, callers fetch a ticket via this + * authenticated request and pass the ticket (not the token) in the stream URL. + */ +export async function requestStreamTicket(token: string | null): Promise { + const response = await fetch(`${getApiBaseUrl()}/stream-tickets`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (!response.ok) { + throw new Error(`Failed to obtain stream ticket: ${response.status}`); + } + + const data = await response.json().catch(() => ({})); + if (!data.ticket) { + throw new Error('Stream ticket response did not include a ticket'); + } + return data.ticket as string; +} diff --git a/packages/frontend/src/lib/api/traces.ts b/packages/frontend/src/lib/api/traces.ts index b26be3a1..ae74dc2e 100644 --- a/packages/frontend/src/lib/api/traces.ts +++ b/packages/frontend/src/lib/api/traces.ts @@ -1,5 +1,6 @@ import { getApiBaseUrl } from '$lib/config'; import { getAuthToken } from '$lib/utils/auth'; +import { requestStreamTicket } from './stream-tickets'; export interface TraceRecord { trace_id: string; @@ -227,11 +228,11 @@ export class TracesAPI { * the traces query (projectId, service, error). Returns an EventSource the * caller is responsible for closing. */ - createTracesEventSource(filters: { + async createTracesEventSource(filters: { projectId: string; service?: string | string[]; error?: boolean; - }): EventSource { + }): Promise { const params = new URLSearchParams(); params.append('projectId', filters.projectId); if (filters.service) { @@ -239,8 +240,10 @@ export class TracesAPI { if (services.length > 0) params.append('service', services.join(',')); } if (filters.error !== undefined) params.append('error', String(filters.error)); - const token = this.getToken(); - if (token) params.append('token', token); + // Use a short-lived single-use ticket instead of the session token so the + // token never appears in the SSE URL (and thus in proxy/server logs). + const ticket = await requestStreamTicket(this.getToken()); + params.append('ticket', ticket); const url = `${getApiBaseUrl()}/traces/stream?${params.toString()}`; return new EventSource(url, { withCredentials: true }); } 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/Footer.svelte b/packages/frontend/src/lib/components/Footer.svelte index 39ad4ff6..4ce73582 100644 --- a/packages/frontend/src/lib/components/Footer.svelte +++ b/packages/frontend/src/lib/components/Footer.svelte @@ -1,7 +1,7 @@ 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 @@ (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/stores/siem.ts b/packages/frontend/src/lib/stores/siem.ts index 08286d2f..cc446611 100644 --- a/packages/frontend/src/lib/stores/siem.ts +++ b/packages/frontend/src/lib/stores/siem.ts @@ -9,6 +9,7 @@ import { } from '$lib/api/siem'; import { getApiUrl } from '$lib/config'; import { getAuthToken } from '$lib/utils/auth'; +import { requestStreamTicket } from '$lib/api/stream-tickets'; // ============================================================================ // TYPES @@ -187,7 +188,7 @@ function createSiemStore() { // Real-time Methods (SSE) // ======================================== - startRealtimeUpdates(organizationId: string, incidentId?: string): void { + async startRealtimeUpdates(organizationId: string, incidentId?: string): Promise { const currentState = get({ subscribe }); // Close existing connection @@ -203,8 +204,19 @@ function createSiemStore() { params.append('incidentId', incidentId); } + // Use a short-lived single-use ticket instead of the session token so the + // token never appears in the SSE URL (and thus in proxy/server logs). + let ticket: string; + try { + ticket = await requestStreamTicket(token); + } catch (error) { + console.error('Failed to obtain SIEM stream ticket:', error); + return; + } + params.append('ticket', ticket); + const eventSource = new EventSource( - `${getApiUrl()}/api/v1/siem/events?${params.toString()}&token=${token}` + `${getApiUrl()}/api/v1/siem/events?${params.toString()}` ); eventSource.onmessage = (event) => { 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', diff --git a/packages/frontend/src/routes/+layout.svelte b/packages/frontend/src/routes/+layout.svelte index 56f000f6..4096e2f6 100644 --- a/packages/frontend/src/routes/+layout.svelte +++ b/packages/frontend/src/routes/+layout.svelte @@ -5,9 +5,13 @@ import { afterNavigate } from "$app/navigation"; import { hub } from "@logtide/core"; import { createBoundaryHandler } from "@logtide/sveltekit"; + import { installAuthFetchInterceptor } from "$lib/api/fetch-interceptor"; const onerror = createBoundaryHandler('RootLayout'); + // Install the global 401 handler once, before any API request fires. + installAuthFetchInterceptor(); + // Track client-side navigations as page views afterNavigate(({ to, type }) => { const client = hub.getClient(); 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}