Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ claude.md
/test-vector/
/.env.prod
/docs/superpowers/
/FRONTEND-BUGS.md
/BUGS.md
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
<a href="https://codecov.io/gh/logtide-dev/logtide"><img src="https://codecov.io/gh/logtide-dev/logtide/branch/main/graph/badge.svg" alt="Coverage"></a>
<a href="https://hub.docker.com/r/logtide/backend"><img src="https://img.shields.io/docker/v/logtide/backend?label=docker&logo=docker" alt="Docker"></a>
<a href="https://artifacthub.io/packages/helm/logtide/logtide"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/logtide" alt="Artifact Hub"></a>
<img src="https://img.shields.io/badge/version-1.0.1-blue.svg" alt="Version">
<img src="https://img.shields.io/badge/version-1.0.2-blue.svg" alt="Version">
<img src="https://img.shields.io/badge/license-AGPLv3-blue.svg" alt="License">
<img src="https://img.shields.io/badge/status-beta-success.svg" alt="Status">
</div>

<br />

> **🌊 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.

---

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/migrations/049_stream_tickets.sql
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@logtide/backend",
"version": "1.0.1",
"version": "1.0.2",
"private": true,
"description": "LogTide Backend API",
"type": "module",
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export interface SessionsTable {
created_at: Generated<Timestamp>;
}

export interface StreamTicketsTable {
ticket: string;
user_id: string;
expires_at: Timestamp;
created_at: Generated<Timestamp>;
}

export interface OrganizationsTable {
id: Generated<string>;
name: string;
Expand Down Expand Up @@ -1116,6 +1123,7 @@ export interface Database {
logs: LogsTable;
users: UsersTable;
sessions: SessionsTable;
stream_tickets: StreamTicketsTable;
organizations: OrganizationsTable;
organization_members: OrganizationMembersTable;
organization_invitations: OrganizationInvitationsTable;
Expand Down
28 changes: 28 additions & 0 deletions packages/backend/src/modules/auth/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
46 changes: 27 additions & 19 deletions packages/backend/src/modules/query/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 22 additions & 4 deletions packages/backend/src/modules/siem/sse-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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' },
},
},
Expand All @@ -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);
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions packages/backend/src/modules/streaming/stream-ticket-routes.ts
Original file line number Diff line number Diff line change
@@ -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;
62 changes: 62 additions & 0 deletions packages/backend/src/modules/streaming/stream-ticket-service.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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;
},
};
Loading
Loading