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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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
Expand Up @@ -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",
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;
},
};
2 changes: 2 additions & 0 deletions packages/backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' });
Expand Down
Loading
Loading