From da751157aa60c9f82d8a870c15118ab435ce61cf Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:59:24 -0500 Subject: [PATCH] fix(webhooks): bound the size of watch paths and deploy when a pattern cannot be evaluated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: у watchPaths появились ограничения: не больше 64 путей по 256 символов (watchPathsSchema в utils/watch-paths/limits.ts; подключена в createSchema приложения и compose, поэтому действует во всех apiSave*Provider, apiUpdateApplication и apiUpdateCompose). shouldDeploy не разбирает слишком большой набор и оборачивает micromatch в try/catch: и в том, и в другом случае деплой запускается, в лог идёт предупреждение без самих шаблонов. Зачем: проверка зависимостей через cve-now нашла braces 3.0.3 (CVE-2026-93687, высокая, исправления нет). Измерение на micromatch 4.0.8 показало, что форк её не затрагивает: micromatch(files, patterns) идёт в picomatch и до braces не доходит (braces зовутся только из micromatch.parse, .braces и .braceExpand); формы шаблонов с переполнением стека и взрывом раскрытия ({a,b} подряд до предела длины) через этот вызов завершаются за миллисекунды. Зато сам разбор вложенных скобок в picomatch растёт квадратично, а длина watchPaths не была ограничена: шаблон из 40 000 символов блокировал цикл событий на 8,6 с под Bun, на Node заканчивался SyntaxError после 8 с. Кроме того, пустая строка в watchPaths делала micromatch TypeError ("Expected pattern to be a non-empty string"), и обработчик вебхука падал. Риски: путь длиннее 256 символов или больше 64 путей нельзя сохранить, API вернёт сообщение схемы (формы показывают ошибку сохранения). Уже сохранённые длинные шаблоны не ломаются, а ведут к деплою на каждый пуш, пока их не сократят. Обычные шаблоны (src/**, {apps,packages}/*/src/**, !tests/**) оцениваются как раньше. Проверки на Linux, Bun 1.4.4, Node 26.13.0: server:build, typecheck и biome проходят; should-deploy.test.ts 17 из 17 (было 10: добавлены оценка слишком большого шаблона за миллисекунды, 65 путей, путь ровно в 256 символов, пустой путь, схемы приложения и compose); unit-тесты: 6136 прошли, 2 упали, и каждый из них так же падает на базовой ветке (backup-files под нагрузкой, service-webhook-secret обращается к настоящему Bitbucket API). Co-Authored-By: Claude Sonnet 5.5 --- .../__test__/deploy/should-deploy.test.ts | 112 +++++++++++++++++- packages/server/src/db/schema/application.ts | 3 +- packages/server/src/db/schema/compose.ts | 3 +- .../server/src/utils/watch-paths/limits.ts | 25 ++++ .../src/utils/watch-paths/should-deploy.ts | 16 ++- 5 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/utils/watch-paths/limits.ts diff --git a/apps/dokploy/__test__/deploy/should-deploy.test.ts b/apps/dokploy/__test__/deploy/should-deploy.test.ts index 8b0da4428f2..a9092aad287 100644 --- a/apps/dokploy/__test__/deploy/should-deploy.test.ts +++ b/apps/dokploy/__test__/deploy/should-deploy.test.ts @@ -1,5 +1,10 @@ import { shouldDeploy } from "@dokploy/server"; -import { describe, expect, it } from "vitest"; +import { + apiSaveGithubProvider, + apiSaveGitProvider, + apiUpdateCompose, +} from "@dokploy/server/db/schema"; +import { describe, expect, it, vi } from "vitest"; describe("shouldDeploy", () => { it("should deploy when no watch paths are configured", () => { @@ -63,3 +68,108 @@ describe("shouldDeploy", () => { expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false); }); }); + +describe("shouldDeploy limits", () => { + const nested = (depth: number) => `${"{".repeat(depth)}a${"}".repeat(depth)}`; + + it("does not parse an oversized pattern and deploys", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const started = performance.now(); + + expect(shouldDeploy([nested(20000)], ["docs/readme.md"])).toBe(true); + expect(performance.now() - started).toBeLessThan(200); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it("deploys when there are more watch paths than allowed", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const paths = Array.from({ length: 65 }, (_, i) => `dir${i}/**`); + + expect(shouldDeploy(paths, ["docs/readme.md"])).toBe(true); + warn.mockRestore(); + }); + + it("still evaluates a pattern of exactly the largest allowed size", () => { + const longest = "a".repeat(256); + + expect(shouldDeploy([longest], ["docs/readme.md"])).toBe(false); + expect(shouldDeploy([longest], [longest])).toBe(true); + }); + + it("deploys instead of throwing when the matcher rejects a pattern", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(shouldDeploy(["src/**", ""], ["docs/readme.md"])).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); +}); + +describe("watch paths in the API schemas", () => { + const github = { + repository: "repo", + branch: "main", + owner: "owner", + buildPath: "/", + githubId: "gh", + triggerType: "push" as const, + enableSubmodules: false, + }; + + it("accept ordinary globs", () => { + const result = apiSaveGithubProvider.safeParse({ + ...github, + applicationId: "app", + watchPaths: ["src/**", "!src/**/*.test.ts", "{apps,packages}/*/src/**"], + }); + + expect(result.success).toBe(true); + }); + + it("refuse a pattern over the size limit", () => { + const result = apiSaveGithubProvider.safeParse({ + ...github, + applicationId: "app", + watchPaths: ["src/**", "x".repeat(257)], + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path[0]).toBe("watchPaths"); + expect(result.error?.issues[0]?.message).toContain("at most 256"); + }); + + it("refuse too many watch paths", () => { + const result = apiUpdateCompose.safeParse({ + composeId: "compose", + watchPaths: Array.from({ length: 65 }, (_, i) => `dir${i}/**`), + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path[0]).toBe("watchPaths"); + }); + + it("apply to the generic git provider and accept a cleared list", () => { + const git = { + applicationId: "app", + customGitUrl: "https://example.com/r.git", + customGitBranch: "main", + customGitBuildPath: "/", + enableSubmodules: false, + }; + const tooLong = apiSaveGitProvider.safeParse({ + ...git, + watchPaths: ["a".repeat(300)], + }); + + expect(tooLong.success).toBe(false); + expect(tooLong.error?.issues[0]?.path[0]).toBe("watchPaths"); + expect( + apiSaveGitProvider.safeParse({ ...git, watchPaths: ["src/**"] }).success, + ).toBe(true); + expect( + apiUpdateCompose.safeParse({ composeId: "compose", watchPaths: null }) + .success, + ).toBe(true); + }); +}); diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index cece896ec32..89f1cb6e1aa 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -1,5 +1,6 @@ import { normalizeRelativeFilePath } from "@dokploy/server/utils/filesystem/safe-path"; import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation"; +import { watchPathsSchema } from "@dokploy/server/utils/watch-paths/limits"; import { relations } from "drizzle-orm"; import { bigint, @@ -436,7 +437,7 @@ const createSchema = createInsertSchema(applications, { previewPath: z.string().nullable().optional(), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), previewRequireCollaboratorPermissions: z.boolean().nullable().optional(), - watchPaths: z.array(z.string()).nullable().optional(), + watchPaths: watchPathsSchema.nullable().optional(), previewLabels: z.array(z.string()).nullable().optional(), networkIds: z.array(z.string()).nullable().optional(), detachDokployNetwork: z.boolean().optional(), diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 154887bb260..311ebaae165 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -1,4 +1,5 @@ import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation"; +import { watchPathsSchema } from "@dokploy/server/utils/watch-paths/limits"; import { relations } from "drizzle-orm"; import { boolean, @@ -206,7 +207,7 @@ const createSchema = createInsertSchema(compose, { createEnvFile: z.boolean().optional(), composePath: z.string().min(1).optional(), composeType: z.enum(["docker-compose", "stack"]).optional(), - watchPaths: z.array(z.string()).nullable().optional(), + watchPaths: watchPathsSchema.nullable().optional(), sourceType: z .enum(["git", "github", "gitlab", "bitbucket", "gitea", "raw"]) .optional(), diff --git a/packages/server/src/utils/watch-paths/limits.ts b/packages/server/src/utils/watch-paths/limits.ts new file mode 100644 index 00000000000..2348ed3ef64 --- /dev/null +++ b/packages/server/src/utils/watch-paths/limits.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +// picomatch's parse time grows with the square of the nesting of braces, and it runs on +// the event loop: a 40 000 character pattern blocked it for 9 s under Bun. Real globs +// stay far below these limits. +export const WATCH_PATH_MAX_LENGTH = 256; +export const WATCH_PATHS_MAX_COUNT = 64; + +export const watchPathsSchema = z + .array( + z + .string() + .max( + WATCH_PATH_MAX_LENGTH, + `A watch path can have at most ${WATCH_PATH_MAX_LENGTH} characters`, + ), + ) + .max( + WATCH_PATHS_MAX_COUNT, + `At most ${WATCH_PATHS_MAX_COUNT} watch paths are allowed`, + ); + +export const areWatchPathsWithinLimits = (watchPaths: string[]): boolean => + watchPaths.length <= WATCH_PATHS_MAX_COUNT && + watchPaths.every((path) => path.length <= WATCH_PATH_MAX_LENGTH); diff --git a/packages/server/src/utils/watch-paths/should-deploy.ts b/packages/server/src/utils/watch-paths/should-deploy.ts index 84e335c1a2c..1a0112cc7a4 100644 --- a/packages/server/src/utils/watch-paths/should-deploy.ts +++ b/packages/server/src/utils/watch-paths/should-deploy.ts @@ -1,12 +1,26 @@ import micromatch from "micromatch"; +import { areWatchPathsWithinLimits } from "./limits"; export const shouldDeploy = ( watchPaths: string[] | null, modifiedFiles: (string | null | undefined)[] | null | undefined, ): boolean => { if (!watchPaths || watchPaths?.length === 0) return true; + // Watch paths that cannot be evaluated must not stop deployments without a trace. + if (!areWatchPathsWithinLimits(watchPaths)) { + console.warn("Watch paths are over the size limits, deploying anyway"); + return true; + } const files = (modifiedFiles ?? []).filter( (file): file is string => typeof file === "string", ); - return micromatch(files, watchPaths).length > 0; + try { + return micromatch(files, watchPaths).length > 0; + } catch (error) { + console.warn( + "Watch paths could not be evaluated, deploying anyway:", + error instanceof Error ? error.message : error, + ); + return true; + } };