v0.9.6
Changed
- Frontend now runs as a full SPA on
adapter-nodeinstead of doing SSR with client hydration. A singleexport const ssr = falsein the new root+layout.tscascades 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/callbackpage and the various dashboard subpages, each previously patched with its own per-routessr = 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.tsfiles 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 overrideexport const ssr = truein that page's+page.tsputs it back on SSR without affecting anything else) - Removed 22 redundant route-level
ssr = falsedeclarations (dashboard/+layout.ts,dashboard/admin/+layout.tsand 20+page.tsfiles 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/tracesand/dashboard/metricswhen 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 atsearch/+page.svelte:441-444(and the identical pattern attraces/+page.svelte:142-145andmetrics/+page.svelte:90-93) readconst logsProjectIds = availability?.logsand then branched onlogsProjectIds ? filter : fallback. WhengetProjectDataAvailabilitylegitimately 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, andhasLoadedOncestayedfalseso theSkeletonTablerendered forever. Fix: guard the truthy branch withlogsProjectIds && logsProjectIds.length > 0so 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 toON DELETE CASCADEonlogs.project_id(tracked separately under the soft-delete projects epic) - OIDC login failed against issuer-identifying providers (e.g. Authelia) because the
isscallback parameter was dropped (#233, #234): the OIDC callback handler extracted onlycodeandstatefrom 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) appendissto the redirect andopenid-client'sauthorizationCodeGrant()validates it, so the token exchange failed with "issuer parameter missing" and login never completed. The Fastify callback route now forwards the fullrequest.querythroughhandleOidcCallbackinto the provider, which replays every parameter onto the callback URL handed to the token exchange (soiss,session_state, etc. survive); the requiredcode/stateare always re-asserted from the validated values. Duplicated/array-valued query params are collapsed to a single value withsearchParams.setinstead of being appended, since OIDC authorization-response params are single-valued per RFC 6749 / RFC 9207, avoiding a malformed URL with duplicateiss/codereachingauthorizationCodeGrant. 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 anorganizationIdand aprojectId, 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 ownorganizationId(membership check passes) with a victim's knownprojectIdand reach another tenant's data. Confirmed on four handlers:POST /api/v1/alerts/previewreturnedsampleLogs(time, service, level, message, trace ID) from the foreign project;POST /api/v1/alertsandPOST /api/v1/monitorslet a rule/monitor be scoped to a foreign project (the monitor case also surfaces on the victim's public status page, which renders byproject_id); andGET/DELETE /api/v1/sourcemapslet a member list or delete another tenant's source maps. Fix: a sharedprojectsService.projectBelongsToOrg(projectId, organizationId)helper (singleprojectslookup filtered on bothidandorganization_id) is now enforced right after the existing membership check on each of those routes, returning403when the project is foreign. The alert/monitor update paths don't accept aprojectIdand the custom-dashboards panel pipeline already had an equivalentensureProjectInOrgguard 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.tsthen calledfetch(target, { redirect: 'follow' })andcreateConnection({ host, port }), so a registered user could point a monitor athttp://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.254cloud metadata), CGNAT (100.64.0.0/10), multicast and reserved IPv4/IPv6 ranges (including IPv4-mapped IPv6 and ULA/fc00::/7, link-localfe80::/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 ofredirect: 'follow'. The guard runs both at monitor create/update time (immediate400feedback) and at execution time (authoritative, returns ablockedresult). Private/internal targets are denied by default; self-hosted deployments that legitimately monitor internal services can opt back in withMONITOR_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
- @outcast292 made their first contribution in #233
Full Changelog: v0.9.5...v0.9.6