LogTide
v1.0.3
Released
Security

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) took organizationId from the query string and only ran the membership check behind if (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 shared resolveDashboardScope that, for API-key auth, requires the requested org/project to match the key’s bound scope (defaulting to the bound project when omitted) — mirroring the resolveQueryProjectId guard that already protects the query and traces routes. Reported privately via KIberblick.de
  • Stored XSS via OTLP service.name in the service map: service.name only had null bytes stripped at ingestion, so < > " ' survived into span.service_name, and ServiceMap.svelte’s ECharts tooltip.formatter returned a raw HTML string built from it — so a service.name like <img src=x onerror=…> executed for any operator who hovered the node/edge. User-derived tooltip values are now HTML-escaped via a shared escapeHtml util (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-supplied redirect query parameter via goto() with no validation. The check now lives in a shared isSafeInternalPath/safeRedirect helper 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): safeFetch resolved and validated the host, then let fetch() 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: createUser decided the automatic first-admin promotion with a non-atomic hasAnyAdmin() 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 when INITIAL_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 → insert without 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 shared withLimitLock helper holding a per-organization, per-capability transaction-scoped advisory lock. Reported privately via KIberblick.de
  • Defense-in-depth on OTLP service.name at ingestion: complementing the service-map fix, ingested service.name (logs, spans and metrics) is now run through a shared sanitizeServiceName that 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.ip and user agents. 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, those are deep-masked (parse, mask password/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 traceId it 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 requested organizationId, 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 spans hypertable and short-circuited to an empty series when the engine wasn’t TimescaleDB. A new multi-engine reservoir.getSpanTimeseries (time-bucketed volume + true window p50/p95/p99 from raw spans — percentile_cont on TimescaleDB, quantile on ClickHouse, $percentile on 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 to en-US (the project convention). Swept 51 files
  • Error detail trend bars were invisible: the occurrence-trend bars used a percentage height whose 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/removeOnFail defaults on the queue, but its add() passed those keys as undefined on every job. BullMQ merges per-job options with Object.assign, which copies the undefined keys 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 passed autoCreateAlerts: true, inserting an alert_rules row 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 (by sigmahq_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 had min-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|contains on | 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. Added cidr and numeric gt/gte/lt/lte comparators while reworking the parser
  • Sigma |all modifier had the wrong semantics: it was implemented as “all whitespace-split words present in any order” rather than the SigmaHQ list quantifier. |all now 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_overview fetcher via a new GET /api/v1/dashboard/activity-overview endpoint (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 new reservoir.getServiceHealthStats computed directly from raw spans over the requested window on every engine (percentile_cont on TimescaleDB, quantile(0.95) on ClickHouse, $percentile on 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