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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Two correctness follow-ups from the multi-engine bug-hunt sweep (issue #255): Sigma detection now honors full SigmaHQ field-modifier chains, and the service-map p95 is a true window percentile on every storage engine. No database migrations; drop-in upgrade. The storage-layer change was validated against real ClickHouse, MongoDB and TimescaleDB. This line also fixes two operational bugs: a Redis memory leak where completed/failed BullMQ jobs were never evicted, and a nightly SigmaHQ sync that re-imported the whole catalog as enabled and auto-created alert rules. Plus a few frontend touch-ups: theme-aware trace/session IDs in the log detail, per-occurrence trace links on the error page, metadata copy buttons, a breadcrumbs timeline and nested metadata columns in log search.

### Security
- **Cross-tenant read on the dashboard API endpoints (fixed)**: the five dashboard endpoints (`/api/v1/dashboard/stats`, `/timeseries`, `/top-services`, `/timeline-events`, `/recent-errors`) plus the newer `/activity-overview` took `organizationId` from the query string and only ran the organization-membership check behind `if (request.user?.id)`, which is set for session auth only. For API-key auth `requireFullAccess` lets any non-write key through without setting `request.user`, so the membership check was skipped and the org was read from the attacker-supplied query rather than the key's bound `request.organizationId`; when `projectId` was omitted the project-in-org check was skipped too. A holder of any full-access API key (bound to org A) could read another organization's dashboard data by passing that org's id. All six handlers now route through a shared `resolveDashboardScope` that, for API-key auth, requires the requested org to match the key's bound org and the requested project to match (defaulting to the key's bound project when omitted), mirroring `resolveQueryProjectId` which already protects the query and traces routes. Session auth keeps the org-membership and project-in-org checks. Reported privately via KIberblick.de
- **Stored XSS via OTLP `service.name` in the service map (fixed)**: `service.name` from ingested traces passed through `sanitizeForPostgres`, which only strips null bytes, so `< > " '` survived into `span.service_name` and were served verbatim by the service-map API. In `ServiceMap.svelte` the ECharts `tooltip.formatter` returned a raw HTML string built from `params.name` / `params.data.source` / `params.data.target`, and ECharts renders tooltip output as HTML, so a `service.name` like `<img src=x onerror=...>` executed in the browser of any operator who opened the project's service map and hovered the node or edge. User-derived tooltip values are now HTML-escaped via a shared `escapeHtml` util (also adopted by the SIEM HTML report builder, replacing its private copy). Stored data is left raw on purpose (escaping at the sink, not the store, avoids double-encoding and keeps the JSON API correct). Reported privately via KIberblick.de
- **PII masking now also covers trace span attributes**: masking was wired only into log ingestion, so trace spans were stored with their attributes verbatim. Spans routinely carry `http.request_body` / `http.response_body` (plaintext credentials, JWTs), `net.peer.ip` and user agents, all persisted unmasked. `tracesService.ingestSpans` now runs the same org/project masking rules over each span's `attributes`, `resourceAttributes` and event/link attributes before storage, and drops (fail-closed) any span whose masking throws. Because request/response bodies are opaque stringified JSON that field-name rules can't see into, those body attributes are deep-masked (parse the JSON, mask `password`/`token`/email inside, re-serialize) with full redaction as a fallback when the value is not parseable JSON. Metric attributes are not yet masked (tracked separately)

### Added
Expand Down
194 changes: 88 additions & 106 deletions packages/backend/src/modules/dashboard/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,76 @@ async function verifyProjectBelongsToOrg(projectId: string, organizationId: stri
return !!result;
}

/** Returned when access was denied and a response has already been sent. */
const SCOPE_DENIED = Symbol('dashboard-scope-denied');

/**
* Resolve and authorize the tenant scope for a dashboard request.
*
* Session auth (request.user set): the user must be a member of the requested
* organization; a provided projectId must belong to that organization.
*
* API-key auth (no user; request.organizationId/projectId bound by the auth
* plugin): the requested organizationId MUST match the key's bound organization
* and a provided projectId MUST match the key's bound project. When projectId is
* omitted it defaults to the key's bound project, so a project-scoped key can
* never read org-wide or cross-org data. This mirrors resolveQueryProjectId,
* which already protects the query and traces routes.
*
* Returns the effective projectId to scope on (string | undefined), or
* SCOPE_DENIED if a 403/404 was already sent.
*/
async function resolveDashboardScope(
request: any,
reply: any,
organizationId: string,
projectId?: string,
): Promise<string | undefined | typeof SCOPE_DENIED> {
// Session-based auth: org-wide membership check.
if (request.user?.id) {
const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);
if (!hasAccess) {
reply.code(403).send({
error: 'Access denied - you are not a member of this organization',
});
return SCOPE_DENIED;
}

if (projectId) {
const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
if (!belongsToOrg) {
reply.code(404).send({ error: 'Project not found in this organization' });
return SCOPE_DENIED;
}
}

return projectId;
}

// API-key auth: the key is bound to a single org/project by the auth plugin.
// Enforce that the requested org/project match the key's bound values.
const boundOrg = request.organizationId;
const boundProject = request.projectId;

if (boundOrg && organizationId !== boundOrg) {
reply.code(403).send({
error: 'Access denied - API key is not bound to this organization',
});
return SCOPE_DENIED;
}

if (boundProject && projectId && projectId !== boundProject) {
reply.code(403).send({
error: 'Access denied - API key is not bound to this project',
});
return SCOPE_DENIED;
}

// Default to the key's bound project so a project-scoped key cannot read
// org-wide data (mirrors resolveQueryProjectId).
return projectId ?? boundProject;
}

const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
// GET /api/v1/dashboard/stats - Get dashboard statistics
fastify.get('/api/v1/dashboard/stats', {
Expand All @@ -63,26 +133,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}

// SECURITY: Verify user is member of this organization (if using session auth)
if (request.user?.id) {
const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);

if (!hasAccess) {
return reply.code(403).send({
error: 'Access denied - you are not a member of this organization',
});
}
}

// Verify project belongs to org if specified
if (projectId) {
const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
if (!belongsToOrg) {
return reply.code(404).send({ error: 'Project not found in this organization' });
}
}
const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
if (scope === SCOPE_DENIED) return;

const stats = await dashboardService.getStats(organizationId, projectId);
const stats = await dashboardService.getStats(organizationId, scope);
return stats;
},
});
Expand Down Expand Up @@ -112,25 +166,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}

