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 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/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 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/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/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/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}