Compare commits

...

31 Commits

Author SHA1 Message Date
Mauricio Siu
c688311580 Merge pull request #3736 from Dokploy/feat/add-modify-sso-by-admin
refactor(sso): update trusted origins handling and introduce license …
2026-02-18 01:49:37 -06:00
Mauricio Siu
b9c62cc515 refactor(license-key): remove unused import and add organization owner ID retrieval
- Removed the unused import of the organization schema.
- Introduced a new import for the getOrganizationOwnerId function to enhance license validation logic.
2026-02-18 01:40:22 -06:00
Mauricio Siu
605931861b Update packages/server/src/services/proprietary/license-key.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-18 01:39:59 -06:00
Mauricio Siu
4e8d37bff7 refactor(user): remove getTrustedOrigins query from user router
- Eliminated the getTrustedOrigins query from the user router to streamline the API and improve code maintainability.
2026-02-18 01:38:04 -06:00
Mauricio Siu
be35709cea fix(auth): remove callback URL from email sign-in process on home page 2026-02-18 01:35:57 -06:00
Mauricio Siu
6c3230648a refactor(sso): update trusted origins handling and introduce license validation
- Replaced user data fetching with a dedicated query for trusted origins in SSO settings.
- Updated mutation functions to utilize the new trusted origins query.
- Introduced a new service function to validate enterprise licenses based on organization ownership.
- Enhanced SSO router to ensure trusted origins are managed by the organization owner.
- Added callback URL for email sign-in in the home page.
2026-02-18 01:34:07 -06:00
Mauricio Siu
756d276f47 feat(workflow): add PR quality check to enforce standards and prevent issues 2026-02-17 21:09:32 -06:00
Mauricio Siu
1d5ab71bd5 Merge pull request #3735 from Dokploy/fix/Command-Injection-in-/docker-container-logs-Endpoint
feat(tests): add unit tests for validation functions in docker-contai…
2026-02-17 18:17:53 -06:00
Mauricio Siu
9880c71dba refactor(validation): update isValidSearch to prevent command injection
- Enhanced the isValidSearch function to restrict allowed characters to alphanumeric, space, dot, underscore, and hyphen, preventing command injection vulnerabilities.
- Updated unit tests to reflect the new validation rules and ensure comprehensive coverage against potential injection attacks.
2026-02-17 18:17:39 -06:00
Mauricio Siu
33c3a4ed4e fix(validation): enhance isValidSearch function to restrict allowed characters
- Updated the regex in the isValidSearch function to limit valid characters, improving input validation and security against potential injection attacks.
2026-02-17 18:11:43 -06:00
Mauricio Siu
3689a82ec5 feat(tests): add unit tests for validation functions in docker-container-logs
- Introduced tests for isValidTail, isValidSince, isValidSearch, and isValidContainerId functions to ensure proper validation and security against command injection.
- Updated docker-container-logs to utilize these validation functions, enhancing input handling for WebSocket connections.
2026-02-17 18:07:30 -06:00
Mauricio Siu
b818d661fd Merge pull request #3733 from Dokploy/fix/Remote-Code-Execution-through-Path-Traversal
feat(tests): add unit tests for readValidDirectory function to valida…
2026-02-17 14:43:33 -06:00
Mauricio Siu
1302d705e7 test(drop): add security tests for traversal prevention in unzipDrop function
- Introduced a new test suite to validate that the unzipDrop function prevents writing outside the application directory, specifically addressing potential sandbox escape vulnerabilities.
- Implemented setup and teardown logic to ensure a clean test environment for each test run.
2026-02-17 14:42:52 -06:00
Mauricio Siu
685a4c0b69 refactor(drop): replace symlink entry check with dangerous node entry validation
- Updated the unzipDrop function to remove the symlink entry check and replace it with a more general validation for dangerous node entries.
- Adjusted the associated test to reflect the change in error messaging.
2026-02-17 14:31:10 -06:00
Mauricio Siu
b58f2b236f feat(tests): add unit tests for readValidDirectory function to validate path traversal logic 2026-02-17 14:22:20 -06:00
Mauricio Siu
06fd561bb1 fix(deployments): remove unnecessary newline in deployment schema definition 2026-02-16 22:44:10 -06:00
Mauricio Siu
62fb117ecf Merge pull request #3042 from theo-vdml/feat/auth-add-otp-autofill
feat(auth): add autocomplete for 2FA OTP input
2026-02-16 22:35:41 -06:00
Mauricio Siu
8713d3e1aa Merge pull request #3185 from mcfdez/feat/add-delete-old-deployments
feat(deployments): add ability to delete old deployments
2026-02-16 22:31:20 -06:00
Mauricio Siu
76038f6db6 refactor(deployments): streamline deployment clearing process and remove cloud check
- Removed the cloud check from the ClearDeployments component, simplifying the logic.
- Updated the clearOldDeployments function to accept appName and serverId, enhancing its flexibility.
- Adjusted the return values in the application and compose routers to return a boolean instead of a detailed message, improving consistency.
2026-02-16 22:19:57 -06:00
Mauricio Siu
a511f4db40 refactor(deployments): unify old deployment clearing logic for applications and composes
- Renamed and consolidated the functions for clearing old deployments to a single method, `clearOldDeployments`, which now accepts an ID and type (application or compose).
- Updated the logic to filter deployments based on status and type, improving code maintainability and reducing redundancy.
2026-02-16 22:15:39 -06:00
Mauricio Siu
95a944c4e5 feat(deployments): enhance deployment deletion logic and improve error handling
- Updated the deployment deletion process to include error handling for non-existent deployments.
- Refactored the command execution to handle both remote and local execution based on server availability.
- Simplified the logic for determining deletable deployments in the ShowDeployments component.
2026-02-16 22:12:56 -06:00
Mauricio Siu
6d6cf18108 Merge branch 'canary' into feat/add-delete-old-deployments 2026-02-16 22:06:48 -06:00
Mauricio Siu
32ed0c7285 Merge pull request #2767 from aegypius/features/support-soft-serve-webhooks
feat: add support for Soft Serve webhooks
2026-02-16 21:39:51 -06:00
Mauricio Siu
923466b4fa Merge pull request #3729 from Dokploy/feat/add-teams-notification-provider
feat(notifications): add Microsoft Teams integration for notifications
2026-02-16 21:23:09 -06:00
autofix-ci[bot]
2be92d20bb [autofix.ci] apply automated fixes 2026-01-19 08:59:46 +01:00
Marc Fernandez
2be938a695 feat: add individual deployment deletion 2026-01-19 08:59:45 +01:00
autofix-ci[bot]
95dd9ddeb6 [autofix.ci] apply automated fixes 2026-01-19 08:59:45 +01:00
Marc Fernandez
33fb21bfe1 feat: add ability to delete old deployments 2026-01-19 08:59:44 +01:00
Nicolas LAURENT
f5f21ef195 test(deployment): add Soft Serve tests 2025-11-28 16:41:40 +01:00
Nicolas LAURENT
464d58daaa feat(deployment): add support for Soft Serve webhooks 2025-11-28 16:41:40 +01:00
Théo Vandormael
50b0a5d61c feat(auth): add autocomplete for 2FA OTP input 2025-11-18 01:58:20 +01:00
30 changed files with 907 additions and 73 deletions

