mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge branch 'canary' into patches-impl
This commit is contained in:
22
.github/workflows/pr-quality.yml
vendored
Normal file
22
.github/workflows/pr-quality.yml
vendored
Normal 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
|
||||
@@ -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 {
|
||||
}
|
||||
});
|
||||
|
||||
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
@@ -0,0 +1 @@
|
||||
/etc/passwd
|
||||
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
Binary file not shown.
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
132
apps/dokploy/__test__/wss/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
24
packages/server/src/services/proprietary/license-key.ts
Normal file
24
packages/server/src/services/proprietary/license-key.ts
Normal 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
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user