fix: harden demo-mode guard, availability scope, and image secrets
Pull Request Labeler / labeler (pull_request_target) Has been cancelled
Pull Request Labeler / apply-labels-from-issue (pull_request_target) Has been cancelled
PR Welcome Bot / Welcome new contributors (pull_request_target) Has been cancelled
PR Update / Trust Check (pull_request_target) Has been cancelled
Validate PRs / Validate PR title (pull_request_target) Has been cancelled
cleanup caches by a branch / cleanup (pull_request) Has been cancelled
/ cleanup-report (pull_request) Has been cancelled
PR Update / Prepare (pull_request_target) Has been cancelled
PR Update / Type Checks (pull_request_target) Has been cancelled
PR Update / Linters (pull_request_target) Has been cancelled
PR Update / Security Audit (pull_request_target) Has been cancelled
PR Update / Check Prisma Migrations (pull_request_target) Has been cancelled
PR Update / Setup Database (pull_request_target) Has been cancelled
PR Update / Analyze Build (pull_request_target) Has been cancelled
PR Update / required (pull_request_target) Has been cancelled
PR Update / Production builds (pull_request_target) Has been cancelled
PR Update / Tests (pull_request_target) Has been cancelled

- Move the demo-mode check into a single shared module so the
  NODE_ENV!=production guard cannot drift; service.ts previously had an
  unguarded copy that could serve fixture data / impersonate the organizer
  in production.
- Restrict getAvailability to unlocked team members so an authenticated
  viewer cannot probe free/busy for arbitrary user ids.
- Pass build-time secrets inline on the build RUN instead of persisting them
  in an image ENV layer.
This commit is contained in:
2026-06-14 13:10:52 -06:00
parent 81b8cf8133
commit a461321dbd
4 changed files with 39 additions and 19 deletions
+8 -5
View File
@@ -10,11 +10,10 @@ ARG CALENDSO_ENCRYPTION_KEY=secret
ARG DATABASE_URL=postgresql://calcom:calcom@postgres:5432/calcom
ARG MAX_OLD_SPACE_SIZE=4096
# Only non-secret values persist in the image. The build-time secrets are passed
# inline on the build RUN below so they never bake into an image layer; real
# runtime values are injected by compose.
ENV NODE_ENV=production \
NEXTAUTH_SECRET=${NEXTAUTH_SECRET} \
CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY} \
DATABASE_URL=${DATABASE_URL} \
DATABASE_DIRECT_URL=${DATABASE_URL} \
NODE_OPTIONS=--max-old-space-size=${MAX_OLD_SPACE_SIZE}
COPY package.json yarn.lock .yarnrc.yml turbo.json i18n.json ./
@@ -24,7 +23,11 @@ COPY packages ./packages
RUN yarn config set httpTimeout 1200000
RUN yarn install
RUN yarn workspace @vynte/scheduler build
RUN NEXTAUTH_SECRET=${NEXTAUTH_SECRET} \
CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY} \
DATABASE_URL=${DATABASE_URL} \
DATABASE_DIRECT_URL=${DATABASE_URL} \
yarn workspace @vynte/scheduler build
EXPOSE 3040
+1 -8
View File
@@ -3,19 +3,12 @@ import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { isDemoMode } from "./demo-mode";
import { buildLegacyRequest } from "./legacy-request";
/** Viewer id used for the demo team's organizer when SCHEDULER_DEMO_MODE=1. */
const DEMO_VIEWER_ID = 1;
/**
* Demo mode bypasses Authentik and impersonates the organizer, so it must never
* be honored in production even if the env var is accidentally left set.
*/
function isDemoMode(): boolean {
return process.env.SCHEDULER_DEMO_MODE === "1" && process.env.NODE_ENV !== "production";
}
export async function getSchedulerSession(request: NextRequest) {
return getServerSession({ req: buildLegacyRequest(request) });
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Demo mode swaps real Authentik auth and DB reads for in-memory fixtures.
* It impersonates the organizer, so it must NEVER be honored in production even
* if SCHEDULER_DEMO_MODE is accidentally left set. This is the single source of
* truth for that guard — both the auth layer and the service layer import it so
* the production check can never drift between them.
*/
export function isDemoMode(): boolean {
return process.env.SCHEDULER_DEMO_MODE === "1" && process.env.NODE_ENV !== "production";
}
+20 -6
View File
@@ -9,6 +9,7 @@ import {
demoUsers,
type DemoConnection,
} from "./demo-data";
import { isDemoMode } from "./demo-mode";
import { filterBusyBlocksForViewer } from "./privacy";
import type {
BusyBlock,
@@ -22,10 +23,6 @@ import { z } from "zod";
const DEFAULT_TIME_ZONE = "America/Denver";
function isDemoMode(): boolean {
return process.env.SCHEDULER_DEMO_MODE === "1";
}
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
@@ -396,9 +393,13 @@ export async function getAvailability(
return { busy: filterBusyBlocksForViewer(relevant, viewerId), mutualSlots };
}
// Only expose free/busy for actual teammates (single-team = unlocked users),
// so an authenticated viewer cannot probe arbitrary user ids.
const allowedIds = await teamMemberIds(attendeeIds);
const [busyBlocks, schedules] = await Promise.all([
bookingsToBusyBlocks(attendeeIds, rangeStart, rangeEnd),
loadSchedules(attendeeIds),
bookingsToBusyBlocks(allowedIds, rangeStart, rangeEnd),
loadSchedules(allowedIds),
]);
const mutualSlots = computeMutualSlots({
@@ -412,6 +413,19 @@ export async function getAvailability(
return { busy: filterBusyBlocksForViewer(busyBlocks, viewerId), mutualSlots };
}
/** Intersects requested ids with the single team (unlocked users), preserving order. */
async function teamMemberIds(requestedIds: number[]): Promise<number[]> {
if (requestedIds.length === 0) {
return [];
}
const members = await prisma.user.findMany({
where: { id: { in: requestedIds }, locked: false },
select: { id: true },
});
const allowed = new Set(members.map((member) => member.id));
return requestedIds.filter((id) => allowed.has(id));
}
// ---------------------------------------------------------------------------
// Meetings
// ---------------------------------------------------------------------------