From f4703b65dbf9541c28bece6641894ef40bb23e9c Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:14:42 +0530 Subject: [PATCH 1/3] fix(auth): block billing-role members from workspace product data [ENG-1763] (#8592) --- apps/web/modules/workspaces/lib/utils.test.ts | 87 +++++++++++++++++++ apps/web/modules/workspaces/lib/utils.ts | 11 +++ .../playwright/billing-role-access.spec.ts | 76 ++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 apps/web/modules/workspaces/lib/utils.test.ts create mode 100644 apps/web/playwright/billing-role-access.spec.ts diff --git a/apps/web/modules/workspaces/lib/utils.test.ts b/apps/web/modules/workspaces/lib/utils.test.ts new file mode 100644 index 000000000000..2e566e67275c --- /dev/null +++ b/apps/web/modules/workspaces/lib/utils.test.ts @@ -0,0 +1,87 @@ +import { redirect } from "next/navigation"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TMembership, TOrganizationRole } from "@formbricks/types/memberships"; +import { getBillingFallbackPath } from "@/lib/membership/navigation"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getOrganization } from "@/lib/organization/service"; +import { getWorkspace } from "@/lib/workspace/service"; +import { getSession } from "@/modules/auth/lib/session"; +import { getWorkspacePermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getWorkspaceAuth } from "./utils"; + +const mocks = vi.hoisted(() => ({ isFormbricksCloud: false })); + +// Real getAccessFlags is used on purpose so the tests exercise the actual role -> isBilling mapping +// that the redirect branch keys off of. Everything else getWorkspaceAuth touches is stubbed. +vi.mock("react", () => ({ cache: (fn: (...args: unknown[]) => unknown) => fn })); +vi.mock("@/lib/constants", async (importOriginal) => ({ + ...(await importOriginal()), + IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud, +})); +vi.mock("@/lib/workspace/service", () => ({ getWorkspace: vi.fn() })); +vi.mock("@/lib/organization/service", () => ({ getOrganization: vi.fn() })); +vi.mock("@/lib/membership/service", () => ({ getMembershipByUserIdOrganizationId: vi.fn() })); +vi.mock("@/lib/membership/navigation", () => ({ getBillingFallbackPath: vi.fn() })); +vi.mock("@/lingodotdev/server", () => ({ getTranslate: vi.fn(() => Promise.resolve((k: string) => k)) })); +vi.mock("@/modules/auth/lib/session", () => ({ getSession: vi.fn() })); +vi.mock("@/modules/ee/teams/lib/roles", () => ({ getWorkspacePermissionByUserId: vi.fn() })); +vi.mock("@/modules/ee/teams/utils/teams", () => ({ + getTeamPermissionFlags: vi.fn(() => ({ + hasReadAccess: false, + hasReadWriteAccess: false, + hasManageAccess: false, + })), +})); + +const workspaceId = "workspace-1"; +const organizationId = "organization-1"; +const billingFallbackPath = `/organizations/${organizationId}/settings/enterprise`; + +const primeAuth = (role: TOrganizationRole) => { + vi.mocked(getWorkspace).mockResolvedValue({ id: workspaceId, organizationId } as Awaited< + ReturnType + >); + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1" }, + expires: new Date(0).toISOString(), + } as Awaited>); + vi.mocked(getOrganization).mockResolvedValue({ id: organizationId } as Awaited< + ReturnType + >); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ + role, + organizationId, + userId: "user-1", + accepted: true, + } as TMembership); + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null); + vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath); +}; + +describe("getWorkspaceAuth billing gate (ENG-1763)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("redirects a billing-role member to the billing fallback path", async () => { + primeAuth("billing"); + + await getWorkspaceAuth(workspaceId); + + expect(getBillingFallbackPath).toHaveBeenCalledWith(organizationId, mocks.isFormbricksCloud); + expect(redirect).toHaveBeenCalledWith(billingFallbackPath); + }); + + test.each(["owner", "manager", "member"])( + "does not redirect a %s member", + async (role) => { + primeAuth(role); + + const result = await getWorkspaceAuth(workspaceId); + + expect(redirect).not.toHaveBeenCalled(); + expect(getBillingFallbackPath).not.toHaveBeenCalled(); + expect(result.isBilling).toBe(false); + } + ); +}); diff --git a/apps/web/modules/workspaces/lib/utils.ts b/apps/web/modules/workspaces/lib/utils.ts index 2f1e1e760832..abaffe2c2432 100644 --- a/apps/web/modules/workspaces/lib/utils.ts +++ b/apps/web/modules/workspaces/lib/utils.ts @@ -1,3 +1,4 @@ +import { redirect } from "next/navigation"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; @@ -10,6 +11,7 @@ import { ResourceNotFoundError, } from "@formbricks/types/errors"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getBillingFallbackPath } from "@/lib/membership/navigation"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getAccessFlags } from "@/lib/membership/utils"; import { getMonthlyOrganizationResponseCount, getOrganization } from "@/lib/organization/service"; @@ -56,6 +58,15 @@ export const getWorkspaceAuth = reactCache(async (workspaceId: string): Promise< const { isMember, isOwner, isManager, isBilling } = getAccessFlags(currentUserMembership.role); + // Billing-role members are scoped to billing/enterprise screens only. They must never reach + // workspace product data (contacts PII, survey summaries/responses, dashboards). This is the + // single choke point every product page flows through, so gating here closes all of them at + // once and keeps this helper aligned with hasUserWorkspaceAccessForAction, which already denies + // billing. Individual pages that also guard billing inline remain correct (defense in depth). + if (isBilling) { + redirect(getBillingFallbackPath(organization.id, IS_FORMBRICKS_CLOUD)); + } + const workspacePermission = await getWorkspacePermissionByUserId(session.user.id, workspace.id); const { hasReadAccess, hasReadWriteAccess, hasManageAccess } = getTeamPermissionFlags(workspacePermission); diff --git a/apps/web/playwright/billing-role-access.spec.ts b/apps/web/playwright/billing-role-access.spec.ts new file mode 100644 index 000000000000..822687295d44 --- /dev/null +++ b/apps/web/playwright/billing-role-access.spec.ts @@ -0,0 +1,76 @@ +import { expect } from "@playwright/test"; +import { prisma } from "@formbricks/database"; +import type { UsersFixture } from "./fixtures/users"; +import { test } from "./lib/fixtures"; + +// ENG-1763 regression: a billing-role member must not reach workspace product data (contacts PII, +// survey summaries/responses, dashboards) by direct navigation. getWorkspaceAuth is the single choke +// point every product page flows through, so it bounces billing users to their org billing/enterprise +// home instead. The owner test guards against a naive "redirect everyone" fix regressing product +// access for legitimate roles. + +// Creates an organization (owned by `owner`) with a workspace holding product data, plus a second +// user who is a billing-role member of that same organization. +const setupOrgWithBillingMember = async (users: UsersFixture) => { + const owner = await users.create(); + if (!owner.organizationId || !owner.workspaceId) { + throw new Error("Owner org/workspace not seeded for test"); + } + + const billingUser = await users.create({ withoutWorkspace: true }); + await prisma.membership.create({ + data: { + userId: billingUser.id, + organizationId: owner.organizationId, + role: "billing", + accepted: true, + }, + }); + + // users.create seeds a survey in the workspace; it backs the survey summary/responses URLs below. + const survey = await prisma.survey.findFirstOrThrow({ + where: { workspaceId: owner.workspaceId }, + select: { id: true }, + }); + + const workspaceBase = `/workspaces/${owner.workspaceId}`; + return { + owner, + billingUser, + workspaceId: owner.workspaceId, + contactsUrl: `${workspaceBase}/contacts`, + // Every workspace product surface flows through getWorkspaceAuth, so all of them must redirect. + productUrls: [ + `${workspaceBase}/contacts`, + `${workspaceBase}/dashboards`, + `${workspaceBase}/surveys/${survey.id}/summary`, + `${workspaceBase}/surveys/${survey.id}/responses`, + ], + }; +}; + +test.describe("Billing-role workspace access (ENG-1763)", () => { + const billingHomeUrl = /\/organizations\/[^/]+\/settings\/(billing|enterprise)/; + + test("redirects a billing member off product pages to the billing home", async ({ page, users }) => { + const { billingUser, productUrls } = await setupOrgWithBillingMember(users); + + await billingUser.login(); + + for (const url of productUrls) { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await expect(page, `billing member should be redirected off ${url}`).toHaveURL(billingHomeUrl); + } + }); + + test("keeps product-page access for a non-billing owner", async ({ page, users }) => { + const { owner, workspaceId, contactsUrl } = await setupOrgWithBillingMember(users); + + await owner.login(); + await page.goto(contactsUrl, { waitUntil: "domcontentloaded" }); + + // Owner stays on the product route (the page may render an upgrade prompt when the enterprise + // feature is unlicensed, but crucially it is not bounced to the billing home). + await expect(page).toHaveURL(new RegExp(`/workspaces/${workspaceId}/contacts`)); + }); +}); From cb6a2027d9f574c98cd156670ce65e095a08679a Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:44:45 +0530 Subject: [PATCH 2/3] docs(self-hosting): add v5.2 migration guide (#8593) --- docs/self-hosting/advanced/migration.mdx | 125 +++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/docs/self-hosting/advanced/migration.mdx b/docs/self-hosting/advanced/migration.mdx index 6fa913e08ece..d229b5e58f5f 100644 --- a/docs/self-hosting/advanced/migration.mdx +++ b/docs/self-hosting/advanced/migration.mdx @@ -4,6 +4,131 @@ description: "Formbricks Self-hosted version migration" icon: "arrow-right" --- +## v5.2 + +Formbricks v5.2 migrates authentication from NextAuth to **Better Auth**. For most self-hosted +instances this is a drop-in upgrade, but **if you use SSO (Azure AD / Entra ID, generic OIDC, or SAML) +you must re-register the OAuth callback URL at your identity provider before the first v5.2 restart**, +or SSO sign-in will fail with a redirect-URI mismatch. + + + All users are signed out once when you upgrade to v5.2. Better Auth takes over session handling and + there is no dual-read from the old NextAuth sessions, so the cutover is a one-time forced re-login. + Passwords are preserved — existing credential hashes are migrated automatically, so no password reset + is needed. + + +### SSO Callback URL Change (action required for SSO users) + +Better Auth serves the OAuth2/OIDC callback at a new path: + +- old (NextAuth): `/api/auth/callback/{provider}` +- new (Better Auth): `/api/auth/oauth2/callback/{providerId}` + +Update the redirect / callback URIs at your identity provider to the new path. Replace `https://your-domain.com` +with your instance URL: + +| Provider | Where to update | New callback URL | +| --- | --- | --- | +| Azure AD / Entra ID | Azure portal -> App registration -> Authentication -> Redirect URIs | `https://your-domain.com/api/auth/oauth2/callback/azuread` | +| Generic OIDC | At your OIDC provider | `https://your-domain.com/api/auth/oauth2/callback/openid` | +| SAML (BoxyHQ / Jackson) | The Jackson connection's `redirect_uri` | `https://your-domain.com/api/auth/oauth2/callback/saml` | + + + **Google and GitHub sign-in do not change.** They use Better Auth's built-in social providers, whose + callback path (`/api/auth/callback/{provider}`) is unchanged — nothing to do there. + + For SAML, only the Jackson connection's `redirect_uri` changes. The IdP-side ACS endpoint + (`/api/auth/saml/callback`) is unchanged, so you do **not** need to reconfigure your IdP's SAML app. + + Leave the old `/api/auth/callback/*` redirect URIs registered through the transition. Keeping them in + place is harmless and makes a rollback to v5.1 clean. + + +### Authentication Environment Variables + +Better Auth introduces two optional variables. **Neither is required** for a standard upgrade — both fall +back to your existing NextAuth configuration: + +- `BETTER_AUTH_SECRET` — cookie/session signing secret. Falls back to `NEXTAUTH_SECRET` when unset. If you + set it explicitly it must be at least 32 characters. Keeping the `NEXTAUTH_SECRET` fallback is + recommended so the value the forward-auth proxy already verifies with stays consistent. +- `BETTER_AUTH_URL` — the auth base URL. Falls back to `NEXTAUTH_URL` (and then `WEBAPP_URL`) when unset. + + + If your instance already sets `NEXTAUTH_SECRET` and `NEXTAUTH_URL` (all v5.1 instances do), you can + upgrade without adding any new auth variables. + + +**Removed:** `DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION` no longer exists. Remove it from your environment +if you set it; it now has no effect. + +### Optional New Configuration + +None of the following is required to run v5.2. Add them only if you want the associated feature. + +#### Hub Enrichment (sentiment, emotion, translation) + +Hub `>= 0.8.0` can classify sentiment and emotion and translate feedback. These are **off** unless you set +a provider and model, and they run in both the Hub API and the hub-worker, so set the variables on **both** +services. Bump your Hub image to `>= 0.8.0` (via `HUB_IMAGE_REF`) to pick up these features. + +- `SENTIMENT_PROVIDER`, `SENTIMENT_MODEL`, `SENTIMENT_PROVIDER_API_KEY` +- `EMOTIONS_PROVIDER`, `EMOTIONS_MODEL`, `EMOTIONS_PROVIDER_API_KEY` +- `TRANSLATION_PROVIDER`, `TRANSLATION_MODEL`, `TRANSLATION_PROVIDER_API_KEY`, `TRANSLATION_DEFAULT_LANGUAGE` + +Supported providers: `openai`, `google` (AI Studio, needs `*_PROVIDER_API_KEY`), and `google-gemini` +(Vertex, needs `*_GOOGLE_CLOUD_PROJECT` / `*_LOCATION` plus Application Default Credentials). + + + A provider set **without** its required credentials crash-loops both Hub containers on startup — it does + not degrade gracefully. Set the credentials, or leave the provider unset. + + +#### AI Taxonomy (beta) + +An optional standalone taxonomy service, enabled in Docker Compose with `COMPOSE_PROFILES=taxonomy`. It +shares an internal token with Hub: + +- `TAXONOMY_SERVICE_URL` (the internal URL to the taxonomy service) +- `HUB_INTERNAL_API_TOKEN`, `TAXONOMY_SERVICE_TOKEN` (strong random secrets) +- `TAXONOMY_LLM_PROVIDER` / `TAXONOMY_LLM_MODEL` / `TAXONOMY_LLM_BASE_URL` / `TAXONOMY_LLM_API_KEY` + (`openai-compatible` by default; `bedrock` and `vertex-gemini` also supported) +- `TAXONOMY_IMAGE_REF` to pin a released image (e.g. `:v0.1.0`) + +Use `COMPOSE_PROFILES=qwen,taxonomy` to back it with the bundled Qwen/vLLM service. + +#### Bundled Qwen / vLLM Runtime + +Docker Compose can now run a bundled OpenAI-compatible LLM for self-hosted AI features with +`COMPOSE_PROFILES=qwen`. Tunable via `QWEN_VLLM_IMAGE`, `QWEN_MODEL_ID`, `QWEN_SERVED_MODEL_NAME`, +`QWEN_MAX_MODEL_LEN`, `QWEN_MAX_NUM_SEQS`, `QWEN_GPU_MEMORY_UTILIZATION`, `QWEN_VLLM_HOST`, and +`QWEN_VLLM_PORT`. + +#### OpenTelemetry Log Export + +- `OTEL_LOGS_ENABLED=1` — export Pino application logs via OTLP (off by default; also needs + `OTEL_EXPORTER_OTLP_ENDPOINT`). + +### Upgrade Steps + +v5.2 requires no manual database migration step beyond the standard automatic Prisma migrations on startup +(the credential-account backfill runs automatically). The Helm chart is unchanged from v5.1. + +1. **If you use SSO**, add the new `/api/auth/oauth2/callback/{providerId}` redirect URIs at your IdP (see + the table above), keeping the old ones in place. +2. Back up your database. +3. Pull the v5.2 images and restart (`docker compose pull && docker compose down && docker compose up -d`, + or `helm upgrade` with the new tag). +4. After startup, sign in again (the one-time forced re-login) and verify each configured SSO provider + completes sign-in. + +### Rollback + +Because the old `/api/auth/callback/*` redirect URIs are left in place, rolling back to v5.1 is clean: +revert your image tags / Helm values, and the previous NextAuth callback path works again. Restore the +database backup only if you need to undo schema changes. + ## v5 Formbricks v5 changes the self-hosted runtime contract. If you are upgrading an existing Formbricks 4.x From bab631f1c26ef0ee90419e487eb171b0f8599920 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:36:40 +0530 Subject: [PATCH 3/3] fix: bump tar to 7.5.19 to patch decompression DoS (ENG-1947) (#8604) --- pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 5 ++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f96daad2ffbf..158bbc1cf749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,7 +32,7 @@ overrides: esbuild: 0.28.1 undici@7: 7.28.0 form-data@4: 4.0.6 - tar: 7.5.16 + tar: 7.5.19 js-yaml@4: 4.2.0 importers: @@ -11515,8 +11515,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@7.5.16: - resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + tar@7.5.19: + resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} engines: {node: '>=18'} tarn@3.0.2: @@ -19938,7 +19938,7 @@ snapshots: promise-inflight: 1.0.1 rimraf: 3.0.2 ssri: 8.0.1 - tar: 7.5.16 + tar: 7.5.19 unique-filename: 1.1.1 transitivePeerDependencies: - bluebird @@ -22842,7 +22842,7 @@ snapshots: npmlog: 6.0.2 rimraf: 3.0.2 semver: 7.8.0 - tar: 7.5.16 + tar: 7.5.19 which: 2.0.2 transitivePeerDependencies: - bluebird @@ -24521,7 +24521,7 @@ snapshots: bindings: 1.5.0 node-addon-api: 7.1.1 prebuild-install: 7.1.3 - tar: 7.5.16 + tar: 7.5.19 optionalDependencies: node-gyp: 8.4.1 transitivePeerDependencies: @@ -24839,7 +24839,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@7.5.16: + tar@7.5.19: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d11a84e72c3c..fd5593036643 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -71,7 +71,10 @@ overrides: # ENG-1513 / GHSA-fjxv-7rqg-78g4: form-data <4.0.4 uses an unsafe random function for the # multipart boundary. @redocly/respect-core pins 4.0.0; patched in 4.0.4+. form-data@4: 4.0.6 - tar: 7.5.16 + # ENG-1947 / GHSA-23hp-3jrh-7fpw (CVE-2026-59873): node-tar <=7.5.18 has no hard + # upper bound on total decompressed bytes / entry count, so a small gzip bomb can + # exhaust disk and CPU (DoS). Patched in 7.5.19. + tar: 7.5.19 # ENG-1527-batch / GHSA-mh29-5h37-fv8m + GHSA-h67p-54hq-rp68: js-yaml <4.1.2 quadratic-complexity # DoS via merge keys / repeated aliases (dev-only, via @redocly/cli). No 4.1.2 published; 4.2.0 is the # lowest patched and stays <5. Scoped to the 4.x line so any 3.x consumer is untouched.