diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml new file mode 100644 index 000000000..3554babb2 --- /dev/null +++ b/.github/workflows/pr-quality.yml @@ -0,0 +1,22 @@ + +name: PR Quality + +permissions: + contents: read + issues: read + pull-requests: write + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + anti-slop: + runs-on: ubuntu-latest + steps: + - uses: peakoss/anti-slop@v0 + with: + max-failures: 4 + blocked-commit-authors: "claude,copilot" + require-description: true + min-account-age: 5 diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index dc795fd35..6e9940d6d 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants"; import AdmZip from "adm-zip"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +const OUTPUT_BASE = "./__test__/drop/zips/output"; const { APPLICATIONS_PATH } = paths(); vi.mock("@dokploy/server/constants", async (importOriginal) => { const actual = await importOriginal(); @@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => { // @ts-ignore ...actual, paths: () => ({ - APPLICATIONS_PATH: "./__test__/drop/zips/output", + // @ts-ignore + ...actual.paths(), + BASE_PATH: OUTPUT_BASE, + APPLICATIONS_PATH: OUTPUT_BASE, }), }; }); @@ -150,6 +154,176 @@ const baseApp: ApplicationNested = { ulimitsSwarm: null, }; +/** + * GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal. + * Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron + * plus cover files (package.json, index.js). unzipDrop must reject and never write outside output. + */ +describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => { + baseApp.appName = "ghsa-rce"; + // PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace) + const traversalEntry = "../../../../../etc/cron.d/malicious-cron"; + const cronPayload = "* * * * * root id\n"; + const placeholder = "x".repeat(traversalEntry.length); + const zip = new AdmZip(); + zip.addFile( + "package.json", + Buffer.from('{"name": "app", "version": "1.0.0"}'), + ); + zip.addFile("index.js", Buffer.from('console.log("Application");')); + zip.addFile(placeholder, Buffer.from(cronPayload)); + let buf = Buffer.from(zip.toBuffer()); + buf = Buffer.from( + buf.toString("binary").split(placeholder).join(traversalEntry), + "binary", + ); + const file = new File([buf as unknown as ArrayBuffer], "exploit.zip"); + await expect(unzipDrop(file, baseApp)).rejects.toThrow( + /Path traversal detected.*resolved path escapes output directory/, + ); + }); +}); + +describe("security: existing symlink escape", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("should NOT write outside base when directory is a symlink", async () => { + const appName = "symlink-existing"; + const output = path.join(APPLICATIONS_PATH, appName, "code"); + await fs.mkdir(output, { recursive: true }); + + // outside target (attacker wants to write here) + const outside = path.join(APPLICATIONS_PATH, "..", "outside"); + await fs.mkdir(outside, { recursive: true }); + + // attacker-controlled symlink inside project + await fs.symlink(outside, path.join(output, "logs")); + + // zip looks totally harmless + const zip = new AdmZip(); + zip.addFile("logs/pwned.txt", Buffer.from("owned")); + + const file = new File([zip.toBuffer() as any], "exploit.zip"); + + await unzipDrop(file, { ...baseApp, appName }); + + // if vulnerable -> file exists outside sandbox + const escaped = await fs + .readFile(path.join(outside, "pwned.txt"), "utf8") + .then(() => true) + .catch(() => false); + + expect(escaped).toBe(false); + }); +}); + +describe("security: zip symlink entry blocked", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("rejects zip containing real symlink entry", async () => { + const appName = "zip-symlink"; + + const zipBuffer = await fs.readFile( + path.join(__dirname, "./zips/payload/symlink-entry.zip"), + ); + + const file = new File([zipBuffer as any], "exploit.zip"); + + await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow( + /Dangerous node entries are not allowed/, + ); + }); +}); + +describe("unzipDrop path under output (no traversal)", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => { + baseApp.appName = "cron-under-output"; + const zip = new AdmZip(); + zip.addFile( + "etc/cron.d/malicious-cron", + Buffer.from("* * * * * root id\n"), + ); + zip.addFile("package.json", Buffer.from('{"name":"app"}')); + const file = new File( + [zip.toBuffer() as unknown as ArrayBuffer], + "app.zip", + ); + const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); + await unzipDrop(file, baseApp); + const content = await fs.readFile( + path.join(outputPath, "etc/cron.d/malicious-cron"), + "utf8", + ); + expect(content).toBe("* * * * * root id\n"); + }); +}); + +describe("security: traversal inside BASE_PATH (sandbox escape)", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("should NOT allow writing outside application directory but inside BASE_PATH", async () => { + const appName = "sandbox-escape"; + + const base = APPLICATIONS_PATH.replace("/applications", ""); + const output = path.join(APPLICATIONS_PATH, appName, "code"); + + await fs.mkdir(output, { recursive: true }); + + // attacker writes into traefik config inside base + const zip = new AdmZip(); + zip.addFile( + "../../../traefik/dynamic/evil.yml", + Buffer.from("pwned: true"), + ); + + const file = new File([zip.toBuffer() as any], "exploit.zip"); + + await unzipDrop(file, { ...baseApp, appName }); + + const escapedPath = path.join(base, "traefik/dynamic/evil.yml"); + + const exists = await fs + .readFile(escapedPath) + .then(() => true) + .catch(() => false); + + expect(exists).toBe(false); + }); +}); + describe("unzipDrop using real zip files", () => { // const { APPLICATIONS_PATH } = paths(); beforeAll(async () => { @@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => { try { const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); const zip = new AdmZip("./__test__/drop/zips/single-file.zip"); - console.log(`Output Path: ${outputPath}`); const zipBuffer = zip.toBuffer() as Buffer; const file = new File([zipBuffer], "single.zip"); await unzipDrop(file, baseApp); const files = await fs.readdir(outputPath, { withFileTypes: true }); expect(files.some((f) => f.name === "test.txt")).toBe(true); } catch (err) { - console.log(err); } finally { } }); diff --git a/apps/dokploy/__test__/drop/zips/payload/link b/apps/dokploy/__test__/drop/zips/payload/link new file mode 120000 index 000000000..3594e94c0 --- /dev/null +++ b/apps/dokploy/__test__/drop/zips/payload/link @@ -0,0 +1 @@ +/etc/passwd \ No newline at end of file diff --git a/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip b/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip new file mode 100644 index 000000000..b30279c6b Binary files /dev/null and b/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip differ diff --git a/apps/dokploy/__test__/wss/readValidDirectory.test.ts b/apps/dokploy/__test__/wss/readValidDirectory.test.ts new file mode 100644 index 000000000..8107bb591 --- /dev/null +++ b/apps/dokploy/__test__/wss/readValidDirectory.test.ts @@ -0,0 +1,81 @@ +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +const BASE = "/base"; + +vi.mock("@dokploy/server/constants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + paths: () => ({ + ...actual.paths(), + BASE_PATH: BASE, + LOGS_PATH: `${BASE}/logs`, + APPLICATIONS_PATH: `${BASE}/applications`, + }), + }; +}); + +// Import after mock so paths() uses our BASE +const { readValidDirectory } = await import("@dokploy/server"); + +describe("readValidDirectory (path traversal)", () => { + it("returns true when directory is exactly BASE_PATH", () => { + expect(readValidDirectory(BASE)).toBe(true); + expect(readValidDirectory(path.resolve(BASE))).toBe(true); + }); + + it("returns true when directory is under BASE_PATH", () => { + expect(readValidDirectory(`${BASE}/logs`)).toBe(true); + expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true); + expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true); + }); + + it("returns false for path traversal escaping base (absolute)", () => { + expect(readValidDirectory("/etc/passwd")).toBe(false); + expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false); + expect(readValidDirectory("/tmp/outside")).toBe(false); + }); + + it("returns false when resolved path escapes base via ..", () => { + // Resolved: /etc/passwd (outside /base) + expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false); + expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false); + expect(readValidDirectory(`${BASE}/..`)).toBe(false); + }); + + it("returns true when .. stays within base", () => { + // e.g. /base/logs/../applications -> /base/applications (still under /base) + expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true); + expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true); + }); + + it("accepts serverId for remote base path", () => { + // With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw + expect(readValidDirectory(BASE, "server-1")).toBe(true); + expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false); + }); + + it("returns false for null/undefined-like paths that resolve outside", () => { + // Paths that might resolve to cwd or root + expect(readValidDirectory(".")).toBe(false); + expect(readValidDirectory("..")).toBe(false); + }); + + it("returns true for BASE_PATH with trailing slash or double slashes under base", () => { + expect(readValidDirectory(`${BASE}/`)).toBe(true); + expect(readValidDirectory(`${BASE}//logs`)).toBe(true); + expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true); + }); + + it("returns false when path looks like base but is a sibling or prefix", () => { + expect(readValidDirectory("/base-evil")).toBe(false); + expect(readValidDirectory("/bas")).toBe(false); + expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false); + }); + + it("returns false for empty string (resolves to cwd)", () => { + expect(readValidDirectory("")).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/wss/utils.test.ts b/apps/dokploy/__test__/wss/utils.test.ts new file mode 100644 index 000000000..209bd5f86 --- /dev/null +++ b/apps/dokploy/__test__/wss/utils.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + isValidContainerId, + isValidSearch, + isValidSince, + isValidTail, +} from "../../server/wss/utils"; + +describe("isValidTail (docker-container-logs)", () => { + it("accepts valid numeric tail values", () => { + expect(isValidTail("0")).toBe(true); + expect(isValidTail("1")).toBe(true); + expect(isValidTail("100")).toBe(true); + expect(isValidTail("10000")).toBe(true); + }); + + it("rejects tail above 10000", () => { + expect(isValidTail("10001")).toBe(false); + expect(isValidTail("99999")).toBe(false); + }); + + it("rejects non-numeric tail", () => { + expect(isValidTail("")).toBe(false); + expect(isValidTail("abc")).toBe(false); + expect(isValidTail("10a")).toBe(false); + expect(isValidTail("-1")).toBe(false); + }); + + it("rejects command injection payloads in tail", () => { + expect(isValidTail("10; whoami; #")).toBe(false); + expect(isValidTail("100 | cat /etc/passwd")).toBe(false); + expect(isValidTail("$(id)")).toBe(false); + expect(isValidTail("`id`")).toBe(false); + expect(isValidTail("100\nid")).toBe(false); + expect(isValidTail("100 && id")).toBe(false); + expect(isValidTail("100; env | grep DATABASE")).toBe(false); + }); +}); + +describe("isValidSince (docker-container-logs)", () => { + it("accepts 'all'", () => { + expect(isValidSince("all")).toBe(true); + }); + + it("accepts valid duration format (number + s|m|h|d)", () => { + expect(isValidSince("5s")).toBe(true); + expect(isValidSince("10m")).toBe(true); + expect(isValidSince("1h")).toBe(true); + expect(isValidSince("2d")).toBe(true); + expect(isValidSince("0s")).toBe(true); + expect(isValidSince("999d")).toBe(true); + }); + + it("rejects invalid duration format", () => { + expect(isValidSince("")).toBe(false); + expect(isValidSince("5")).toBe(false); + expect(isValidSince("s")).toBe(false); + expect(isValidSince("5x")).toBe(false); + expect(isValidSince("5sec")).toBe(false); + expect(isValidSince("5 m")).toBe(false); + }); + + it("rejects command injection payloads in since", () => { + expect(isValidSince("5s; whoami")).toBe(false); + expect(isValidSince("all; id")).toBe(false); + expect(isValidSince("1m$(id)")).toBe(false); + expect(isValidSince("1m | cat /etc/passwd")).toBe(false); + }); +}); + +describe("isValidSearch (docker-container-logs)", () => { + it("accepts empty string", () => { + expect(isValidSearch("")).toBe(true); + }); + + it("accepts only alphanumeric, space, dot, underscore, hyphen", () => { + expect(isValidSearch("error")).toBe(true); + expect(isValidSearch("foo bar")).toBe(true); + expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true); + expect(isValidSearch("")).toBe(true); + }); + + it("rejects strings longer than 500 chars", () => { + expect(isValidSearch("a".repeat(501))).toBe(false); + expect(isValidSearch("a".repeat(500))).toBe(true); + }); + + it("rejects control characters and non-printable", () => { + expect(isValidSearch("foo\nbar")).toBe(false); + expect(isValidSearch("foo\rbar")).toBe(false); + expect(isValidSearch("\x00")).toBe(false); + expect(isValidSearch("a\x19b")).toBe(false); + }); + + it("rejects command injection vectors in search (search is concatenated into shell)", () => { + // Double-quoted context (SSH line 99): $ and ` execute + expect(isValidSearch("$(whoami)")).toBe(false); + expect(isValidSearch("`id`")).toBe(false); + expect(isValidSearch("$(id)")).toBe(false); + // Single-quoted context (local line 153): ' breaks out + expect(isValidSearch("'$(whoami)'")).toBe(false); + expect(isValidSearch("error'")).toBe(false); + expect(isValidSearch("'; whoami; #")).toBe(false); + // Other shell-metacharacters + expect(isValidSearch("error; id")).toBe(false); + expect(isValidSearch("a|b")).toBe(false); + expect(isValidSearch('error"')).toBe(false); + expect(isValidSearch("a&b")).toBe(false); + }); +}); + +describe("isValidContainerId (docker-container-logs)", () => { + it("accepts valid hex container IDs", () => { + expect(isValidContainerId("a".repeat(12))).toBe(true); + expect(isValidContainerId("abc123def456")).toBe(true); + expect(isValidContainerId("a".repeat(64))).toBe(true); + }); + + it("accepts valid container names", () => { + expect(isValidContainerId("my-container")).toBe(true); + expect(isValidContainerId("app_1")).toBe(true); + expect(isValidContainerId("service.name")).toBe(true); + }); + + it("rejects command injection in container ID", () => { + expect(isValidContainerId("dummy; whoami")).toBe(false); + expect(isValidContainerId("$(id)")).toBe(false); + expect(isValidContainerId("`id`")).toBe(false); + expect(isValidContainerId("container|cat /etc/passwd")).toBe(false); + expect(isValidContainerId("x; env | grep DATABASE")).toBe(false); + }); +}); diff --git a/apps/dokploy/components/proprietary/sso/sso-settings.tsx b/apps/dokploy/components/proprietary/sso/sso-settings.tsx index bb8330cda..dee41b13c 100644 --- a/apps/dokploy/components/proprietary/sso/sso-settings.tsx +++ b/apps/dokploy/components/proprietary/sso/sso-settings.tsx @@ -84,9 +84,10 @@ export const SSOSettings = () => { const [newOriginInput, setNewOriginInput] = useState(""); const { data: providers, isLoading } = api.sso.listProviders.useQuery(); - const { data: userData } = api.user.get.useQuery(undefined, { - enabled: manageOriginsOpen, - }); + const { data: trustedOrigins = [] } = api.sso.getTrustedOrigins.useQuery( + undefined, + { enabled: manageOriginsOpen }, + ); const { mutateAsync: deleteProvider, isLoading: isDeleting } = api.sso.deleteProvider.useMutation(); const { mutateAsync: addTrustedOrigin, isLoading: isAddingOrigin } = @@ -96,8 +97,6 @@ export const SSOSettings = () => { const { mutateAsync: updateTrustedOrigin, isLoading: isUpdatingOrigin } = api.sso.updateTrustedOrigin.useMutation(); - const trustedOrigins = userData?.user?.trustedOrigins ?? []; - const handleAddOrigin = async () => { const value = newOriginInput.trim(); if (!value) return; @@ -105,7 +104,7 @@ export const SSOSettings = () => { await addTrustedOrigin({ origin: value }); toast.success("Trusted origin added"); setNewOriginInput(""); - await utils.user.get.invalidate(); + await utils.sso.getTrustedOrigins.invalidate(); } catch (err) { toast.error( err instanceof Error ? err.message : "Failed to add trusted origin", @@ -118,7 +117,7 @@ export const SSOSettings = () => { await removeTrustedOrigin({ origin }); toast.success("Trusted origin removed"); if (editingOrigin === origin) setEditingOrigin(null); - await utils.user.get.invalidate(); + await utils.sso.getTrustedOrigins.invalidate(); } catch (err) { toast.error( err instanceof Error ? err.message : "Failed to remove trusted origin", @@ -144,7 +143,7 @@ export const SSOSettings = () => { toast.success("Trusted origin updated"); setEditingOrigin(null); setEditingValue(""); - await utils.user.get.invalidate(); + await utils.sso.getTrustedOrigins.invalidate(); } catch (err) { toast.error( err instanceof Error ? err.message : "Failed to update trusted origin", diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index a88a5dc8e..d2ea83297 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -105,7 +105,6 @@ export default function Home({ IS_CLOUD }: Props) { setIsLoginLoading(false); } }; - const onTwoFactorSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (twoFactorCode.length !== 6) { diff --git a/apps/dokploy/server/api/routers/proprietary/license-key.ts b/apps/dokploy/server/api/routers/proprietary/license-key.ts index ec7ad55c8..d6a770be9 100644 --- a/apps/dokploy/server/api/routers/proprietary/license-key.ts +++ b/apps/dokploy/server/api/routers/proprietary/license-key.ts @@ -1,5 +1,5 @@ import { user } from "@dokploy/server/db/schema"; -import { validateLicenseKey } from "@dokploy/server/index"; +import { hasValidLicense, validateLicenseKey } from "@dokploy/server/index"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; @@ -184,18 +184,7 @@ export const licenseKeyRouter = createTRPCRouter({ }; }), haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => { - const currentUserId = ctx.user.id; - const currentUser = await db.query.user.findFirst({ - where: eq(user.id, currentUserId), - columns: { - enableEnterpriseFeatures: true, - isValidEnterpriseLicense: true, - }, - }); - return !!( - currentUser?.enableEnterpriseFeatures && - currentUser?.isValidEnterpriseLicense - ); + return await hasValidLicense(ctx.session.activeOrganizationId); }), updateEnterpriseSettings: adminProcedure .input( diff --git a/apps/dokploy/server/api/routers/proprietary/sso.ts b/apps/dokploy/server/api/routers/proprietary/sso.ts index d59b2c974..c8b42f7fb 100644 --- a/apps/dokploy/server/api/routers/proprietary/sso.ts +++ b/apps/dokploy/server/api/routers/proprietary/sso.ts @@ -2,7 +2,10 @@ import { normalizeTrustedOrigin } from "@dokploy/server"; import { IS_CLOUD } from "@dokploy/server/constants"; import { member, ssoProvider, user } from "@dokploy/server/db/schema"; import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso"; -import { requestToHeaders } from "@dokploy/server/index"; +import { + getOrganizationOwnerId, + requestToHeaders, +} from "@dokploy/server/index"; import { auth } from "@dokploy/server/lib/auth"; import { TRPCError } from "@trpc/server"; import { and, asc, eq } from "drizzle-orm"; @@ -59,6 +62,17 @@ export const ssoRouter = createTRPCRouter({ }); return providers; }), + getTrustedOrigins: enterpriseProcedure.query(async ({ ctx }) => { + const ownerId = await getOrganizationOwnerId( + ctx.session.activeOrganizationId, + ); + if (!ownerId) return []; + const ownerUser = await db.query.user.findFirst({ + where: eq(user.id, ownerId), + columns: { trustedOrigins: true }, + }); + return ownerUser?.trustedOrigins ?? []; + }), one: enterpriseProcedure .input(z.object({ providerId: z.string().min(1) })) .query(async ({ ctx, input }) => { @@ -135,11 +149,20 @@ export const ssoRouter = createTRPCRouter({ normalizeTrustedOrigin(existing.issuer) !== normalizeTrustedOrigin(input.issuer); if (issuerChanged) { - const currentUser = await db.query.user.findFirst({ - where: eq(user.id, ctx.session.userId), + const ownerId = await getOrganizationOwnerId( + ctx.session.activeOrganizationId, + ); + if (!ownerId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Organization owner not found", + }); + } + const ownerUser = await db.query.user.findFirst({ + where: eq(user.id, ownerId), columns: { trustedOrigins: true }, }); - const trustedOrigins = currentUser?.trustedOrigins ?? []; + const trustedOrigins = ownerUser?.trustedOrigins ?? []; const newOrigin = normalizeTrustedOrigin(input.issuer); const isInTrustedOrigins = trustedOrigins.some( (o) => o.toLowerCase() === newOrigin.toLowerCase(), @@ -148,7 +171,7 @@ export const ssoRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: - "The new Issuer URL is not in your trusted origins list. Please add it in Manage origins before saving.", + "The new Issuer URL is not in the organization's trusted origins list. Please add it in Manage origins before saving.", }); } } @@ -262,12 +285,21 @@ export const ssoRouter = createTRPCRouter({ addTrustedOrigin: enterpriseProcedure .input(z.object({ origin: z.string().min(1) })) .mutation(async ({ ctx, input }) => { + const ownerId = await getOrganizationOwnerId( + ctx.session.activeOrganizationId, + ); + if (!ownerId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Organization owner not found", + }); + } const normalized = normalizeTrustedOrigin(input.origin); - const currentUser = await db.query.user.findFirst({ - where: eq(user.id, ctx.session.userId), + const ownerUser = await db.query.user.findFirst({ + where: eq(user.id, ownerId), columns: { trustedOrigins: true }, }); - const existing = currentUser?.trustedOrigins || []; + const existing = ownerUser?.trustedOrigins || []; if (existing.some((o) => o.toLowerCase() === normalized.toLowerCase())) { return { success: true }; } @@ -275,25 +307,34 @@ export const ssoRouter = createTRPCRouter({ await db .update(user) .set({ trustedOrigins: next }) - .where(eq(user.id, ctx.session.userId)); + .where(eq(user.id, ownerId)); return { success: true }; }), removeTrustedOrigin: enterpriseProcedure .input(z.object({ origin: z.string().min(1) })) .mutation(async ({ ctx, input }) => { + const ownerId = await getOrganizationOwnerId( + ctx.session.activeOrganizationId, + ); + if (!ownerId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Organization owner not found", + }); + } const normalized = normalizeTrustedOrigin(input.origin); - const currentUser = await db.query.user.findFirst({ - where: eq(user.id, ctx.session.userId), + const ownerUser = await db.query.user.findFirst({ + where: eq(user.id, ownerId), columns: { trustedOrigins: true }, }); - const existing = currentUser?.trustedOrigins || []; + const existing = ownerUser?.trustedOrigins || []; const next = existing.filter( (o) => o.toLowerCase() !== normalized.toLowerCase(), ); await db .update(user) .set({ trustedOrigins: next }) - .where(eq(user.id, ctx.session.userId)); + .where(eq(user.id, ownerId)); return { success: true }; }), updateTrustedOrigin: enterpriseProcedure @@ -304,20 +345,29 @@ export const ssoRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + const ownerId = await getOrganizationOwnerId( + ctx.session.activeOrganizationId, + ); + if (!ownerId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Organization owner not found", + }); + } const oldNorm = normalizeTrustedOrigin(input.oldOrigin); const newNorm = normalizeTrustedOrigin(input.newOrigin); - const currentUser = await db.query.user.findFirst({ - where: eq(user.id, ctx.session.userId), + const ownerUser = await db.query.user.findFirst({ + where: eq(user.id, ownerId), columns: { trustedOrigins: true }, }); - const existing = currentUser?.trustedOrigins || []; + const existing = ownerUser?.trustedOrigins || []; const next = existing.map((o) => o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o, ); await db .update(user) .set({ trustedOrigins: next }) - .where(eq(user.id, ctx.session.userId)); + .where(eq(user.id, ownerId)); return { success: true }; }), }); diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index 51f8cdbee..084d529c7 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -7,6 +7,7 @@ * need to use are documented accordingly near the end. */ +import { hasValidLicense } from "@dokploy/server/index"; import { validateRequest } from "@dokploy/server/lib/auth"; import type { OpenApiMeta } from "@dokploy/trpc-openapi"; import { initTRPC, TRPCError } from "@trpc/server"; @@ -239,10 +240,11 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => { throw new TRPCError({ code: "UNAUTHORIZED" }); } - if ( - !ctx.user?.enableEnterpriseFeatures || - !ctx.user.isValidEnterpriseLicense - ) { + const hasValidLicenseResult = await hasValidLicense( + ctx.session.activeOrganizationId, + ); + + if (!hasValidLicenseResult) { throw new TRPCError({ code: "FORBIDDEN", message: "Valid enterprise license required", diff --git a/apps/dokploy/server/wss/docker-container-logs.ts b/apps/dokploy/server/wss/docker-container-logs.ts index c3f902475..159bedaae 100644 --- a/apps/dokploy/server/wss/docker-container-logs.ts +++ b/apps/dokploy/server/wss/docker-container-logs.ts @@ -3,7 +3,13 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; -import { getShell, isValidContainerId } from "./utils"; +import { + getShell, + isValidContainerId, + isValidSearch, + isValidSince, + isValidTail, +} from "./utils"; export const setupDockerContainerLogsWebSocketServer = ( server: http.Server, @@ -30,9 +36,9 @@ export const setupDockerContainerLogsWebSocketServer = ( wssTerm.on("connection", async (ws, req) => { const url = new URL(req.url || "", `http://${req.headers.host}`); const containerId = url.searchParams.get("containerId"); - const tail = url.searchParams.get("tail"); - const search = url.searchParams.get("search"); - const since = url.searchParams.get("since"); + const tail = url.searchParams.get("tail") ?? "100"; + const search = url.searchParams.get("search") ?? ""; + const since = url.searchParams.get("since") ?? "all"; const serverId = url.searchParams.get("serverId"); const runType = url.searchParams.get("runType"); const { user, session } = await validateRequest(req); @@ -48,6 +54,21 @@ export const setupDockerContainerLogsWebSocketServer = ( return; } + if (!isValidTail(tail)) { + ws.close(4000, "Invalid tail parameter"); + return; + } + + if (!isValidSince(since)) { + ws.close(4000, "Invalid since parameter"); + return; + } + + if (search !== "" && !isValidSearch(search)) { + ws.close(4000, "Invalid search parameter"); + return; + } + if (!user || !session) { ws.close(); return; diff --git a/apps/dokploy/server/wss/listen-deployment.ts b/apps/dokploy/server/wss/listen-deployment.ts index 99de9949d..c39fa70b7 100644 --- a/apps/dokploy/server/wss/listen-deployment.ts +++ b/apps/dokploy/server/wss/listen-deployment.ts @@ -1,9 +1,9 @@ import { spawn } from "node:child_process"; import type http from "node:http"; import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; +import { readValidDirectory } from "@dokploy/server/wss/utils"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; -import { readValidDirectory } from "./utils"; export const setupDeploymentLogsWebSocketServer = ( server: http.Server, diff --git a/apps/dokploy/server/wss/utils.ts b/apps/dokploy/server/wss/utils.ts index 651269c13..346093e1b 100644 --- a/apps/dokploy/server/wss/utils.ts +++ b/apps/dokploy/server/wss/utils.ts @@ -15,6 +15,37 @@ export const isValidContainerId = (id: string): boolean => { return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128); }; +/** + * Validates the `tail` parameter for docker logs (number of lines, max 10000). + * Prevents command injection by allowing only digits. + */ +export const isValidTail = (tail: string): boolean => { + return ( + /^\d+$/.test(tail) && + Number.parseInt(tail, 10) <= 10000 && + Number.parseInt(tail, 10) >= 0 + ); +}; + +/** + * Validates the `since` parameter for docker logs: "all" or duration like 5s, 10m, 1h, 2d. + * Prevents command injection by allowing only a strict format. + */ +export const isValidSince = (since: string): boolean => { + return since === "all" || /^\d+[smhd]$/.test(since); +}; + +/** + * Validates the `search` parameter for log filtering. + * Search is concatenated into shell commands (SSH path: double quotes; local path: single quotes). + * Only allow alphanumeric, space, dot, underscore, hyphen to prevent $, `, ', " from enabling command injection. + * Max length 500. + */ +export const isValidSearch = (search: string): boolean => { + // Space only (not \s) to reject \n, \r, \t and other control chars + return /^[a-zA-Z0-9 ._-]{0,500}$/.test(search); +}; + /** * Validates that the shell is one of the allowed shells. */ @@ -32,20 +63,6 @@ export const isValidShell = (shell: string): boolean => { return allowedShells.includes(shell); }; -export const readValidDirectory = ( - directory: string, - serverId?: string | null, -) => { - const { BASE_PATH } = paths(!!serverId); - - const resolvedBase = path.resolve(BASE_PATH); - const resolvedDir = path.resolve(directory); - - return ( - resolvedDir === resolvedBase || - resolvedDir.startsWith(resolvedBase + path.sep) - ); -}; export const getShell = () => { if (IS_CLOUD) { return "NO_AVAILABLE"; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1dd4f2b8b..9adc9081e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -33,6 +33,7 @@ export * from "./services/port"; export * from "./services/postgres"; export * from "./services/preview-deployment"; export * from "./services/project"; +export * from "./services/proprietary/license-key"; export * from "./services/proprietary/sso"; export * from "./services/redirect"; export * from "./services/redis"; diff --git a/packages/server/src/services/proprietary/license-key.ts b/packages/server/src/services/proprietary/license-key.ts new file mode 100644 index 000000000..69976ca8c --- /dev/null +++ b/packages/server/src/services/proprietary/license-key.ts @@ -0,0 +1,24 @@ +import { db } from "@dokploy/server/db"; +import { user } from "@dokploy/server/db/schema"; +import { eq } from "drizzle-orm"; +import { getOrganizationOwnerId } from "./sso"; + +export const hasValidLicense = async (organizationId: string) => { + const ownerId = await getOrganizationOwnerId(organizationId); + + if (!ownerId) { + return false; + } + + const currentUser = await db.query.user.findFirst({ + where: eq(user.id, ownerId), + columns: { + enableEnterpriseFeatures: true, + isValidEnterpriseLicense: true, + }, + }); + return !!( + currentUser?.enableEnterpriseFeatures && + currentUser?.isValidEnterpriseLicense + ); +}; diff --git a/packages/server/src/services/proprietary/sso.ts b/packages/server/src/services/proprietary/sso.ts index 85caf600c..352a042e0 100644 --- a/packages/server/src/services/proprietary/sso.ts +++ b/packages/server/src/services/proprietary/sso.ts @@ -1,4 +1,6 @@ import { db } from "@dokploy/server/db"; +import { organization } from "@dokploy/server/db/schema"; +import { eq } from "drizzle-orm"; export const getSSOProviders = async () => { const providers = await db.query.ssoProvider.findMany({ @@ -33,3 +35,12 @@ export const normalizeTrustedOrigin = (value: string): string => { // e.g. "https://example.com/" -> "https://example.com" return value.trim().replace(/\/+$/, ""); }; + +export const getOrganizationOwnerId = async (organizationId: string) => { + const org = await db.query.organization.findFirst({ + where: eq(organization.id, organizationId), + columns: { ownerId: true }, + }); + if (!org) return null; + return org.ownerId; +}; diff --git a/packages/server/src/utils/builders/drop.ts b/packages/server/src/utils/builders/drop.ts index 396c52d96..e5298a839 100644 --- a/packages/server/src/utils/builders/drop.ts +++ b/packages/server/src/utils/builders/drop.ts @@ -3,6 +3,7 @@ import path, { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import type { Application } from "@dokploy/server/services/application"; import { findServerById } from "@dokploy/server/services/server"; +import { readValidDirectory } from "@dokploy/server/wss/utils"; import AdmZip from "adm-zip"; import { Client, type SFTPWrapper } from "ssh2"; import { @@ -62,6 +63,17 @@ export const unzipDrop = async (zipFile: File, application: Application) => { if (!filePath) continue; const fullPath = path.join(outputPath, filePath).replace(/\\/g, "/"); + if (!readValidDirectory(fullPath, application.serverId)) { + throw new Error( + `Path traversal detected: resolved path escapes output directory: ${filePath}`, + ); + } + + if (isDangerousNode(entry)) { + throw new Error( + `Dangerous node entries are not allowed: ${entry.entryName}`, + ); + } if (application.serverId) { if (!entry.isDirectory) { @@ -132,3 +144,14 @@ const uploadFileToServer = ( }); }); }; + +function isDangerousNode(entry: AdmZip.IZipEntry) { + const type = (entry.header.attr >> 16) & 0o170000; + + return ( + type === 0o120000 || // symlink + type === 0o060000 || // block device + type === 0o020000 || // char device + type === 0o010000 // fifo/pipe + ); +} diff --git a/packages/server/src/wss/utils.ts b/packages/server/src/wss/utils.ts index 9ed75c3af..d54197ad7 100644 --- a/packages/server/src/wss/utils.ts +++ b/packages/server/src/wss/utils.ts @@ -1,4 +1,6 @@ import os from "node:os"; +import path from "node:path"; +import { paths } from "@dokploy/server/constants"; import { publicIpv4, publicIpv6 } from "public-ip"; export const getShell = () => { @@ -33,3 +35,18 @@ export const getPublicIpWithFallback = async () => { } return ip; }; + +export const readValidDirectory = ( + directory: string, + serverId?: string | null, +) => { + const { BASE_PATH } = paths(!!serverId); + + const resolvedBase = path.resolve(BASE_PATH); + const resolvedDir = path.resolve(directory); + + return ( + resolvedDir === resolvedBase || + resolvedDir.startsWith(resolvedBase + path.sep) + ); +};