From b6eaa85733d044c2813e3622210af5471a338872 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 29 Apr 2026 17:16:33 -0700 Subject: [PATCH] fix(tenancy): stop leaking workspace-level Coolify services across projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: every Vibn project was rendering every other project's services in the same workspace (Twenty CRM, n8n, all databases, all secrets). Tenancy was effectively broken — cross-project data exposure inside a workspace. Root cause: - Coolify's POST /projects validates `description` against a strict allowlist (letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`). - Our description "Vibn project: (workspace: )" contains two colons. Every project-create on Coolify returned 422. - lib/projects.ts caught that 422 and fell back to `workspace.coolify_project_uuid` so deploys "weren't blocked." - That UUID is shared by every Vibn project in the workspace, so listServicesInProject(coolifyProjectUuid) returned the union of all projects' services, applications, and databases for any project in the workspace. The Product, Hosting, and Infrastructure tabs all rendered cross-tenant data as if it were the current project's. Fixes (defense in depth — fix at every layer): 1. lib/coolify.ts createProject(): sanitize the description against Coolify's allowlist at the boundary so no caller can ever ship a description that 422s. Replaces disallowed chars with `-`, collapses runs, caps at 255 chars. 2. lib/projects.ts ensureProjectCoolifyProject(): - Pre-sanitize the description we pass (belt + suspenders). - Detect when `stored === workspace.coolify_project_uuid` (the legacy bad state) and re-provision a dedicated project. - REMOVE the workspace-UUID fallback on create failure. A 422 now leaves coolifyProjectUuid null and the UI shows an empty state, which is correct: better to surface "no resources" than to lie about which project owns what. - Export sanitizeCoolifyDescription helper for reuse. 3. /api/projects/[projectId]/anatomy/route.ts: SELF-HEAL on every read. If the project's stored Coolify UUID matches the workspace's UUID, we treat it as missing, re-provision a dedicated Coolify project on the fly (idempotent — reuses the existing one if found by name), persist the new UUID, and continue serving with the corrected scope. If provisioning fails we fall back to undefined, NOT the workspace UUID, so no cross-tenant data ever surfaces again. The self-heal means existing already-broken projects will fix themselves on the next page load — no manual data migration needed. Made-with: Cursor --- app/api/projects/[projectId]/anatomy/route.ts | 50 ++++++++++++++++++- lib/coolify.ts | 15 +++++- lib/projects.ts | 44 +++++++++++++--- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/app/api/projects/[projectId]/anatomy/route.ts b/app/api/projects/[projectId]/anatomy/route.ts index c488fb56..e43cd87c 100644 --- a/app/api/projects/[projectId]/anatomy/route.ts +++ b/app/api/projects/[projectId]/anatomy/route.ts @@ -33,6 +33,8 @@ import { listServicesInProject, listServiceEnvs, listDatabasesInProject, + listProjects as listCoolifyProjects, + createProject as createCoolifyProject, type CoolifyApplication, type CoolifyService, type CoolifyDatabase, @@ -597,7 +599,53 @@ export async function GET( const data = rows[0].data; const workspaceId = rows[0].vibn_workspace_id ?? undefined; const giteaRepo = data?.giteaRepo as string | undefined; - const coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined; + let coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined; + + // Self-heal: if a previous bug stored the *workspace*'s Coolify + // project UUID on this project (which would surface every other + // project's services as if they belonged to this one), wipe it + // and re-provision a dedicated Coolify project on the fly. + if (coolifyProjectUuid && workspaceId) { + try { + const wsRow = await query<{ slug: string; coolify_project_uuid: string | null }>( + `SELECT slug, coolify_project_uuid FROM vibn_workspaces WHERE id = $1 LIMIT 1`, + [workspaceId], + ); + const ws = wsRow[0]; + if (ws && ws.coolify_project_uuid && ws.coolify_project_uuid === coolifyProjectUuid) { + console.warn( + "[anatomy] Project", projectId, + "had workspace-UUID stored as coolifyProjectUuid — re-provisioning." + ); + const projectSlug = (data?.slug as string | undefined) ?? projectId; + const projectNm = (data?.productName as string | undefined) ?? projectSlug; + const wantName = `vibn-${ws.slug}-${projectSlug}`; + try { + const all = await listCoolifyProjects(); + const existing = all.find(p => p.name === wantName); + const fresh = existing + ? existing + : await createCoolifyProject( + wantName, + `Vibn project ${projectNm} - workspace ${ws.slug}`, + ); + await query( + `UPDATE fs_projects + SET data = data || jsonb_build_object('coolifyProjectUuid', $2::text), + updated_at = NOW() + WHERE id = $1`, + [projectId, fresh.uuid], + ); + coolifyProjectUuid = fresh.uuid; + } catch (provErr) { + console.error("[anatomy] auto-heal provisioning failed:", provErr); + coolifyProjectUuid = undefined; // refuse to serve workspace-level resources + } + } + } catch (wsErr) { + console.error("[anatomy] workspace lookup for self-heal failed:", wsErr); + } + } const projectName = (data?.productName as string | undefined) ?? (data?.name as string | undefined) ?? diff --git a/lib/coolify.ts b/lib/coolify.ts index 699a541f..c581d43c 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -177,9 +177,22 @@ export async function listProjects(): Promise { } export async function createProject(name: string, description?: string): Promise { + // Coolify validates the description against a narrow allowlist: + // letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`. + // Anything else (colon, semicolon, brackets, pipe, …) errors out with + // a 422 — and a 422 here used to silently fall back to the workspace + // project, leaking services across tenants. Sanitize defensively so + // every caller is safe. + const cleaned = description + ? description + .replace(/[^A-Za-z0-9 \-_.,!?()'"+=*/@&]/g, "-") + .replace(/-{2,}/g, "-") + .trim() + .slice(0, 255) + : undefined; return coolifyFetch('/projects', { method: 'POST', - body: JSON.stringify({ name, description }), + body: JSON.stringify({ name, description: cleaned }), }); } diff --git a/lib/projects.ts b/lib/projects.ts index 2cfd9158..1cbea2e4 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -34,6 +34,18 @@ export function coolifyProjectNameForVibnProject(workspaceSlug: string, projectS return `vibn-${workspaceSlug}-${projectSlug}`; } +/** Coolify's `description` validator only accepts a narrow set of chars + * (letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`). + * Notably colon, semicolon, brackets, and pipe are rejected. We strip + * anything else down to a hyphen so create payloads always validate. */ +export function sanitizeCoolifyDescription(s: string): string { + return s + .replace(/[^A-Za-z0-9 \-_.,!?()'"+=*/@&]/g, "-") + .replace(/-{2,}/g, "-") + .trim() + .slice(0, 255); +} + /** * Idempotently ensure the given Vibn project has its own Coolify Project. * Returns the Coolify project UUID. Persists it to fs_projects.data.coolifyProjectUuid. @@ -56,9 +68,25 @@ export async function ensureProjectCoolifyProject( if (!row) return null; const stored = row.data?.coolifyProjectUuid as string | undefined; - if (stored) return stored; + + // SAFETY: if a prior bug stored the *workspace*'s Coolify project UUID + // here, treat it as missing and re-provision. Sharing the workspace + // Coolify project across Vibn projects causes services from one + // project to bleed into another (we hit this on Apr 29). + const isWorkspaceUuid = + stored && workspace.coolify_project_uuid && stored === workspace.coolify_project_uuid; + if (stored && !isWorkspaceUuid) return stored; + if (isWorkspaceUuid) { + console.warn( + '[projects] Detected workspace-UUID stored as project Coolify UUID for', + projectId, '— re-provisioning a dedicated project.' + ); + } const wantName = coolifyProjectNameForVibnProject(workspace.slug, opts.projectSlug); + const description = sanitizeCoolifyDescription( + `Vibn project ${opts.projectName || opts.projectSlug} - workspace ${workspace.slug}`, + ); let coolifyUuid: string | null = null; try { @@ -69,10 +97,7 @@ export async function ensureProjectCoolifyProject( if (existing) { coolifyUuid = existing.uuid; } else { - const created = await createCoolifyProject( - wantName, - `Vibn project: ${opts.projectName || opts.projectSlug} (workspace: ${workspace.slug})`, - ); + const created = await createCoolifyProject(wantName, description); coolifyUuid = created.uuid; } } catch (err) { @@ -81,9 +106,12 @@ export async function ensureProjectCoolifyProject( projectId, err instanceof Error ? err.message : String(err), ); - // Fall back to the workspace's legacy Coolify project so the user can - // still deploy. Lifecycle isolation is degraded but functionality works. - coolifyUuid = workspace.coolify_project_uuid; + // Do NOT fall back to the workspace project. Sharing it across Vibn + // projects leaks every project's services into every other project + // (the bug this branch was originally written to "soften"). Better + // to leave coolifyProjectUuid null — the UI surfaces an empty state + // and the user can retry once the underlying error is fixed. + coolifyUuid = null; } if (coolifyUuid) {