// SECURITY: Verify user is member of this organization (if using session auth)
if (request.user?.id) {
const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);

if (!hasAccess) {
return reply.code(403).send({
error: 'Access denied - you are not a member of this organization',
});
}
}
const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
if (scope === SCOPE_DENIED) return;

if (projectId) {
const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
if (!belongsToOrg) {
return reply.code(404).send({ error: 'Project not found in this organization' });
}
}

const timeseries = await dashboardService.getTimeseries(organizationId, projectId);
const timeseries = await dashboardService.getTimeseries(organizationId, scope);
return { timeseries };
},
});
Expand Down Expand Up @@ -164,27 +203,14 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'organizationId is required' });
}

if (request.user?.id) {
const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);
if (!hasAccess) {
return reply.code(403).send({
error: 'Access denied - you are not a member of this organization',
});
}
}

if (projectId) {
const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
if (!belongsToOrg) {
return reply.code(404).send({ error: 'Project not found in this organization' });
}
}
const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
if (scope === SCOPE_DENIED) return;

const config: ActivityOverviewConfig = {
type: 'activity_overview',
title: 'Activity Overview',
source: 'mixed',
projectId: projectId ?? null,
projectId: scope ?? null,
timeRange: timeRange ?? '24h',
series: ACTIVITY_OVERVIEW_SERIES,
};
Expand Down Expand Up @@ -223,25 +249,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}

// SECURITY: Verify user is member of this organization (if using session auth)
if (request.user?.id) {
const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);

if (!hasAccess) {
return reply.code(403).send({
error: 'Access denied - you are not a member of this organization',
});
}
}
const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
if (scope === SCOPE_DENIED) return;

if (projectId) {
const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
if (!belongsToOrg) {
return reply.code(404).send({ error: 'Project not found in this organization' });
}
}

const services = await dashboardService.getTopServices(organizationId, limit || 5, projectId);
const services = await dashboardService.getTopServices(organizationId, limit || 5, scope);
return { services };
},
});
Expand Down Expand Up @@ -271,24 +282,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}

if (request.user?.id) {
const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);

if (!hasAccess) {
return reply.code(403).send({
error: 'Access denied - you are not a member of this organization',
});
}
}
const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
if (scope === SCOPE_DENIED) return;

if (projectId) {
const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
if (!belongsToOrg) {
return reply.code(404).send({ error: 'Project not found in this organization' });
}
}

const events = await dashboardService.getTimelineEvents(organizationId, projectId);
const events = await dashboardService.getTimelineEvents(organizationId, scope);
return { events };
},
});
Expand Down Expand Up @@ -318,25 +315,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}

// SECURITY: Verify user is member of this organization (if using session auth)
if (request.user?.id) {
const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);

if (!hasAccess) {
return reply.code(403).send({
error: 'Access denied - you are not a member of this organization',
});
}
}

if (projectId) {
const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
if (!belongsToOrg) {
return reply.code(404).send({ error: 'Project not found in this organization' });
}
}
const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
if (scope === SCOPE_DENIED) return;

const errors = await dashboardService.getRecentErrors(organizationId, projectId);
const errors = await dashboardService.getRecentErrors(organizationId, scope);
return { errors };
},
});
Expand Down
Loading
Loading