From d313ae3dede864f0bb7940ddbe377107167e7281 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Wed, 2 Sep 2026 01:55:53 -0600 Subject: [PATCH] Add IS_DEMO read-only demo mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocks every non-query tRPC procedure (mutations and subscriptions), the local/remote terminal websockets, and the unauthenticated deploy webhooks when IS_DEMO=true. Login still works; sign-up and every better-auth endpoint except sign-in/get-session/sign-out is blocked via an allowlist. Also fixes settings.getUpdateData, which was declared as a mutation even though it only reads version info — the demo guard caught it as a false positive, so reclassified it as a query. --- .../settings/web-server/update-server.tsx | 9 ++++-- .../components/layouts/update-server.tsx | 5 ++-- .../pages/api/deploy/[refreshToken].ts | 5 ++++ .../api/deploy/compose/[refreshToken].ts | 6 +++- apps/dokploy/pages/api/deploy/github.ts | 6 ++++ apps/dokploy/server/api/routers/settings.ts | 2 +- apps/dokploy/server/api/trpc.ts | 29 +++++++++++++++---- .../server/wss/docker-container-terminal.ts | 14 ++++++++- apps/dokploy/server/wss/terminal.ts | 7 +++++ packages/server/src/constants/index.ts | 5 ++++ packages/server/src/lib/auth.ts | 22 +++++++++++++- 11 files changed, 94 insertions(+), 16 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx index 5c1ce5cbb..bec416d88 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx @@ -45,8 +45,8 @@ export const UpdateServer = ({ const [isUpdateAvailable, setIsUpdateAvailable] = useState( !!updateData?.updateAvailable, ); - const { mutateAsync: getUpdateData, isPending } = - api.settings.getUpdateData.useMutation(); + const utils = api.useUtils(); + const [isPending, setIsPending] = useState(false); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); const { data: releaseTag } = api.settings.getReleaseTag.useQuery(); const [latestVersion, setLatestVersion] = useState( @@ -55,8 +55,9 @@ export const UpdateServer = ({ const [isOpenInternal, setIsOpenInternal] = useState(false); const handleCheckUpdates = async () => { + setIsPending(true); try { - const updateData = await getUpdateData(); + const updateData = await utils.settings.getUpdateData.fetch(); const versionToUpdate = updateData.latestVersion || ""; setHasCheckedUpdate(true); setIsUpdateAvailable(updateData.updateAvailable); @@ -76,6 +77,8 @@ export const UpdateServer = ({ toast.error( "An error occurred while checking for updates, please try again.", ); + } finally { + setIsPending(false); } }; diff --git a/apps/dokploy/components/layouts/update-server.tsx b/apps/dokploy/components/layouts/update-server.tsx index 246f59b2a..ad5292858 100644 --- a/apps/dokploy/components/layouts/update-server.tsx +++ b/apps/dokploy/components/layouts/update-server.tsx @@ -19,8 +19,7 @@ export const UpdateServerButton = () => { updateAvailable: false, }); const { data: isCloud } = api.settings.isCloud.useQuery(); - const { mutateAsync: getUpdateData } = - api.settings.getUpdateData.useMutation(); + const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const checkUpdatesIntervalRef = useRef(null); @@ -48,7 +47,7 @@ export const UpdateServerButton = () => { return; } - const fetchedUpdateData = await getUpdateData(); + const fetchedUpdateData = await utils.settings.getUpdateData.fetch(); if (fetchedUpdateData?.updateAvailable) { // Stop interval when update is available diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index 9cf6142d2..77d079f66 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -2,6 +2,7 @@ import { type Bitbucket, getBitbucketHeaders, IS_CLOUD, + IS_DEMO, shouldDeploy, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -38,6 +39,10 @@ export default async function handler( ) { const { refreshToken } = req.query; try { + if (IS_DEMO) { + res.status(403).json({ message: "This is a read-only demo instance." }); + return; + } if (req.headers["x-github-event"] === "ping") { res.status(200).json({ message: "Ping received, webhook is active" }); return; diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index 0d83c1017..cc91086bb 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -1,4 +1,4 @@ -import { IS_CLOUD, shouldDeploy } from "@dokploy/server"; +import { IS_CLOUD, IS_DEMO, shouldDeploy } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -21,6 +21,10 @@ export default async function handler( ) { const { refreshToken } = req.query; try { + if (IS_DEMO) { + res.status(403).json({ message: "This is a read-only demo instance." }); + return; + } if (req.headers["x-github-event"] === "ping") { res.status(200).json({ message: "Ping received, webhook is active" }); return; diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index 2273ac3bc..3d7cba593 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -6,6 +6,7 @@ import { findPreviewDeploymentByApplicationId, findPreviewDeploymentsByPullRequestId, IS_CLOUD, + IS_DEMO, removePreviewDeployment, shouldDeploy, } from "@dokploy/server"; @@ -30,6 +31,11 @@ export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { + if (IS_DEMO) { + res.status(403).json({ message: "This is a read-only demo instance." }); + return; + } + const signature = req.headers["x-hub-signature-256"]; if (!signature) { res.status(401).json({ message: "Missing signature header" }); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index db2951ae4..2a7197945 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -543,7 +543,7 @@ export const settingsRouter = createTRPCRouter({ }); return true; }), - getUpdateData: protectedProcedure.mutation(async () => { + getUpdateData: protectedProcedure.query(async () => { if (IS_CLOUD) { return DEFAULT_UPDATE_DATA; } diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index d0b43e76f..34062e237 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -9,7 +9,7 @@ // import { getServerAuthSession } from "@/server/auth"; import { db } from "@dokploy/server/db"; -import { hasValidLicense } from "@dokploy/server/index"; +import { hasValidLicense, IS_DEMO } from "@dokploy/server/index"; import type { statements } from "@dokploy/server/lib/access-control"; import { validateRequest } from "@dokploy/server/lib/auth"; import { checkPermission } from "@dokploy/server/services/permission"; @@ -141,6 +141,23 @@ const t = initTRPC */ export const createTRPCRouter = t.router; +/** + * Every procedure below is built on this instead of `t.procedure` directly, so + * demo-mode blocking applies everywhere in one place. It checks `type !== + * "query"` rather than `type === "mutation"` because some real deploy/restore + * actions (e.g. deployWithLogs, restoreBackupWithLogs) are implemented as + * `.subscription()` generators, not `.mutation()`. + */ +const baseProcedure = t.procedure.use(({ type, next }) => { + if (IS_DEMO && type !== "query") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This is a read-only demo instance.", + }); + } + return next(); +}); + /** * Public (unauthenticated) procedure * @@ -148,7 +165,7 @@ export const createTRPCRouter = t.router; * guarantee that a user querying is authorized, but you can still access user session data if they * are logged in. */ -export const publicProcedure = t.procedure; +export const publicProcedure = baseProcedure; /** * Protected (authenticated) procedure @@ -158,7 +175,7 @@ export const publicProcedure = t.procedure; * * @see https://trpc.io/docs/procedures */ -export const protectedProcedure = t.procedure.use(({ ctx, next }) => { +export const protectedProcedure = baseProcedure.use(({ ctx, next }) => { if (!ctx.session || !ctx.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } @@ -172,7 +189,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { }); }); -export const cliProcedure = t.procedure.use(({ ctx, next }) => { +export const cliProcedure = baseProcedure.use(({ ctx, next }) => { if ( !ctx.session || !ctx.user || @@ -190,7 +207,7 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => { }); }); -export const adminProcedure = t.procedure.use(({ ctx, next }) => { +export const adminProcedure = baseProcedure.use(({ ctx, next }) => { if ( !ctx.session || !ctx.user || @@ -213,7 +230,7 @@ export const adminProcedure = t.procedure.use(({ ctx, next }) => { * Does NOT call the license server on every request; full validation (haveValidLicenseKey) * is used in the UI gate and when activating/validating keys. */ -export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => { +export const enterpriseProcedure = baseProcedure.use(async ({ ctx, next }) => { if ( !ctx.session || !ctx.user || diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index 45a3a4d34..a7a8d7ba9 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -1,5 +1,10 @@ import type http from "node:http"; -import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; +import { + findServerById, + IS_CLOUD, + IS_DEMO, + validateRequest, +} from "@dokploy/server"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; @@ -74,6 +79,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( ws.close(4003, "Not authorized"); return; } + + if (IS_DEMO) { + ws.send("Terminal access is disabled on this read-only demo instance."); + ws.close(); + return; + } + try { if (serverId) { const server = await findServerById(serverId); diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index ed9495aeb..f8dbde209 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -3,6 +3,7 @@ import { execAsync, findServerById, IS_CLOUD, + IS_DEMO, validateRequest, } from "@dokploy/server"; import { publicIpv4, publicIpv6 } from "public-ip"; @@ -107,6 +108,12 @@ export const setupTerminalWebSocketServer = ( return; } + if (IS_DEMO) { + ws.send("Terminal access is disabled on this read-only demo instance."); + ws.close(); + return; + } + let connectionDetails: ConnectConfig = {}; const isLocalServer = serverId === "local"; diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts index c303041f3..799cf2156 100644 --- a/packages/server/src/constants/index.ts +++ b/packages/server/src/constants/index.ts @@ -4,6 +4,11 @@ import Docker from "dockerode"; export const IS_CLOUD = process.env.IS_CLOUD === "true"; +// Public read-only demo instance: every non-query tRPC procedure, the +// terminal/exec websockets, and the deploy webhooks are blocked so visitors +// can browse seeded data without being able to change anything. +export const IS_DEMO = process.env.IS_DEMO === "true"; + export const DOKPLOY_DOCKER_API_VERSION = process.env.DOKPLOY_DOCKER_API_VERSION; export const DOKPLOY_DOCKER_HOST = process.env.DOKPLOY_DOCKER_HOST; diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index ab52c6ca2..0f7f9ead1 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -9,7 +9,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { APIError, createAuthMiddleware } from "better-auth/api"; import { admin, organization, twoFactor } from "better-auth/plugins"; import { and, desc, eq } from "drizzle-orm"; -import { IS_CLOUD } from "../constants"; +import { IS_CLOUD, IS_DEMO } from "../constants"; import { db } from "../db"; import * as schema from "../db/schema"; import { @@ -32,6 +32,20 @@ import { getPublicIpWithFallback } from "../wss/utils"; import { ac, adminRole, memberRole, ownerRole } from "./access-control"; import { betterAuthSecret } from "./auth-secret"; +// Default-deny allowlist for demo mode: block every better-auth endpoint +// except the ones a shared read-only login needs (sign-in, session check, +// sign-out). This is deliberately an allowlist, not a blocklist of the +// mutating paths (sign-up, change-password, invite-member, api-key, sso, +// scim, ...) — better-auth plugins add new endpoints over time, and a +// blocklist silently stops covering them. +const DEMO_ALLOWED_AUTH_PATHS = new Set([ + "/sign-in/email", + "/get-session", + "/sign-out", + "/ok", + "/error", +]); + const resolveTrustedOrigins = async () => { try { if (IS_CLOUD) { @@ -125,6 +139,12 @@ const createBetterAuth = () => ...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []), ...(await resolveTrustedOrigins()), ].filter(Boolean); + + if (IS_DEMO && !DEMO_ALLOWED_AUTH_PATHS.has(ctx.path)) { + throw new APIError("FORBIDDEN", { + message: "This is a read-only demo instance.", + }); + } }), }, emailVerification: {