Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a78b1be
Merge branch 'calcom:main' into dev
JOY Sep 8, 2026
7782de6
chore(deps): register @calcom/crovecrm workspace in yarn.lock
JOY Sep 8, 2026
2e7df3e
fix(auth): remove hardcoded DOS.Me OIDC credentials and re-anchor JWT…
JOY Sep 8, 2026
82397d2
fix(webhooks): require HMAC signatures and honest delivery reporting …
JOY Sep 8, 2026
f77912e
fix(workflows): enforce team membership and re-add dropped workflow t…
JOY Sep 8, 2026
8bb6826
fix(teams-orgs): enforce membership, role hierarchy and consent on te…
JOY Sep 8, 2026
7dbe386
fix(mcp-server): remove unsafe ownership fallbacks and enable type ch…
JOY Sep 8, 2026
45a4a5a
fix(upstream-sync): restore platform-types permission type and drop u…
JOY Sep 8, 2026
47a4804
fix(i18n): add missing teams listing strings
JOY Sep 8, 2026
4adef44
docs(audit): add full-repo audit report with prioritized findings
JOY Sep 8, 2026
d8eba1a
fix(security): resolve dependabot alerts — bump axios/hono/vite/proto…
JOY Sep 8, 2026
b2b693f
fix(hygiene): stop leaking DB errors from /api/health, declare crovec…
JOY Sep 8, 2026
fe0c70c
docs(audit): add fix-status section after Round 1 and Dependabot pass
JOY Sep 9, 2026
e389330
fix(db): make Postgres TLS verification opt-out instead of always dis…
JOY Sep 9, 2026
f9cefd9
fix(webhooks): require authentication on webhook monitoring endpoints
JOY Sep 9, 2026
c0418d8
docs(audit): mark HI-08 and MD-01 as fixed
JOY Sep 9, 2026
a2f7c73
docs(changelog): release 2.3.0 — security hardening, upstream sync fi…
JOY Sep 10, 2026
d2f8006
fix(deps): resolve dependabot critical and high alerts — bump axios/h…
Sep 10, 2026
4234549
chore(git): untrack generated files (MD-02)
Sep 10, 2026
33c804a
fix(security): SSRF guard on self-hosted, fail-closed video tokens, t…
Sep 10, 2026
a81ee2e
fix(trpc): rate limit sensitive auth mutations and add security headers
Sep 10, 2026
d78cc95
fix(mcp-server): scope every tool to the calling user and stop corrup…
Sep 10, 2026
8d8c7ac
fix(cron): authenticate every cron endpoint with a timing-safe bearer…
Sep 10, 2026
8fedf44
fix(workflows): recheck bookings before scheduling and cancel stale r…
Sep 10, 2026
d6ff1f9
docs(audit): Round 3 security fixes complete — mark HI-06/08/11, HI-2…
Sep 10, 2026
a058d89
refactor(features): remove dead barrel files and import services dire…
Sep 10, 2026
c5ec2d0
fix(docker): refuse boot on default secrets, disable Prisma Studio, s…
Sep 10, 2026
efb232e
perf(db): index DOS tenant lookups and bound the scheduled-webhook drain
Sep 10, 2026
aa68fd3
fix(trpc): implement PBAC permission check and restore report status …
Sep 10, 2026
341cc71
refactor(webhooks): rename DOS sync secret to the product-prefixed co…
Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,11 @@ GOOGLE_ADS_ENABLED=1 # To enable Google Ads tracking (gclid)
LINKEDIN_ADS_ENABLED=1 # To enable LinkedIn Ads tracking (li_fat_id)

# DOS Ecosystem Webhook Sync Secret
# Per-product signing key for the dos-org-sync webhook. GCP Secret Manager name:
# CROVE_CAL_DOS_WEBHOOK_SECRET (mirrors CROVE_SIGN_DOS_WEBHOOK_SECRET for Crove Sign).
# Do NOT reuse the OAuth client secret here.
CROVE_CAL_DOS_WEBHOOK_SECRET=
# Legacy unprefixed name, still accepted as a fallback:
DOS_SYNC_WEBHOOK_SECRET=