22
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -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

View File

@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Soft Serve
expect(
extractCommitMessage(
{ "x-softserve-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
extractBranchName,
extractCommitMessage,
extractHash,
getProviderByHeader,
} from "@/pages/api/deploy/[refreshToken]";
describe("Soft Serve Webhook", () => {
const mockSoftServeHeaders = {
"x-softserve-event": "push",
};
const createMockBody = (message: string, hash: string, branch: string) => ({
event: "push",
ref: `refs/heads/${branch}`,
after: hash,
commits: [{ message: message }],
});
const message: string = "feat: add new feature";
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
const branch: string = "feat/add-new";
const goodWebhook = createMockBody(message, hash, branch);
it("should properly extract the provider name", () => {
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
});
it("should properly extract the commit message", () => {
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
message,
);
});
it("should properly extract hash", () => {
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
});
it("should properly extract branch name", () => {
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
});
it("should gracefully handle invalid webhook", () => {
expect(getProviderByHeader({})).toBeNull();
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
});
});

View File

@@ -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<ArrayBuffer>;
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 {
}
});

View File

@@ -0,0 +1 @@
/etc/passwd

View File

@@ -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<typeof import("@dokploy/server/constants")>();
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);
});
});

View File

@@ -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);
});
});

View File

@@ -1,4 +1,4 @@
import { Paintbrush } from "lucide-react";
import { Ban } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
@@ -35,7 +35,7 @@ export const CancelQueues = ({ id, type }: Props) => {
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
Cancel Queues
<Paintbrush className="size-4" />
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -0,0 +1,73 @@
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const ClearDeployments = ({ id, type }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } =
type === "application"
? api.application.clearDeployments.useMutation()
: api.compose.clearDeployments.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
Clear deployments
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to clear old deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all old deployment records and logs, keeping only
the active deployment (the most recent successful one).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(async () => {
toast.success("Old deployments cleared successfully");
await utils.deployment.allByType.invalidate({
id,
type: type as "application" | "compose",
});
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -6,6 +6,7 @@ import {
RefreshCcw,
RocketIcon,
Settings,
Trash2,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -25,6 +26,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { ClearDeployments } from "./clear-deployments";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -77,6 +79,8 @@ export const ShowDeployments = ({
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isLoading: isRemovingDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
const {
@@ -144,6 +148,9 @@ export const ShowDeployments = ({
</CardDescription>
</div>
<div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<ClearDeployments id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
@@ -252,6 +259,8 @@ export const ShowDeployments = ({
const isExpanded = expandedDescriptions.has(
deployment.deploymentId,
);
const canDelete =
deployment.status === "done" || deployment.status === "error";
return (
<div
@@ -370,6 +379,33 @@ export const ShowDeployments = ({
View
</Button>
{canDelete && (
<DialogAction
title="Delete Deployment"
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
});
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
>
Delete
<Trash2 className="size-4" />
</Button>
</DialogAction>
)}
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (

View File

@@ -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",

View File

@@ -152,6 +152,10 @@ export default async function handler(
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "soft-serve") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
}
const shouldDeployPaths = shouldDeploy(
@@ -439,6 +443,13 @@ export const extractCommitMessage = (headers: any, body: any) => {
: "NEW COMMIT";
}
// Soft Serve
if (headers["x-softserve-event"]) {
return body.commits && body.commits.length > 0
? body.commits[0].message
: "NEW COMMIT";
}
if (headers["user-agent"]?.includes("Go-http-client")) {
if (body.push_data && body.repository) {
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
@@ -476,6 +487,11 @@ export const extractHash = (headers: any, body: any) => {
return body.after || "NEW COMMIT";
}
// Soft Serve
if (headers["x-softserve-event"]) {
return body.after || "NEW COMMIT";
}
return "";
};
@@ -484,7 +500,10 @@ export const extractBranchName = (headers: any, body: any) => {
return body?.ref?.replace("refs/heads/", "");
}
if (headers["x-gitlab-event"]) {
if (
headers["x-gitlab-event"] ||
headers["x-softserve-event"]?.includes("push")
) {
return body?.ref ? body?.ref.replace("refs/heads/", "") : null;
}
@@ -512,6 +531,10 @@ export const getProviderByHeader = (headers: any) => {
return "bitbucket";
}
if (headers["x-softserve-event"]) {
return "soft-serve";
}
return null;
};

View File

@@ -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) {
@@ -254,7 +253,6 @@ export default function Home({ IS_CLOUD }: Props) {
onChange={setTwoFactorCode}
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
autoComplete="off"
autoFocus
>
<InputOTPGroup>

View File

@@ -1,6 +1,7 @@
import {
addNewService,
checkServiceAccess,
clearOldDeployments,
createApplication,
deleteAllMiddlewares,
findApplicationById,
@@ -746,6 +747,23 @@ export const applicationRouter = createTRPCRouter({
}
await cleanQueuesByApplication(input.applicationId);
}),
clearDeployments: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this application",
});
}
await clearOldDeployments(application.appName, application.serverId);
return true;
}),
killBuild: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {

View File

@@ -2,6 +2,7 @@ import {
addDomainToCompose,
addNewService,
checkServiceAccess,
clearOldDeployments,
cloneCompose,
createCommand,
createCompose,
@@ -263,6 +264,23 @@ export const composeRouter = createTRPCRouter({
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this compose",
});
}
await clearOldDeployments(compose.appName, compose.serverId);
return true;
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {

View File

@@ -8,6 +8,7 @@ import {
findComposeById,
findDeploymentById,
findServerById,
removeDeployment,
updateDeploymentStatus,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -107,4 +108,14 @@ export const deploymentRouter = createTRPCRouter({
await updateDeploymentStatus(deployment.deploymentId, "error");
}),
removeDeployment: protectedProcedure
.input(
z.object({
deploymentId: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
return await removeDeployment(input.deploymentId);
}),
});

View File

@@ -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(

View File

@@ -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 };
}),
});

View File

@@ -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",

View File

@@ -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<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -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;

View File

@@ -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<typeof http.IncomingMessage, typeof http.ServerResponse>,

View File

@@ -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";

View File

@@ -126,7 +126,6 @@ const schema = createInsertSchema(deployments, {
previewDeploymentId: z.string(),
buildServerId: z.string(),
});
export const apiCreateDeployment = schema
.pick({
title: true,

View File

@@ -31,6 +31,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";

View File

@@ -13,7 +13,10 @@ import {
deployments,
} from "@dokploy/server/db/schema";
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { format } from "date-fns";
import { desc, eq } from "drizzle-orm";
@@ -554,8 +557,25 @@ export const removeDeployment = async (deploymentId: string) => {
const deployment = await db
.delete(deployments)
.where(eq(deployments.deploymentId, deploymentId))
.returning();
return deployment[0];
.returning()
.then((result) => result[0]);
if (!deployment) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment not found",
});
}
const command = `
rm -f ${deployment.logPath};
`;
if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command);
} else {
await execAsync(command);
}
return deployment;
} catch (error) {
const message =
error instanceof Error ? error.message : "Error creating the deployment";
@@ -831,3 +851,19 @@ export const findAllDeploymentsByServerId = async (serverId: string) => {
});
return deploymentsList;
};
export const clearOldDeployments = async (
appName: string,
serverId: string | null,
) => {
const { LOGS_PATH } = paths(!!serverId);
const folder = path.join(LOGS_PATH, appName);
const command = `
rm -rf ${folder};
`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};

View File

@@ -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
);
};

View File

@@ -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;
};

View File

@@ -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
);
}

View File

@@ -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)
);
};