Skip to content

v0.9.6

Choose a tag to compare

@Polliog Polliog released this 01 Jun 15:56
· 4 commits to main since this release

Changed

  • Frontend now runs as a full SPA on adapter-node instead of doing SSR with client hydration. A single export const ssr = false in the new root +layout.ts cascades to every route, so the server only ships the empty app shell and the client renders from scratch. Eliminates the entire class of hydration mismatch bugs that had been accumulating across login, register, invite, public status page, the /auth/callback page and the various dashboard subpages, each previously patched with its own per-route ssr = false. Server load functions and the Node runtime are untouched (the adapter, API proxying, env vars, BullMQ etc. keep working exactly as before); there are no +page.server.ts / +layout.server.ts files in the repo today so nothing had to change semantically on the server side. UX tradeoff: the public status page at /status/[orgSlug]/[projectSlug] now flashes the empty shell for one paint before the JS hydrates, which is acceptable for an authenticated-by-default product but should be revisited if SEO on the status page becomes a goal (a single-line override export const ssr = true in that page's +page.ts puts it back on SSR without affecting anything else)
  • Removed 22 redundant route-level ssr = false declarations (dashboard/+layout.ts, dashboard/admin/+layout.ts and 20 +page.ts files across landing, login, register, onboarding, invite, status, auth callback and the dashboard search / metrics / alerts / monitoring / projects / sessions / settings subtrees) that had been added one by one as each page hit a hydration bug. The new root-level cascade makes them all dead config; deleting them removes the temptation to copy the pattern into new routes

Fixed

  • Infinite skeleton spinner on /dashboard/search, /dashboard/traces and /dashboard/metrics when no project in the org had its data-availability flag set, typically right after a user deleted the only projects that had been ingesting. The filter logic at search/+page.svelte:441-444 (and the identical pattern at traces/+page.svelte:142-145 and metrics/+page.svelte:90-93) read const logsProjectIds = availability?.logs and then branched on logsProjectIds ? filter : fallback. When getProjectDataAvailability legitimately returned { logs: [] } the empty array took the truthy branch ([] is truthy in JS) and [].includes(p.id) excluded every project, so the displayed project list was empty, loadLogs() never fired, and hasLoadedOnce stayed false so the SkeletonTable rendered forever. Fix: guard the truthy branch with logsProjectIds && logsProjectIds.length > 0 so an empty availability response falls back to "show all projects" exactly like the API-failure path (.catch(() => null)) already does. The 0.9.4 backend optimization that introduced the cached availability flags is unaffected; this is purely a frontend null-vs-empty conflation. Logs of already-hard-deleted projects on the TimescaleDB engine remain unrecoverable due to ON DELETE CASCADE on logs.project_id (tracked separately under the soft-delete projects epic)
  • OIDC login failed against issuer-identifying providers (e.g. Authelia) because the iss callback parameter was dropped (#233, #234): the OIDC callback handler extracted only code and state from the provider redirect and rebuilt the callback URL from just those two, discarding everything else. Providers implementing RFC 9207 (OAuth 2.0 Authorization Server Issuer Identification) append iss to the redirect and openid-client's authorizationCodeGrant() validates it, so the token exchange failed with "issuer parameter missing" and login never completed. The Fastify callback route now forwards the full request.query through handleOidcCallback into the provider, which replays every parameter onto the callback URL handed to the token exchange (so iss, session_state, etc. survive); the required code/state are always re-asserted from the validated values. Duplicated/array-valued query params are collapsed to a single value with searchParams.set instead of being appended, since OIDC authorization-response params are single-valued per RFC 6749 / RFC 9207, avoiding a malformed URL with duplicate iss/code reaching authorizationCodeGrant. Covered by new tests across the route, service and provider layers, including the array-collapse and undefined-param branches

Security

  • Cross-tenant project access via unvalidated projectId (authenticated): several routes accepted both an organizationId and a projectId, verified the caller was a member of the organization, but never verified that the supplied project actually belonged to that organization. Since project UUIDs are normal identifiers that appear in dashboard URLs and client API calls, a user could pair their own organizationId (membership check passes) with a victim's known projectId and reach another tenant's data. Confirmed on four handlers: POST /api/v1/alerts/preview returned sampleLogs (time, service, level, message, trace ID) from the foreign project; POST /api/v1/alerts and POST /api/v1/monitors let a rule/monitor be scoped to a foreign project (the monitor case also surfaces on the victim's public status page, which renders by project_id); and GET/DELETE /api/v1/sourcemaps let a member list or delete another tenant's source maps. Fix: a shared projectsService.projectBelongsToOrg(projectId, organizationId) helper (single projects lookup filtered on both id and organization_id) is now enforced right after the existing membership check on each of those routes, returning 403 when the project is foreign. The alert/monitor update paths don't accept a projectId and the custom-dashboards panel pipeline already had an equivalent ensureProjectInOrg guard at its choke point, so no change was needed there. Regression tests cover each handler
  • Server-side request forgery (SSRF) and internal port scanning via monitors and webhooks (authenticated): HTTP/TCP uptime monitors and webhook delivery executed user-supplied targets from the backend's network with no meaningful destination validation. Monitor creation only checked that an HTTP target started with http(s):// and that a TCP target contained :; checker.ts then called fetch(target, { redirect: 'follow' }) and createConnection({ host, port }), so a registered user could point a monitor at http://169.254.169.254/…, http://127.0.0.1, 10.0.0.0/8, etc. and use the sanitized up/down result and timing to probe internal services. The webhook provider had only literal-string private-IP filtering (no DNS resolution, incomplete IPv6, followed redirects), leaving DNS- and redirect-based bypasses open. Fix: a centralized outbound guard (utils/ssrf-guard.ts) resolves hostnames and rejects loopback, private, link-local (incl. 169.254.169.254 cloud metadata), CGNAT (100.64.0.0/10), multicast and reserved IPv4/IPv6 ranges (including IPv4-mapped IPv6 and ULA/fc00::/7, link-local fe80::/10). TCP checks resolve-then-pin the socket to the validated address (closing DNS-rebinding between validation and connect); HTTP checks and webhook delivery follow redirects manually and revalidate every hop instead of redirect: 'follow'. The guard runs both at monitor create/update time (immediate 400 feedback) and at execution time (authoritative, returns a blocked result). Private/internal targets are denied by default; self-hosted deployments that legitimately monitor internal services can opt back in with MONITOR_ALLOW_PRIVATE_TARGETS=true, which also governs webhook delivery. Note: HTTPS does not yet pin the connected address against a custom dispatcher, so a narrow DNS-rebinding window remains for HTTP monitors (tracked for a follow-up); the reported direct-target and redirect-to-internal vectors are closed

New Contributors

Full Changelog: v0.9.5...v0.9.6