# Crove CRM Integration (crm.crove.com)
Expand Down
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
patch-and-minor:
update-types:
- "patch"
- "minor"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
5 changes: 4 additions & 1 deletion .github/workflows/deploy-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ jobs:
${{ steps.image-name.outputs.image_name }}:latest
${{ steps.image-name.outputs.image_name }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Why: mode=max exports every build stage — including the builder stage
# whose ENV carries DATABASE_URL and NEXTAUTH_SECRET defaults — into the
# shared Actions cache. mode=min exports only the final image layers.
cache-to: type=gha,mode=min
build-args: |
NEXT_PUBLIC_WEBAPP_URL=https://cal.crove.com
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ packages/prisma/generated/prisma/*
packages/prisma/zod/**/*.ts
packages/prisma/enums

# generated by app-store-cli (MD-02)
packages/app-store/*.generated.ts
packages/app-store/*.generated.tsx

# Builds
dist
packages/app-store/types
Expand Down
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ if [ -f .git/MERGE_HEAD ]; then
fi

yarn lint-staged --verbose
yarn app-store:build && git add packages/app-store/*.generated.*
yarn app-store:build
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ All notable changes to the **Crove Cal** platform will be documented in this fil

---

## [2.3.0] - 2026-09-08

### Security
- **Upstream Sync (cal.diy main, 7 fixes)**: HitPay/PayPal zero-decimal currency charging (VND/JPY/KRW were billed at 1% of price), `tempOrgRedirect` writes inside the `updateUser` transaction, CalVideoSettings API defaults, i18n (ja/pl), removal of unused `TokenHandler`.
- **Authentication**: removed hardcoded DOS.Me OIDC client id/secret fallbacks; `dos-id` provider registers only when OIDC credentials are configured; JWT `update` callback re-anchors identity to `token.sub` instead of client-supplied `session.email`; JIT provisioning no longer adopts organizations by slug and caps claim-derived roles at MEMBER.
- **Webhooks**: `brevo` and `crove-crm` routes require HMAC-SHA256 signatures (`timingSafeEqual`, fail-closed) with a 50-attendee cap; `dos-org-sync` requires a dedicated `DOS_SYNC_WEBHOOK_SECRET`, rejects stale timestamps and replayed delivery ids, and scopes `team.deleted` to the parent organization; `/api/webhooks/health` requires a session (POST requires ADMIN); `/api/health` no longer echoes raw database errors.
- **Authorization**: Teams/Organizations endpoints enforce accepted membership (member rosters with email addresses no longer leak to non-members); ADMIN can no longer evict the OWNER or self-grant OWNER; last-OWNER protection; `inviteMember` records a real verification token and answers with an opaque status; Workflows enforce team membership and scope mutations to the owner.
- **Database**: TLS certificate verification defaults to on (`DATABASE_SSL_REJECT_UNAUTHORIZED=false` opts out); fixed a pg pool leak in the api/v2 non-pool branch; added migration recreating the Workflow tables dropped by upstream `20260319000000_drop_workflow_tables` (fixes `P2021` and the `/workflows` 500).
- **Dependencies (Dependabot)**: bumped `next` 16.2.11, `next-auth` 4.24.15, `tar` 7.5.21, `websocket-driver` 0.7.5, `axios` 1.16.0, `hono` 4.12.25, `vite` 6.4.3, `protobufjs` 7.5.6, `@xmldom/xmldom`, `brace-expansion`; added `.github/dependabot.yml`.

### Fixed
- Cross-user cache leak on `/event-types/[type]` (`unstable_cache` keyed on headers/cookies objects that serialize to constants, so every user shared one entry and the router authorization never ran).
- `turbo.json` `post-install` ordering (`dependsOn: ["^post-install"]`) with upstream `permissions.ts` restored — fixes the intermittent TS2305 `PLATFORM_PERMISSION` failure during `yarn install`.
- `/teams` renders real strings instead of raw i18n keys (`create_a_team`, `no_teams_yet`, `team_created_successfully`, `create_team_description`).
- MCP server: removed the "first available user" ownership fallback and the empty-`OR` schedule lookup that returned another tenant's schedule; added a `type-check` script (fixed 6 latent Prisma typing errors).
- `crovecrm` declares its workspace dependencies; husky install failures are no longer silently swallowed; `return` added before `redirect()` in two pages; webhook monitor shared across processes with honest delivery reporting.

### Docs
- Added `docs/audit/2026-09-08-audit-report.html` — full-repo audit report (76 findings with IDs, priorities, fix status table).

---

## [2.2.0] - 2026-09-03

### Added
Expand Down
2 changes: 1 addition & 1 deletion apps/api/v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"@sentry/node": "9.46.0",
"@sentry/profiling-node": "9.46.0",
"@snyk/protect": "latest",
"axios": "1.15.0",
"axios": "1.16.0",
"body-parser": "1.20.3",
"bull": "4.15.1",
"class-transformer": "0.5.1",
Expand Down
24 changes: 5 additions & 19 deletions apps/api/v2/src/modules/prisma/prisma-read.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getSchemaFromUrl, resolveDatabaseSsl } from "@calcom/prisma";
import { PrismaClient } from "@calcom/prisma/client";
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
Expand All @@ -6,17 +7,6 @@ import { Pool } from "pg";

const DB_MAX_POOL_CONNECTION = 10;

function getSchemaFromUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url.replace(/^postgresql:\/\//, "http://").replace(/^postgres:\/\//, "http://"));
return parsed.searchParams.get("schema") || undefined;
} catch {
const match = url.match(/[?&]schema=([^&]+)/);
return match ? match[1] : undefined;
}
}

@Injectable()
export class PrismaReadService implements OnModuleInit, OnModuleDestroy {
private logger = new Logger("PrismaReadService");
Expand Down Expand Up @@ -63,23 +53,19 @@ export class PrismaReadService implements OnModuleInit, OnModuleDestroy {
connectionString: dbUrl,
max: maxReadConnections,
idleTimeoutMillis: 300000,
ssl: {
rejectUnauthorized: false,
},
ssl: resolveDatabaseSsl(),
});

const adapter = new PrismaPg(this.pool, adapterOptions);
this.prisma = new PrismaClient({ adapter });
} else {
const adapterPool = new Pool({
this.pool = new Pool({
connectionString: dbUrl,
max: 5,
idleTimeoutMillis: 300000,
ssl: {
rejectUnauthorized: false,
},
ssl: resolveDatabaseSsl(),
});
const adapter = new PrismaPg(adapterPool, adapterOptions);
const adapter = new PrismaPg(this.pool, adapterOptions);
this.prisma = new PrismaClient({
adapter,
});
Expand Down
24 changes: 5 additions & 19 deletions apps/api/v2/src/modules/prisma/prisma-write.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getSchemaFromUrl, resolveDatabaseSsl } from "@calcom/prisma";
import { PrismaClient } from "@calcom/prisma/client";
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
Expand All @@ -14,17 +15,6 @@ export interface PrismaServiceOptions {
type: "main" | "worker";
}

function getSchemaFromUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url.replace(/^postgresql:\/\//, "http://").replace(/^postgres:\/\//, "http://"));
return parsed.searchParams.get("schema") || undefined;
} catch {
const match = url.match(/[?&]schema=([^&]+)/);
return match ? match[1] : undefined;
}
}

@Injectable()
export class PrismaWriteService implements OnModuleInit, OnModuleDestroy {
private logger = new Logger("PrismaWriteService");
Expand Down Expand Up @@ -69,23 +59,19 @@ export class PrismaWriteService implements OnModuleInit, OnModuleDestroy {
connectionString: dbUrl,
max: maxWriteConnections,
idleTimeoutMillis: 300000,
ssl: {
rejectUnauthorized: false,
},
ssl: resolveDatabaseSsl(),
});

const adapter = new PrismaPg(this.pool, adapterOptions);
this.prisma = new PrismaClient({ adapter });
} else {
const adapterPool = new Pool({
this.pool = new Pool({
connectionString: dbUrl,
max: 5,
idleTimeoutMillis: 300000,
ssl: {
rejectUnauthorized: false,
},
ssl: resolveDatabaseSsl(),
});
const adapter = new PrismaPg(adapterPool, adapterOptions);
const adapter = new PrismaPg(this.pool, adapterOptions);
this.prisma = new PrismaClient({
adapter,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const generateMetadata = async () =>
const WorkflowsPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
return redirect("/auth/login");
}

return <WorkflowsListingView />;
Expand Down
15 changes: 3 additions & 12 deletions apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/
import { EventTypeWebWrapper } from "@calcom/web/modules/event-types/components/EventTypeWebWrapper";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
Expand All @@ -29,15 +28,6 @@ export const generateMetadata = async () => {
);
};

const getCachedEventType = unstable_cache(
async (eventTypeId: number, headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(headers, cookies));
return await caller.get({ id: eventTypeId });
},
["viewer.eventTypes.get"],
{ revalidate: 3600 } // Cache for 1 hour
);

const ServerPage = async ({ params }: PageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
Expand All @@ -52,7 +42,8 @@ const ServerPage = async ({ params }: PageProps) => {
const _headers = await headers();
const _cookies = await cookies();

const data = await getCachedEventType(eventTypeId, _headers, _cookies);
const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(_headers, _cookies));
const data = await caller.get({ id: eventTypeId });
if (!data?.eventType) {
throw new Error("This event type does not exist");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const generateMetadata = async () =>
const WebhookMonitoringPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
return redirect("/auth/login");
}

return <WebhookMonitoringDashboard />;
Expand Down
17 changes: 8 additions & 9 deletions apps/web/app/api/cron/bookingReminder/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

import dayjs from "@calcom/dayjs";
import { sendOrganizerRequestReminderEmail } from "@calcom/emails/email-manager";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { getTranslation } from "@calcom/i18n/server";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { getTranslation } from "@calcom/i18n/server";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { assertCronSecret } from "@lib/cronAuth";
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

async function postHandler(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");

if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
const unauthorized = assertCronSecret(request);
if (unauthorized) {
return unauthorized;
}

const reminderIntervalMinutes = [48 * 60, 24 * 60, 3 * 60];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NextRequest } from "next/server";
import { describe, test, expect, vi, beforeEach } from "vitest";

import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";

vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {
Expand Down Expand Up @@ -78,27 +77,27 @@ describe("/api/cron/calendar-subscriptions-cleanup", () => {
});

describe("Authentication", () => {
test("should return 403 when no API key is provided", async () => {
test("should return 401 when no API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");

const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });

expect(response.status).toBe(403);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.message).toBe("Forbidden");
expect(body.message).toBe("Not authenticated");
});

test("should return 403 when invalid API key is provided", async () => {
test("should return 401 when invalid API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "invalid-key");

const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });

expect(response.status).toBe(403);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.message).toBe("Forbidden");
expect(body.message).toBe("Not authenticated");
});

test("should accept CRON_API_KEY in authorization header", async () => {
Expand Down
13 changes: 6 additions & 7 deletions apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
import { assertCronSecret } from "@lib/cronAuth";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

/**
* Cron webhook
Expand All @@ -14,10 +14,9 @@ import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderF
* @returns
*/
async function getHandler(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");

if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
const unauthorized = assertCronSecret(request);
if (unauthorized) {
return unauthorized;
}

// instantiate dependencies
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NextRequest } from "next/server";
import { describe, test, expect, vi, beforeEach } from "vitest";

import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";

vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {
Expand Down Expand Up @@ -51,27 +50,27 @@ describe("/api/cron/calendar-subscriptions", () => {
});

describe("Authentication", () => {
test("should return 403 when no API key is provided", async () => {
test("should return 401 when no API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");

const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });

expect(response.status).toBe(403);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.message).toBe("Forbiden");
expect(body.message).toBe("Not authenticated");
}, 10000);

test("should return 403 when invalid API key is provided", async () => {
test("should return 401 when invalid API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "invalid-key");

const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });

expect(response.status).toBe(403);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.message).toBe("Forbiden");
expect(body.message).toBe("Not authenticated");
});

test("should accept valid API key", async () => {
Expand Down
Loading
Loading