1.0.3: Coordinated-Disclosure Security Fixes
A security-focused release resolving privately reported issues (coordinated disclosure via KIberblick.de): cross-tenant dashboard reads, stored XSS in the service map, an open redirect, SSRF DNS-rebinding, and two races. Trace span attributes are now PII-masked. No migrations — a drop-in upgrade.
- Cross-tenant read on the dashboard API endpoints closed — API-key auth now matches the requested org/project against the key's bound scope
- Stored XSS via OTLP service.name in the service map fixed; service.name is also sanitized at ingestion as defense in depth
- Open redirect on the auth-free login/register path fixed with a shared safe-redirect helper
- SSRF guard now pins the validated IP on the HTTP path (DNS-rebinding hardening)
- PII masking now also covers trace span attributes, including deep-masked request/response bodies
- No database migrations — a drop-in upgrade from 1.0.2
A security-focused release. It resolves a batch of privately reported issues (coordinated disclosure via KIberblick.de): a 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, and service.name is sanitized at ingestion as defense in depth. No database migrations — a drop-in upgrade. Alongside the security work: two correctness follow-ups from the multi-engine bug-hunt sweep (#255) — full SigmaHQ field-modifier chains and a true service-map p95 on every storage engine (validated against real ClickHouse, MongoDB and TimescaleDB) — two operational fixes (a Redis memory leak and a runaway nightly SigmaHQ sync), and a handful of frontend touch-ups.
Security
- Cross-tenant read on the dashboard API endpoints: the dashboard endpoints (
/api/v1/dashboard/stats,/timeseries,/top-services,/timeline-events,/recent-errors,/activity-overview) tookorganizationIdfrom the query string and only ran the membership check behindif (request.user?.id), which is set for session auth only. For API-key auth that check was skipped, so a holder of any full-access key (bound to org A) could read another org’s dashboard data by passing its id. All six handlers now route through a sharedresolveDashboardScopethat, for API-key auth, requires the requested org/project to match the key’s bound scope (defaulting to the bound project when omitted) — mirroring theresolveQueryProjectIdguard that already protects the query and traces routes. Reported privately via KIberblick.de - Stored XSS via OTLP
service.namein the service map:service.nameonly had null bytes stripped at ingestion, so< > " 'survived intospan.service_name, andServiceMap.svelte’s EChartstooltip.formatterreturned a raw HTML string built from it — so aservice.namelike<img src=x onerror=…>executed for any operator who hovered the node/edge. User-derived tooltip values are now HTML-escaped via a sharedescapeHtmlutil (also adopted by the SIEM HTML report builder). Stored data is left raw on purpose — escaping at the sink avoids double-encoding and keeps the JSON API correct. Reported privately via KIberblick.de - Open redirect on the auth-free login/register path: in
authMode === 'none'deployments both pages forwarded the user-suppliedredirectquery parameter viagoto()with no validation. The check now lives in a sharedisSafeInternalPath/safeRedirecthelper used on both paths; it requires a single-leading-slash path and rejects protocol-relative forms including the backslash variant (/\evil.com). Reported privately via KIberblick.de - SSRF guard now pins the validated IP (DNS-rebinding hardening):
safeFetchresolved and validated the host, then letfetch()re-resolve at connect time, leaving a resolve-then-connect window for rebinding (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:
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 all be promoted. The check-then-insert now runs inside a transaction holding a Postgres advisory lock, so at most one registration wins (and it is closed entirely whenINITIAL_ADMIN_*is set). Reported privately via KIberblick.de - Capability limit check-then-act race: resource-creating routes (api keys, custom dashboards, alert rules, sigma 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 holding a per-organization, per-capability transaction-scoped advisory lock. Reported privately via KIberblick.de - Defense-in-depth on OTLP
service.nameat ingestion: complementing the service-map fix, ingestedservice.name(logs, spans and metrics) is now run through a sharedsanitizeServiceNamethat strips control characters (C0/DEL/C1, including null bytes) and caps the length, while preserving otherwise legitimate characters — so a raw payload can’t resurface through a sink added later. Suggested by KIberblick.de - PII masking now also covers trace span attributes: masking was wired only into log ingestion, so spans were stored with attributes verbatim — including
http.request_body/http.response_body(plaintext credentials, JWTs),net.peer.ipand user agents.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, those are deep-masked (parse, maskpassword/token/email inside, re-serialize) with full redaction as a fallback. 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 the log carries a trace context. The error-group logs endpoint now surfaces the
traceIdit already loaded 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, 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 renders a collapsible “Breadcrumbs (N)” timeline (the same view 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 containing dots keep resolving; object/array values render as compact JSON with the full value on hover
Fixed
- Admin usage page returned 403 for orgs 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 returned “Forbidden” for any org the admin didn’t personally belong to. Platform admins (is_admin) now bypass the membership check on these read endpoints; queries stay filtered by the requestedorganizationId, so tenant scoping is unchanged - Trace volume / latency dashboard panels were empty on ClickHouse and MongoDB: both panel fetchers read span data straight from the Postgres
spanshypertable and short-circuited to an empty series when the engine wasn’t TimescaleDB. A new multi-enginereservoir.getSpanTimeseries(time-bucketed volume + true window p50/p95/p99 from raw spans —percentile_conton TimescaleDB,quantileon ClickHouse,$percentileon MongoDB) now backs both fetchers - UI dates and numbers no longer follow the machine locale: many
toLocale*calls were made with no locale, so on a non-English host they rendered localized weekdays/months (e.g. “mercoledì”) and number grouping. All user-facing date/time/number formatting is now pinned toen-US(the project convention). Swept 51 files - Error detail trend bars were invisible: the occurrence-trend bars used a percentage
heightwhose parent column had no definite height, so it collapsed to zero. The columns now take full height with the bar anchored in a flex track (a small baseline is kept for non-zero days) - Redis memory leak: completed/failed jobs were never evicted: the BullMQ adapter defined sane
removeOnComplete/removeOnFaildefaults on the queue, but itsadd()passed those keys asundefinedon every job. BullMQ merges per-job options withObject.assign, which copies theundefinedkeys and wiped the cleanup config, so every completed/failed job hash (and its full payload) was retained in Redis forever — growing unbounded with the high-volume ingestion jobs while the dashboard still showed 0 waiting / 0 failed.add()now omits those keys unless the caller sets them, so queue-level retention 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 catalog (~2000+ rules) and inserted them all as
enabled = true, and passedautoCreateAlerts: true, inserting analert_rulesrow per synced rule — so an org with 5–6 enabled rules woke up with thousands active and matching alerts. The cron now syncs only the rules the org already imported (bysigmahq_path) to refresh their detection content, and never auto-creates alert rules (a one-off cleanup of already-created rows is tracked separately) - Log Context dialog no longer overflows on wide content: a wide metadata
<pre>or breadcrumb stretched the whole dialog (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 transform-plus-comparator chains 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, following the canonical SigmaHQ model. 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 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 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 series from the legend). It reuses the existing custom-dashboard
activity_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 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,$percentileon MongoDB 7.0+), so ClickHouse and MongoDB service maps now carry real call/error/latency/p95 figures where they previously had none - Trace and session IDs in the log search detail are theme-aware: the expanded row rendered them as hardcoded light-mode pills 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