A security-focused release. It resolves a batch of privately reported issues (coordinated disclosure via KIberblick.de): cross-tenant read on the dashboard API endpoints, stored XSS via OTLP service.name in the service map, an open redirect on the auth-free login/register path, a DNS-rebinding gap in the SSRF guard's HTTP path, a first-admin bootstrap promotion race, and a capability-limit check-then-act race; trace span attributes are now PII-masked as well, and service.name is sanitized at ingestion as defense in depth. No database migrations; drop-in upgrade. Alongside the security work: 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. 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-overviewtookorganizationIdfrom the query string and only ran the organization-membership check behindif (request.user?.id), which is set for session auth only. For API-key authrequireFullAccesslets any non-write key through without settingrequest.user, so the membership check was skipped and the org was read from the attacker-supplied query rather than the key's boundrequest.organizationId; whenprojectIdwas 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 sharedresolveDashboardScopethat, 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), mirroringresolveQueryProjectIdwhich 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.namein the service map (fixed):service.namefrom ingested traces passed throughsanitizeForPostgres, which only strips null bytes, so< > " 'survived intospan.service_nameand were served verbatim by the service-map API. InServiceMap.sveltethe EChartstooltip.formatterreturned a raw HTML string built fromparams.name/params.data.source/params.data.target, and ECharts renders tooltip output as HTML, so aservice.namelike<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 sharedescapeHtmlutil (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 - Open redirect on auth-free login/register (fixed): in
authMode === 'none'deployments the login and register pages forwarded the user-suppliedredirectquery parameter viagoto(redirectUrl)with no validation, while the normal submit path already checked it. The check now lives in a sharedisSafeInternalPath/safeRedirecthelper used by both the auth-free and normal paths on both pages; it requires a single-leading-slash path and rejects protocol-relative forms including the backslash variant (/\evil.com) that some browsers normalize to//. Reported privately via KIberblick.de - SSRF guard now pins the validated IP (DNS rebinding hardening):
safeFetchresolved and validated the target host, then letfetch()re-resolve at connect time, leaving a resolve-then-connect window where a hostname could rebind to an internal address between check and connect (the TCP monitor path already pinned, the HTTP path did not). The HTTP(S) path now connects through a per-request undici dispatcher whose lookup is pinned to the already-validated address, so the socket reaches the exact IP that passed validation; TLS SNI and certificate validation still use the original hostname. Reported privately via KIberblick.de - First-admin bootstrap race (fixed):
createUserdecided the automatic first-admin promotion with a non-atomichasAnyAdmin()check followed by a separate insert, so concurrent registrations in the zero-admin window could each observe "no admin yet" and all be promoted. The check-then-insert now runs inside a transaction holding a Postgres advisory lock, so at most one registration wins the promotion. Only reachable before the first admin exists (and closed entirely whenINITIAL_ADMIN_*is set). Reported privately via KIberblick.de - Capability limit check-then-act race (fixed): resource-creating routes (api keys, custom dashboards, alert rules, sigma rule import/enable, notification channels) ran
COUNT -> assertWithinLimit -> insertwithout serialization, so parallel requests could each read a count under the limit and then all insert, exceeding a configured finite cap (a quota bypass, not a tenant boundary; the OSS default has no finite limits). The count+create now runs through a sharedwithLimitLockhelper that takes a per-organization, per-capability transaction-scoped advisory lock, so concurrent creators of the same resource type serialize and the cap holds. Reported privately via KIberblick.de - Defense-in-depth on OTLP
service.nameat ingestion: complementing the service-map output-encoding fix above, ingestedservice.name(for logs, spans and metrics) is now run through a sharedsanitizeServiceNamethat strips control characters (C0/DEL/C1, including null bytes) and caps the length, while deliberately preserving otherwise legitimate characters (escaping still happens at each sink). This keeps a raw payload from resurfacing through a sink that is added later or forgets to encode. Suggested by 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.ipand user agents, all persisted unmasked.tracesService.ingestSpansnow runs the same org/project masking rules over each span'sattributes,resourceAttributesand 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, maskpassword/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
- Per-occurrence trace links on the error detail page: each log in an error group's Logs tab now shows a "View Trace" action when that log carries a trace context, opening the existing trace timeline. The error-group logs endpoint (
GET /api/v1/error-groups/:id/logs) now surfaces thetraceIdit already loaded from storage and previously discarded; no schema change, no migration - Copy buttons on metadata blocks: the log search expanded detail and the Log Context dialog now have a one-click copy on each metadata block (with copied feedback), so a log's metadata JSON can be grabbed without selecting it by hand
- Breadcrumbs timeline in the log search detail: when a log carries
metadata.breadcrumbs, the expanded row now renders a collapsible "Breadcrumbs (N)" timeline (the sameBreadcrumbTimelineview already used in the Log Context dialog) instead of leaving them buried in the raw metadata JSON - Nested metadata columns: custom metadata columns in log search now accept dot-notation paths (e.g.
sdk.name) to read into nested objects. Exact top-level keys still win first, so flat keys that contain dots (e.g.debug.trace_id) keep resolving; object/array values render as compact JSON, and the full value is available on hover
Fixed
- Admin usage page returned 403 for organizations the admin wasn't a member of: the metering endpoints (
/usage,/usage/breakdown,/usage/storage,/usage/capabilities) gated solely on org membership, so the platform Admin > Usage page (which lists every organization) got "Forbidden" whenever a selected org wasn't one the admin personally belonged to. Platform admins (is_admin) now bypass the membership check on these read endpoints; the queries stay filtered by the requestedorganizationId, so tenant scoping is unchanged - Trace volume / trace latency dashboard panels were empty on ClickHouse and MongoDB: both panel fetchers read span data straight from the Postgres
spanshypertable and its continuous aggregates and short-circuited to an empty series whenreservoir.getEngineType() !== 'timescale', so on ClickHouse/MongoDB deployments (where spans live in those engines) the panels returned no data. Added a multi-enginereservoir.getSpanTimeseries(time-bucketed span volume + true window p50/p95/p99 from raw spans:percentile_conton TimescaleDB,quantileon ClickHouse,$percentileon MongoDB) and switched both fetchers to it. The ClickHouse and MongoDB paths mirror the validatedgetServiceHealthStatspercentile approach - UI dates and numbers no longer follow the machine locale: across the frontend many
toLocaleDateString/toLocaleTimeString/toLocaleStringcalls were made with no locale (orundefined), so on a non-English host they rendered localized weekdays/months (e.g. "mercoledi") and localized number grouping. All user-facing date/time/number formatting is now pinned toen-US(the project convention), so the UI reads the same regardless of the server/browser locale. Swept 51 files - Error detail trend bars were invisible: the occurrence-trend chart on the error detail page rendered only the weekday labels and no bars. The bars used a percentage
heightwhose parent column had no definite height (items-endleft the columns sized to content), so the percentage collapsed to zero. The columns now take full height with the bar anchored in a flex track, so the bars render (a small baseline is kept for non-zero days) - Redis memory leak: completed/failed jobs were never evicted: the BullMQ queue adapter defined sane
removeOnComplete/removeOnFailcleanup defaults on the queue, but itsadd()then passedremoveOnComplete: undefined/removeOnFail: undefinedon every job. BullMQ merges per-job options over the queue defaults withObject.assign, which copies theundefinedkeys and so wiped the cleanup config, making BullMQ retain every completed and failed job hash (and its full payload) in Redis forever. With the high-volume ingestion jobs (sigma-detection,log-pipeline,exception-parsing) carrying whole log batches, Redis grew unbounded (multi-GB) while the dashboard still showed 0 waiting / 0 failed.add()now omits those keys unless the caller sets them, so the queue-level retention (keep 100 completed/1h, 50 failed/24h) applies - Nightly SigmaHQ sync re-imported the entire catalog and auto-created alert rules: the 2:30 AM cron called the sync with no rule selection, falling into the "fetch ALL rules" path that pulled the whole SigmaHQ catalog (~2000+ rules) and inserted them all as
enabled = true(thesigma_rules.enabledcolumn defaults to true and the insert never set it), so an org that had enabled 5-6 rules woke up with thousands active. The same cron passedautoCreateAlerts: true, which inserted analert_rulesrow per synced Sigma rule, so Sigma rules appeared to "turn into" alert rules overnight. The cron now syncs only the rules the org already imported (bysigmahq_path) to refresh their detection content, and never auto-creates alert rules; Sigma rules stay independent. (Does not retroactively clean rules/alerts already created; a one-off cleanup is tracked separately) - Log Context dialog no longer overflows on wide content: a wide metadata
<pre>or breadcrumb entry stretched the whole dialog (the grid children hadmin-width: auto); the content now stays within the dialog and the wide block scrolls on its own axis - Sigma compound field-modifier chains were silently truncated: the matcher split a field key like
CommandLine|utf16le|base64offset|containson|but kept only the first modifier, so any rule using a transform-plus-comparator chain (or a PowerShell-encstyleutf16le|base64offset|contains) matched incorrectly. The whole chain is now parsed and applied in order: transforms (base64,base64offset,utf16le/utf16/utf16be/wide,windash) rewrite the pattern, then the final comparator runs. Transforms follow the canonical SigmaHQ model (the pattern is encoded, e.g. the field is checked forbase64(value)), which is what real SigmaHQ rules are authored against. Addedcidrand numericgt/gte/lt/ltecomparators while reworking the parser - Sigma
|allmodifier had the wrong semantics: it was implemented as "all whitespace-split words present in any order" rather than the SigmaHQ list quantifier.|allnow flips the default OR over a value list into AND (every list element must match), and composes with modifier chains (e.g.cmd|base64|contains|all)
Changed
- Project overview now shows an Activity Overview instead of a logs-only timeline: the project overview page (
/dashboard/projects/:id/overview) replaced the "Logs Timeline (Last 24 Hours)" chart (log levels only) with the multi-signal Activity Overview, plotting logs, log errors, spans, span errors, detections and alerts over the same 24h window (toggle individual series from the legend). It reuses the existing custom-dashboardactivity_overviewfetcher via a newGET /api/v1/dashboard/activity-overviewendpoint (org-membership + project-in-org scoped); no new storage or migration - Service-map p95 is now a true window percentile across all engines: the service dependency map previously reported
MAX(duration_p95_ms)from the per-bucket spans continuous aggregate, which overestimates (a p95 is not derivable by combining per-bucket p95s) and was only ever produced on TimescaleDB. Per-service health stats now come from a newreservoir.getServiceHealthStatscomputed directly from raw spans over the requested window on every engine:percentile_conton TimescaleDB,quantile(0.95)on ClickHouse, and$percentileon MongoDB (approximate t-digest, Mongo 7.0+). ClickHouse and MongoDB service maps now carry real call/error/latency/p95 figures where they previously had none. Thespans_hourly_stats/spans_daily_statsaggregates are unchanged and still back the dashboards - Trace and session IDs in the log search detail are theme-aware: the expanded log row rendered them as hardcoded light-mode pills (
bg-purple-100/bg-teal-100) that looked washed out in dark mode. The trace ID is now a link that opens the trace timeline (primary accent) with a separate filter button, and the session ID is a dark-safe filter button; both derive their colors from the design tokens