From 63e48f6101e84e59af5552ce3194e9f14646e54d Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:31:29 -0400 Subject: [PATCH 1/2] feat(env): manage env variables without returning their values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Новый tRPC-роутер env. env.keys (GET /env.keys) отдаёт только имена переменных с флагами secret/empty и ревизией. env.applyChanges (POST /env.apply, operationId env-apply) задаёт, удаляет, генерирует на сервере и копирует переменные и возвращает только имя, действие и источник. - Области: application, compose, postgres, mysql, mariadb, mongo, redis, libsql, project, environment. Для application дополнительно доступны поля buildArgs и buildSecrets. - applyEnvChanges правит текст по грамматике dotenv: многострочное значение в кавычках заменяется и удаляется целиком, комментарии, пустые строки, порядок, export и завершающий перевод строки сохраняются. После правки текст заново разбирается и сверяется с ожидаемым набором переменных. - Значение записывается в форме, которую dotenv читает обратно символ в символ (JSON, обратные слэши, кавычки, многострочные блоки). - Чтение, проверка ревизии и запись идут в одной транзакции с блокировкой строки. Источник copy читается в той же транзакции. - Добавлен общий хелпер ревизии getEnvFieldRevision. Для application/env и compose используются существующие ревизии, поэтому они совместимы с application.envUpsert и compose.env.upsert. - Регулярное выражение имени переменной вынесено в одно место, им пользуются и старые схемы upsert. - Тестовый fake-drizzle-db теперь умеет select ... for update и вложенные with. Зачем: - ИИ-агенту с API-ключом нужно управлять переменными окружения, не получая их значений: увидеть имена, задать значение, сгенерировать его на сервере, скопировать между сервисами, переименовать и удалить. Риски: - tRPC резервирует имя apply, поэтому процедура называется env.applyChanges. REST-путь /env.apply закреплён через meta.openapi, в procedure-access процедура записана как env.applyChanges. - Действие unchanged, в том числе на dryRun, показывает, совпало ли переданное или скопированное значение с текущим. Так же ведут себя существующие upsert-методы. - Имена берутся из разбора dotenv. В повреждённом файле, где секрет занимает несколько строк без кавычек, часть значения может оказаться именем переменной. - Значение, которое нельзя записать без искажения, и правка, меняющая разбор соседних строк, отклоняются с BAD_REQUEST и не сохраняются. - Поле, которое не удалось расшифровать, не читается и не правится (PRECONDITION_FAILED). - upsertEnvVariables и старые upsert-методы не менялись и остаются построчными. Проверки: - Команды и результаты: bun run typecheck в packages/server и в apps/dokploy — без ошибок; bun run test -- --run по пяти новым и изменённым файлам тестов — 5 файлов, 76 тестов прошли; bun run test -- --run __test__/security __test__/env — 38 файлов, 413 тестов прошли; bun run test -- --run целиком — 285 файлов прошли, 1 пропущен, 1 упал; biome check по 15 затронутым файлам — без замечаний. - Ограничения: упавший файл __test__/deploy/application.real.test.ts (4 теста) требует запущенного Docker, в этой среде демон недоступен (ECONNREFUSED на сокете Docker). Транзакция и блокировка строки проверены только на тестовой базе в памяти, на настоящем PostgreSQL и на живой панели ничего не запускалось. openapi.json в корне не перегенерирован. What: - New tRPC router env. env.keys (GET /env.keys) returns only variable names with secret/empty flags and a revision. env.applyChanges (POST /env.apply, operationId env-apply) sets, removes, generates server-side and copies variables and returns only the name, action and source. - Scopes: application, compose, postgres, mysql, mariadb, mongo, redis, libsql, project, environment. The application scope also takes the buildArgs and buildSecrets fields. - applyEnvChanges edits the text by the dotenv grammar: a multi-line quoted value is replaced and removed as a whole, while comments, blank lines, ordering, export and the trailing newline are kept. After the edit the text is parsed again and compared with the expected set of variables. - A value is written in a form that dotenv reads back character for character (JSON, backslashes, quotes, multi-line blocks). - The read, the revision check and the write run in one transaction with a row lock. A copy source is read in the same transaction. - Added the generic revision helper getEnvFieldRevision. application/env and compose reuse the existing revisions, so they interoperate with application.envUpsert and compose.env.upsert. - The variable name pattern now lives in one place and the existing upsert schemas use it. - The fake-drizzle-db test helper now supports select ... for update and nested with. Why: - An AI agent holding an API key has to manage environment variables without receiving their values: see the names, set a value, generate one on the server, copy between services, rename and remove. Risks: - tRPC reserves the name apply, so the procedure is env.applyChanges. The REST path /env.apply is pinned through meta.openapi, and procedure-access lists the procedure as env.applyChanges. - The unchanged action, also on a dry run, tells whether a sent or copied value equals the current one. The existing upsert endpoints behave the same way. - Names come from the dotenv parse. In a damaged file, where a secret spans several lines without quotes, a part of the value can show up as a variable name. - A value that cannot be stored without distortion, and an edit that would change how neighbouring lines parse, are rejected with BAD_REQUEST and not saved. - A field that could not be decrypted is neither listed nor edited (PRECONDITION_FAILED). - upsertEnvVariables and the existing upsert endpoints are untouched and stay line-based. Checks: - Commands and results: bun run typecheck in packages/server and in apps/dokploy — no errors; bun run test -- --run on the five new and changed test files — 5 files, 76 tests passed; bun run test -- --run __test__/security __test__/env — 38 files, 413 tests passed; the full bun run test -- --run — 285 files passed, 1 skipped, 1 failed; biome check on the 15 touched files — clean. - Limitations: the failing file __test__/deploy/application.real.test.ts (4 tests) needs a running Docker daemon, which is not available here (ECONNREFUSED on the Docker socket). The transaction and the row lock were only exercised against the in-memory test database; nothing ran against a real PostgreSQL or a live panel. The root openapi.json was not regenerated. Co-Authored-By: Claude Opus 5.5 --- .../__test__/api/openapi-document.test.ts | 108 ++ .../__test__/env/apply-changes.test.ts | 449 ++++++ .../__test__/env/blind-env-router.test.ts | 1307 +++++++++++++++++ .../__test__/helpers/fake-drizzle-db.ts | 34 +- .../security/super-session-blind-env.test.ts | 165 +++ apps/dokploy/server/api/root.ts | 2 + apps/dokploy/server/api/routers/env.ts | 131 ++ .../server/api/utils/procedure-access.ts | 2 + packages/server/src/db/schema/application.ts | 3 +- packages/server/src/db/schema/compose.ts | 3 +- .../server/src/db/schema/env-variables.ts | 171 +++ packages/server/src/db/schema/index.ts | 1 + packages/server/src/index.ts | 1 + packages/server/src/services/env-variables.ts | 380 +++++ packages/server/src/utils/env-upsert.ts | 250 ++++ 15 files changed, 2999 insertions(+), 8 deletions(-) create mode 100644 apps/dokploy/__test__/env/apply-changes.test.ts create mode 100644 apps/dokploy/__test__/env/blind-env-router.test.ts create mode 100644 apps/dokploy/__test__/security/super-session-blind-env.test.ts create mode 100644 apps/dokploy/server/api/routers/env.ts create mode 100644 packages/server/src/db/schema/env-variables.ts create mode 100644 packages/server/src/services/env-variables.ts diff --git a/apps/dokploy/__test__/api/openapi-document.test.ts b/apps/dokploy/__test__/api/openapi-document.test.ts index 7bc7ff47f20..22bfbf3f5b5 100644 --- a/apps/dokploy/__test__/api/openapi-document.test.ts +++ b/apps/dokploy/__test__/api/openapi-document.test.ts @@ -23,6 +23,114 @@ describe("OpenAPI document", () => { "detailLimit", ); }); + + it("serves blind env management at GET /env.keys and POST /env.apply", () => { + const paths = generate().paths ?? {}; + const keys = paths["/env.keys"]?.get as + | { operationId?: string; parameters?: { name: string; in: string }[] } + | undefined; + const apply = paths["/env.apply"]?.post as + | { operationId?: string; requestBody?: unknown } + | undefined; + + expect( + Object.keys(paths).filter((path) => path.startsWith("/env.")), + ).toEqual(["/env.keys", "/env.apply"]); + expect(Object.keys(paths["/env.keys"] ?? {})).toEqual(["get"]); + expect(Object.keys(paths["/env.apply"] ?? {})).toEqual(["post"]); + expect(keys?.operationId).toBe("env-keys"); + expect(keys?.parameters).toEqual([ + expect.objectContaining({ name: "scope", in: "query", required: true }), + expect.objectContaining({ name: "id", in: "query", required: true }), + expect.objectContaining({ name: "field", in: "query" }), + ]); + expect(apply?.operationId).toBe("env-apply"); + expect( + Object.keys( + ( + apply?.requestBody as { + content: { + "application/json": { schema: { properties: object } }; + }; + } + ).content["application/json"].schema.properties, + ), + ).toEqual([ + "scope", + "id", + "field", + "set", + "remove", + "generate", + "copy", + "dryRun", + "expectedRevision", + ]); + }); + + it("describes blind env responses without any value field", () => { + const paths = generate().paths ?? {}; + const responseSchema = (operation: unknown) => + ( + operation as { + responses: { + 200: { + content: { + "application/json": { + schema: { + additionalProperties: boolean; + properties: { + variables: { + items: { + additionalProperties: boolean; + properties: object; + }; + }; + }; + }; + }; + }; + }; + }; + } + ).responses[200].content["application/json"].schema; + const keys = responseSchema(paths["/env.keys"]?.get); + const apply = responseSchema(paths["/env.apply"]?.post); + + expect(Object.keys(keys.properties)).toEqual([ + "scope", + "id", + "field", + "revision", + "variables", + ]); + expect(Object.keys(keys.properties.variables.items.properties)).toEqual([ + "name", + "secret", + "empty", + ]); + expect(Object.keys(apply.properties)).toEqual([ + "scope", + "id", + "field", + "changed", + "revision", + "dryRun", + "variables", + ]); + expect(Object.keys(apply.properties.variables.items.properties)).toEqual([ + "name", + "action", + "source", + "secret", + ]); + for (const schema of [keys, apply]) { + expect(schema.additionalProperties).toBe(false); + expect(schema.properties.variables.items.additionalProperties).toBe( + false, + ); + } + }); }); describe("dockerDiskUsageDetailLimitSchema", () => { diff --git a/apps/dokploy/__test__/env/apply-changes.test.ts b/apps/dokploy/__test__/env/apply-changes.test.ts new file mode 100644 index 00000000000..02d05d8126d --- /dev/null +++ b/apps/dokploy/__test__/env/apply-changes.test.ts @@ -0,0 +1,449 @@ +import { + applyEnvChanges, + type EnvChange, + getApplicationEnvRevision, + getComposeEnvRevision, + getEnvFieldRevision, + getEnvVariableValue, + listEnvVariables, +} from "@dokploy/server/utils/env-upsert"; +import { parse } from "dotenv"; +import { describe, expect, it } from "vitest"; + +const SENTINEL = "sentinel-value-0001"; + +describe("listEnvVariables", () => { + it("lists every name once in file order with the last assignment deciding the flags", () => { + const variables = listEnvVariables(`# header +SERVICE_URL=https://service.example.test +SERVICE_TOKEN=${SENTINEL} +EMPTY_FLAG= +export EXPORTED_KEY="${SENTINEL}" + +SERVICE_URL= +QUOTED_EMPTY="" +`); + + expect(variables).toEqual([ + { name: "SERVICE_URL", secret: false, empty: true }, + { name: "SERVICE_TOKEN", secret: true, empty: false }, + { name: "EMPTY_FLAG", secret: false, empty: true }, + { name: "EXPORTED_KEY", secret: true, empty: false }, + { name: "QUOTED_EMPTY", secret: false, empty: true }, + ]); + expect(JSON.stringify(variables)).not.toContain(SENTINEL); + }); + + it("does not mistake lines of a multi-line value or comments for variables", () => { + const variables = listEnvVariables(`PRIVATE_BLOCK="first line +INNER_NAME=${SENTINEL} +last line" +# COMMENTED_OUT=${SENTINEL} +AFTER_BLOCK=1`); + + expect(variables.map(({ name }) => name)).toEqual([ + "PRIVATE_BLOCK", + "AFTER_BLOCK", + ]); + }); + + it("treats a missing field as empty text", () => { + expect(listEnvVariables(null)).toEqual([]); + expect(listEnvVariables(undefined)).toEqual([]); + expect(getEnvVariableValue(null, "ANY_NAME")).toBeUndefined(); + }); +}); + +describe("applyEnvChanges", () => { + it("creates, updates and leaves variables unchanged while keeping the layout", () => { + const result = applyEnvChanges( + `# Service settings +export SERVICE_URL = https://old.example.test # public address + + INDENTED_NAME=old +SERVICE_TOKEN=${SENTINEL} +`, + [ + { + name: "SERVICE_URL", + source: "set", + value: "https://new.example.test", + }, + { name: "INDENTED_NAME", source: "set", value: "two words" }, + { name: "SERVICE_TOKEN", source: "set", value: SENTINEL }, + { name: "NEW_NAME", source: "set", value: "created" }, + ], + ); + + expect(result.changed).toBe(true); + expect(result.env).toBe(`# Service settings +export SERVICE_URL = https://new.example.test # public address + + INDENTED_NAME="two words" +SERVICE_TOKEN=${SENTINEL} +NEW_NAME=created +`); + expect(result.variables).toEqual([ + { name: "SERVICE_URL", action: "updated", source: "set", secret: false }, + { + name: "INDENTED_NAME", + action: "updated", + source: "set", + secret: false, + }, + { + name: "SERVICE_TOKEN", + action: "unchanged", + source: "set", + secret: true, + }, + { name: "NEW_NAME", action: "created", source: "set", secret: false }, + ]); + }); + + it("returns the stored text untouched when nothing changes", () => { + const env = "FIRST=1\r\nSECOND=2\r\n"; + const result = applyEnvChanges(env, [ + { name: "FIRST", source: "set", value: "1" }, + { name: "ABSENT", source: "remove" }, + ]); + + expect(result).toEqual({ + env, + changed: false, + variables: [ + { name: "FIRST", action: "unchanged", source: "set", secret: false }, + { name: "ABSENT", action: "missing", source: "remove", secret: false }, + ], + }); + }); + + it("starts a file from an empty or missing field", () => { + for (const current of [null, undefined, ""]) { + expect( + applyEnvChanges(current, [ + { name: "FIRST", source: "set", value: "1" }, + { name: "SECOND", source: "copy", value: "" }, + ]).env, + ).toBe("FIRST=1\nSECOND="); + } + }); + + it("updates the effective assignment when a name is assigned twice", () => { + const result = applyEnvChanges("NAME=first\nOTHER=1\nNAME=second\n", [ + { name: "NAME", source: "set", value: "third" }, + ]); + + expect(result.env).toBe("NAME=first\nOTHER=1\nNAME=third\n"); + expect(parse(result.env).NAME).toBe("third"); + }); + + it("removes every assignment of a name and reports names that do not exist", () => { + const result = applyEnvChanges( + `# keep me +NAME=first +OTHER=1 # inline + +export NAME=second +`, + [ + { name: "NAME", source: "remove" }, + { name: "ABSENT", source: "remove" }, + ], + ); + + expect(result.env).toBe(`# keep me +OTHER=1 # inline + +`); + expect(result.variables).toEqual([ + { name: "NAME", action: "removed", source: "remove", secret: false }, + { name: "ABSENT", action: "missing", source: "remove", secret: false }, + ]); + }); + + it("keeps the trailing newline convention of the original text on removal", () => { + expect( + applyEnvChanges("FIRST=1\nLAST=2", [{ name: "LAST", source: "remove" }]) + .env, + ).toBe("FIRST=1"); + expect( + applyEnvChanges("FIRST=1\nLAST=2\n", [{ name: "LAST", source: "remove" }]) + .env, + ).toBe("FIRST=1\n"); + expect( + applyEnvChanges("ONLY=1\n", [{ name: "ONLY", source: "remove" }]).env, + ).toBe(""); + }); + + it("replaces the whole span of a multi-line quoted value", () => { + for (const quote of ['"', "'", "`"]) { + const result = applyEnvChanges( + `BEFORE=1 +PRIVATE_BLOCK=${quote}first line +INNER_NAME=${SENTINEL} +last line${quote} # trailing note +AFTER=2 +`, + [{ name: "PRIVATE_BLOCK", source: "set", value: "single" }], + ); + + expect(result.env).toBe(`BEFORE=1 +PRIVATE_BLOCK=single # trailing note +AFTER=2 +`); + expect(result.env).not.toContain(SENTINEL); + } + }); + + it("removes the whole span of a multi-line quoted value", () => { + for (const quote of ['"', "'", "`"]) { + const result = applyEnvChanges( + `BEFORE=1 +PRIVATE_BLOCK=${quote}first line +INNER_NAME=${SENTINEL} +last line${quote} +AFTER=2`, + [{ name: "PRIVATE_BLOCK", source: "remove" }], + ); + + expect(result.env).toBe("BEFORE=1\nAFTER=2"); + } + }); + + it("does not edit a line inside a multi-line value that looks like an assignment", () => { + const env = `PRIVATE_BLOCK="first line +INNER_NAME=inside +last line" +`; + const created = applyEnvChanges(env, [ + { name: "INNER_NAME", source: "set", value: "outside" }, + ]); + const missing = applyEnvChanges(env, [ + { name: "INNER_NAME", source: "remove" }, + ]); + + expect(created.variables[0]?.action).toBe("created"); + expect(created.env).toBe(`${env}INNER_NAME=outside\n`); + expect(parse(created.env).PRIVATE_BLOCK).toBe( + "first line\nINNER_NAME=inside\nlast line", + ); + expect(missing.variables[0]?.action).toBe("missing"); + expect(missing.env).toBe(env); + }); + + it("stores values so that the deploy-time parser reads back exactly what was sent", () => { + const values = { + JSON_VALUE: '{"name":"app-1","port":8080}', + WINDOWS_PATH: "C:\\data\\new folder", + MULTILINE_VALUE: "first line\nsecond line", + MULTILINE_WITH_QUOTES: 'first "line"\nsecond line', + SINGLE_QUOTED: "it's here", + MIXED_QUOTES: 'it\'s "here"', + HASH_VALUE: "value # not a comment", + PADDED_VALUE: " padded ", + ESCAPE_LOOKALIKE: "keep \\n as two characters", + TRAILING_BACKSLASH: "ends with a backslash\\", + }; + const result = applyEnvChanges( + "EXISTING=1\n", + Object.entries(values).map(([name, value]) => ({ + name, + source: "set" as const, + value, + })), + ); + + expect(parse(result.env)).toEqual({ EXISTING: "1", ...values }); + expect(result.env).toContain('MULTILINE_VALUE="first line\\nsecond line"'); + }); + + it("refuses a value it cannot store exactly and names only the variable", () => { + const value = `${SENTINEL} 'one' "two" \`three\` # four`; + + expect(() => + applyEnvChanges("", [{ name: "ODD_VALUE", source: "set", value }]), + ).toThrow("Environment variable value cannot be stored safely: ODD_VALUE"); + try { + applyEnvChanges("", [{ name: "ODD_VALUE", source: "set", value }]); + } catch (error) { + expect(String(error)).not.toContain(SENTINEL); + } + }); + + it("refuses an edit that would change how other lines are parsed", () => { + const edit = () => + applyEnvChanges(`BROKEN_QUOTE="${SENTINEL}`, [ + { name: "NEXT_NAME", source: "set", value: " # note" }, + ]); + + expect(edit).toThrow( + "Environment variables cannot be edited safely: BROKEN_QUOTE, NEXT_NAME", + ); + try { + edit(); + } catch (error) { + expect(String(error)).not.toContain(SENTINEL); + } + }); + + it("previews a generated variable without touching the text", () => { + const env = "SERVICE_TOKEN=current\n"; + const result = applyEnvChanges(env, [ + { name: "SERVICE_TOKEN", source: "generate", value: null }, + { name: "NEW_TOKEN", source: "generate", value: null }, + ]); + + expect(result).toEqual({ + env, + changed: true, + variables: [ + { + name: "SERVICE_TOKEN", + action: "updated", + source: "generate", + secret: true, + }, + { + name: "NEW_TOKEN", + action: "created", + source: "generate", + secret: true, + }, + ], + }); + }); + + it("reports a generated variable as updated even when it replaces an equal value", () => { + const result = applyEnvChanges("SERVICE_TOKEN=same\n", [ + { name: "SERVICE_TOKEN", source: "generate", value: "same" }, + ]); + + expect(result.variables[0]?.action).toBe("updated"); + expect(result.env).toBe("SERVICE_TOKEN=same\n"); + }); + + it("keeps well-formed files consistent under seeded random edits", () => { + let seed = 0x9e3779b9; + const random = () => { + seed = (seed + 0x6d2b79f5) | 0; + let mixed = Math.imul(seed ^ (seed >>> 15), 1 | seed); + mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed; + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296; + }; + const pick = (items: readonly T[]) => + items[Math.floor(random() * items.length)] as T; + const names = ["FIRST", "SECOND_KEY", "THIRD1", "lower_name", "FIFTH"]; + const values = [ + "plain", + "two words", + "", + '{"port":8080}', + "it's", + "back\\slash", + "hash # tag", + "line one\nline two", + " padded ", + "`ticks`", + "name=value", + "trailing\\", + "\\n as text", + "before\nSECOND_KEY=inner\nafter", + "-----BEGIN BLOCK-----\nabc+/=\n-----END BLOCK-----\n", + ]; + const lines = [ + () => "# comment line", + () => "", + () => `${pick(names)}=${pick(["1", "abc", "", "x y", "v # c"])}`, + () => `export ${pick(names)}=exported`, + () => ` ${pick(names)} = spaced`, + () => `${pick(names)}="quoted value" # note`, + () => `${pick(names)}='single quoted'`, + () => `${pick(names)}="multi\nINNER=1\nline"`, + () => `${pick(names)}='multi\n# not a comment\nline'`, + () => `${pick(names)}=\`multi\nline\``, + () => "dotted.name=1", + ]; + const commentCount = (text: string) => + text.split("\n").filter((line) => line === "# comment line").length; + + for (let round = 0; round < 3000; round++) { + const body = Array.from({ length: Math.floor(random() * 7) }, () => + pick(lines)(), + ).join("\n"); + const env = random() < 0.5 && body ? `${body}\n` : body; + const changes = new Map(); + for (let count = 1 + Math.floor(random() * 3); count > 0; count--) { + const name = pick(names); + changes.set( + name, + random() < 0.3 + ? { name, source: "remove" } + : { name, source: "set", value: pick(values) }, + ); + } + + const expected = new Map(Object.entries(parse(env))); + for (const change of changes.values()) { + if (change.source === "remove") { + expected.delete(change.name); + } else if (change.value !== null) { + expected.set(change.name, change.value); + } + } + + const result = applyEnvChanges(env, [...changes.values()]); + const repeated = applyEnvChanges(result.env, [...changes.values()]); + + expect(new Map(Object.entries(parse(result.env)))).toEqual(expected); + expect(commentCount(result.env)).toBe(commentCount(env)); + expect(repeated.changed).toBe(false); + expect(repeated.env).toBe(result.env); + if (env.endsWith("\n") && result.env !== "") { + expect(result.env.endsWith("\n")).toBe(true); + } + } + }); +}); + +describe("getEnvFieldRevision", () => { + it("is stable and keyed by scope, id, field and text", () => { + const revision = getEnvFieldRevision( + "postgres", + "postgres-1", + "env", + `SERVICE_TOKEN=${SENTINEL}`, + ); + + expect(revision).toMatch(/^env-field-v1:[A-Za-z0-9_-]{43}$/); + expect(revision).not.toContain(SENTINEL); + expect(revision).toBe( + getEnvFieldRevision( + "postgres", + "postgres-1", + "env", + `SERVICE_TOKEN=${SENTINEL}`, + ), + ); + + const others = [ + getEnvFieldRevision("mysql", "postgres-1", "env", "A=1"), + getEnvFieldRevision("postgres", "postgres-2", "env", "A=1"), + getEnvFieldRevision("postgres", "postgres-1", "buildArgs", "A=1"), + getEnvFieldRevision("postgres", "postgres-1", "env", "A=2"), + getEnvFieldRevision("postgres", "postgres-1", "env", "A=1"), + getApplicationEnvRevision("postgres-1", "A=1"), + getComposeEnvRevision("postgres-1", "A=1"), + ]; + expect(new Set(others).size).toBe(others.length); + }); + + it("length-prefixes its parts so shifted boundaries give other revisions", () => { + expect(getEnvFieldRevision("project", "ab", "env", "c")).not.toBe( + getEnvFieldRevision("project", "a", "env", "bc"), + ); + expect(getEnvFieldRevision("project", "id", "env", null)).toBe( + getEnvFieldRevision("project", "id", "env", ""), + ); + }); +}); diff --git a/apps/dokploy/__test__/env/blind-env-router.test.ts b/apps/dokploy/__test__/env/blind-env-router.test.ts new file mode 100644 index 00000000000..bdadfe48029 --- /dev/null +++ b/apps/dokploy/__test__/env/blind-env-router.test.ts @@ -0,0 +1,1307 @@ +import { EventEmitter } from "node:events"; +import { + applications, + compose, + environments, + postgres, + projects, +} from "@dokploy/server/db/schema"; +import { + getApplicationEnvRevision, + getComposeEnvRevision, + getEnvFieldRevision, +} from "@dokploy/server/utils/env-upsert"; +import { createOpenApiNextHandler } from "@dokploy/trpc-openapi"; +import { TRPCError } from "@trpc/server"; +import { parse } from "dotenv"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + checkEnvironmentAccess: vi.fn(), + checkPermission: vi.fn(), + checkProjectAccess: vi.fn(), + checkServicePermissionAndAccess: vi.fn(), + findMemberByUserId: vi.fn(), +})); + +vi.mock("@dokploy/server/db", async () => { + const schema = await import("@dokploy/server/db/schema"); + const { createFakeDrizzleDb } = await import("../helpers/fake-drizzle-db"); + const fake = createFakeDrizzleDb({ + applications: schema.applications, + compose: schema.compose, + postgres: schema.postgres, + mysql: schema.mysql, + mariadb: schema.mariadb, + mongo: schema.mongo, + redis: schema.redis, + libsql: schema.libsql, + projects: schema.projects, + environments: schema.environments, + project: schema.projects, + environment: schema.environments, + webServerSettings: schema.webServerSettings, + }); + return { db: fake.db, fake }; +}); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkEnvironmentAccess: mocks.checkEnvironmentAccess, + checkPermission: mocks.checkPermission, + checkProjectAccess: mocks.checkProjectAccess, + checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess, + findMemberByUserId: mocks.findMemberByUserId, +})); + +vi.mock("@/server/api/utils/audit", () => ({ audit: mocks.audit })); + +const { fake } = (await import("@dokploy/server/db")) as unknown as { + fake: ReturnType< + typeof import("../helpers/fake-drizzle-db")["createFakeDrizzleDb"] + >; +}; +const { envRouter } = await import("@/server/api/routers/env"); +const { createTRPCRouter } = await import("@/server/api/trpc"); + +const SENTINEL = "sentinel-value-0001"; +const OTHER_SENTINEL = "sentinel-value-0002"; + +const router = createTRPCRouter({ env: envRouter }); + +const createContext = (role: "owner" | "member" = "owner") => + ({ + db: {}, + req: {}, + res: {}, + session: { userId: "user-1", activeOrganizationId: "org-1" }, + user: { id: "user-1", role, email: "user-1@example.test" }, + }) as never; + +const createCaller = (role: "owner" | "member" = "owner") => + router.createCaller(createContext(role)).env; + +const callRest = async ( + method: "GET" | "POST", + path: string, + payload: Record, +) => { + const request = Object.assign(new EventEmitter(), { + method, + url: `/api/${path}`, + headers: { host: "localhost", "content-type": "application/json" }, + query: { trpc: [path], ...(method === "GET" ? payload : {}) }, + socket: {}, + ...(method === "POST" ? { body: payload } : {}), + }); + const response = Object.assign(new EventEmitter(), { + statusCode: 0, + body: undefined as unknown, + setHeader: () => response, + end: (body?: string) => { + response.body = body ? JSON.parse(body) : undefined; + }, + }); + + await createOpenApiNextHandler({ + router, + createContext: () => createContext(), + })(request as never, response as never); + + return { status: response.statusCode, body: response.body }; +}; + +const ctxMatcher = expect.objectContaining({ + session: expect.objectContaining({ activeOrganizationId: "org-1" }), +}); + +const storedEnv = ( + table: Parameters[0], + field = "env", + index = 0, +) => fake.rows(table)[index]?.[field] as string | null; + +const seed = () => { + fake.seed(projects, { + projectId: "project-1", + name: "Project One", + organizationId: "org-1", + env: `SHARED_URL=https://shared.example.test\nSHARED_TOKEN=${SENTINEL}\n`, + }); + fake.seed(projects, { + projectId: "project-2", + name: "Foreign Project", + organizationId: "org-2", + env: `FOREIGN_TOKEN=${OTHER_SENTINEL}\n`, + }); + fake.seed(environments, { + environmentId: "environment-1", + name: "production", + projectId: "project-1", + env: `STAGE_TOKEN=${SENTINEL}\n`, + }); + fake.seed(environments, { + environmentId: "environment-2", + name: "foreign", + projectId: "project-2", + env: "", + }); + fake.seed(applications, { + applicationId: "app-1", + name: "App One", + appName: "app-one", + environmentId: "environment-1", + env: `# App settings\nSERVICE_URL=https://app.example.test\nSERVICE_TOKEN=${SENTINEL}\nEMPTY_FLAG=\nSERVICE_URL=https://override.example.test\n`, + buildArgs: "BUILD_MODE=release\n", + buildSecrets: null, + }); + fake.seed(applications, { + applicationId: "app-2", + name: "Foreign App", + appName: "foreign-app", + environmentId: "environment-2", + env: `FOREIGN_TOKEN=${OTHER_SENTINEL}\n`, + }); + fake.seed(compose, { + composeId: "compose-1", + name: "Compose One", + appName: "compose-one", + environmentId: "environment-1", + env: null, + }); + fake.seed(postgres, { + postgresId: "postgres-1", + name: "Postgres One", + appName: "postgres-one", + environmentId: "environment-1", + databaseName: "app", + databaseUser: "app", + databasePassword: "fixture-password", + dockerImage: "postgres:18", + env: `POSTGRES_INITDB_ARGS=--data-checksums\nREPLICATION_PASSWORD=${OTHER_SENTINEL}\n`, + }); +}; + +const expectTrpcError = async (promise: Promise, code: string) => { + const error = await promise.then( + () => null, + (reason: unknown) => reason, + ); + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe(code); + return error as TRPCError; +}; + +beforeEach(() => { + fake.reset(); + vi.resetAllMocks(); + mocks.findMemberByUserId.mockResolvedValue({ + accessedProjects: ["project-1"], + accessedEnvironments: ["environment-1"], + }); + seed(); +}); + +describe("env.keys", () => { + it("lists names with flags and a revision but never a value", async () => { + const result = await createCaller().keys({ + scope: "application", + id: "app-1", + }); + + expect(result).toEqual({ + scope: "application", + id: "app-1", + field: "env", + revision: getApplicationEnvRevision("app-1", storedEnv(applications)), + variables: [ + { name: "SERVICE_URL", secret: false, empty: false }, + { name: "SERVICE_TOKEN", secret: true, empty: false }, + { name: "EMPTY_FLAG", secret: false, empty: true }, + ], + }); + expect(JSON.stringify(result)).not.toContain(SENTINEL); + expect(JSON.stringify(result)).not.toContain("example.test"); + expect( + mocks.checkServicePermissionAndAccess, + ).toHaveBeenCalledExactlyOnceWith(ctxMatcher, "app-1", { + envVars: ["read"], + }); + expect(fake.locks).toEqual([]); + }); + + it("reads build args and build secrets of an application", async () => { + const buildArgs = await createCaller().keys({ + scope: "application", + id: "app-1", + field: "buildArgs", + }); + const buildSecrets = await createCaller().keys({ + scope: "application", + id: "app-1", + field: "buildSecrets", + }); + + expect(buildArgs.variables).toEqual([ + { name: "BUILD_MODE", secret: false, empty: false }, + ]); + expect(buildArgs.revision).toBe( + getEnvFieldRevision( + "application", + "app-1", + "buildArgs", + "BUILD_MODE=release\n", + ), + ); + expect(buildSecrets.variables).toEqual([]); + expect(buildSecrets.revision).toBe( + getEnvFieldRevision("application", "app-1", "buildSecrets", ""), + ); + }); + + it("rejects build fields for every other scope", async () => { + for (const scope of ["compose", "postgres", "project"] as const) { + await expectTrpcError( + createCaller().keys({ scope, id: "any-id", field: "buildArgs" }), + "BAD_REQUEST", + ); + } + expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled(); + }); + + it("checks project access and the project variable permission", async () => { + const result = await createCaller("member").keys({ + scope: "project", + id: "project-1", + }); + + expect(result.variables.map(({ name }) => name)).toEqual([ + "SHARED_URL", + "SHARED_TOKEN", + ]); + expect(result.revision).toBe( + getEnvFieldRevision("project", "project-1", "env", storedEnv(projects)), + ); + expect(mocks.findMemberByUserId).toHaveBeenCalledWith("user-1", "org-1"); + expect(mocks.checkPermission).toHaveBeenCalledExactlyOnceWith(ctxMatcher, { + projectEnvVars: ["read"], + }); + expect(mocks.checkProjectAccess).not.toHaveBeenCalled(); + }); + + it("rejects a member who has no access to the project", async () => { + mocks.findMemberByUserId.mockResolvedValue({ accessedProjects: [] }); + + await expectTrpcError( + createCaller("member").keys({ scope: "project", id: "project-1" }), + "UNAUTHORIZED", + ); + expect(mocks.checkPermission).not.toHaveBeenCalled(); + }); + + it("checks environment access and the environment variable permission", async () => { + const result = await createCaller().keys({ + scope: "environment", + id: "environment-1", + }); + + expect(result.variables).toEqual([ + { name: "STAGE_TOKEN", secret: true, empty: false }, + ]); + expect(mocks.checkEnvironmentAccess).toHaveBeenCalledExactlyOnceWith( + ctxMatcher, + "environment-1", + "read", + ); + expect(mocks.checkPermission).toHaveBeenCalledExactlyOnceWith(ctxMatcher, { + environmentEnvVars: ["read"], + }); + }); + + it("rejects a resource of another organization before any permission check", async () => { + for (const input of [ + { scope: "application", id: "app-2" }, + { scope: "project", id: "project-2" }, + { scope: "environment", id: "environment-2" }, + ] as const) { + const error = await expectTrpcError( + createCaller().keys(input), + "UNAUTHORIZED", + ); + expect(error.message).not.toContain(OTHER_SENTINEL); + } + expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled(); + expect(mocks.checkPermission).not.toHaveBeenCalled(); + }); + + it("reports a missing resource and a scope that does not match the id", async () => { + await expectTrpcError( + createCaller().keys({ scope: "application", id: "missing" }), + "NOT_FOUND", + ); + await expectTrpcError( + createCaller().keys({ scope: "postgres", id: "app-1" }), + "NOT_FOUND", + ); + }); + + it("stops when the permission check fails", async () => { + mocks.checkServicePermissionAndAccess.mockRejectedValue( + new TRPCError({ code: "UNAUTHORIZED", message: "Permission denied" }), + ); + + await expectTrpcError( + createCaller().keys({ scope: "application", id: "app-1" }), + "UNAUTHORIZED", + ); + }); + + it("refuses to list a field that could not be decrypted", async () => { + fake.rows(postgres)[0]!.env = "enc:v1:bm90LXJlYWxseS1lbmNyeXB0ZWQ="; + + await expectTrpcError( + createCaller().keys({ scope: "postgres", id: "postgres-1" }), + "PRECONDITION_FAILED", + ); + }); +}); + +describe("env.applyChanges", () => { + it("sets literal values and reports created, updated and unchanged", async () => { + const before = storedEnv(applications); + const result = await createCaller().applyChanges({ + scope: "application", + id: "app-1", + set: { + SERVICE_URL: "https://next.example.test", + SERVICE_TOKEN: SENTINEL, + NEW_TOKEN: OTHER_SENTINEL, + }, + }); + + const after = storedEnv(applications); + expect(after).toBe( + `# App settings\nSERVICE_URL=https://app.example.test\nSERVICE_TOKEN=${SENTINEL}\nEMPTY_FLAG=\nSERVICE_URL=https://next.example.test\nNEW_TOKEN=${OTHER_SENTINEL}\n`, + ); + expect(result).toEqual({ + scope: "application", + id: "app-1", + field: "env", + changed: true, + revision: getApplicationEnvRevision("app-1", after), + dryRun: false, + variables: [ + { + name: "SERVICE_URL", + action: "updated", + source: "set", + secret: false, + }, + { + name: "SERVICE_TOKEN", + action: "unchanged", + source: "set", + secret: true, + }, + { name: "NEW_TOKEN", action: "created", source: "set", secret: true }, + ], + }); + expect(result.revision).not.toBe( + getApplicationEnvRevision("app-1", before), + ); + expect(JSON.stringify(result)).not.toContain(SENTINEL); + expect(JSON.stringify(result)).not.toContain("example.test"); + expect( + mocks.checkServicePermissionAndAccess, + ).toHaveBeenCalledExactlyOnceWith(ctxMatcher, "app-1", { + envVars: ["write"], + }); + expect(fake.locks).toEqual([{ table: "application", strength: "update" }]); + }); + + it("audits a change once without any variable data", async () => { + await createCaller().applyChanges({ + scope: "application", + id: "app-1", + set: { NEW_TOKEN: OTHER_SENTINEL }, + }); + + expect(mocks.audit).toHaveBeenCalledExactlyOnceWith(ctxMatcher, { + action: "update", + resourceType: "application", + resourceId: "app-1", + resourceName: "app-one", + }); + }); + + it("picks the audit resource type and name per scope", async () => { + const cases = [ + { + input: { scope: "compose", id: "compose-1" }, + event: { resourceType: "compose", resourceName: "Compose One" }, + }, + { + input: { scope: "postgres", id: "postgres-1" }, + event: { resourceType: "service", resourceName: "postgres-one" }, + }, + { + input: { scope: "project", id: "project-1" }, + event: { resourceType: "project", resourceName: "Project One" }, + }, + { + input: { scope: "environment", id: "environment-1" }, + event: { resourceType: "environment", resourceName: "production" }, + }, + ] as const; + + for (const { input, event } of cases) { + mocks.audit.mockClear(); + await createCaller().applyChanges({ ...input, set: { ADDED_NAME: "1" } }); + + expect(mocks.audit).toHaveBeenCalledExactlyOnceWith(ctxMatcher, { + action: "update", + resourceId: input.id, + ...event, + }); + } + }); + + it("does not write or audit when nothing changes", async () => { + const before = storedEnv(applications); + const result = await createCaller().applyChanges({ + scope: "application", + id: "app-1", + set: { SERVICE_TOKEN: SENTINEL }, + remove: ["ABSENT_NAME"], + }); + + expect(result.changed).toBe(false); + expect(result.revision).toBe(getApplicationEnvRevision("app-1", before)); + expect(result.variables.map(({ action }) => action)).toEqual([ + "unchanged", + "missing", + ]); + expect(storedEnv(applications)).toBe(before); + expect(mocks.audit).not.toHaveBeenCalled(); + }); + + it("removes variables and reports names that do not exist", async () => { + const result = await createCaller().applyChanges({ + scope: "application", + id: "app-1", + remove: ["SERVICE_URL", "ABSENT_NAME", "SERVICE_URL"], + }); + + expect(storedEnv(applications)).toBe( + `# App settings\nSERVICE_TOKEN=${SENTINEL}\nEMPTY_FLAG=\n`, + ); + expect(result.variables).toEqual([ + { + name: "SERVICE_URL", + action: "removed", + source: "remove", + secret: false, + }, + { + name: "ABSENT_NAME", + action: "missing", + source: "remove", + secret: false, + }, + ]); + }); + + it("updates and removes a multi-line value as one variable", async () => { + fake.rows(postgres)[0]!.env = + `BEFORE=1\nPRIVATE_BLOCK="first line\nINNER_NAME=${SENTINEL}\nlast line"\nAFTER=2\n`; + + await createCaller().applyChanges({ + scope: "postgres", + id: "postgres-1", + set: { PRIVATE_BLOCK: "one line" }, + }); + expect(storedEnv(postgres)).toBe( + 'BEFORE=1\nPRIVATE_BLOCK="one line"\nAFTER=2\n', + ); + + fake.rows(postgres)[0]!.env = + `BEFORE=1\nPRIVATE_BLOCK='first line\nINNER_NAME=${SENTINEL}\nlast line'\nAFTER=2\n`; + await createCaller().applyChanges({ + scope: "postgres", + id: "postgres-1", + remove: ["PRIVATE_BLOCK"], + }); + expect(storedEnv(postgres)).toBe("BEFORE=1\nAFTER=2\n"); + }); + + it("generates values server-side in the requested length and format", async () => { + const formats = { + DEFAULT_TOKEN: { options: {}, pattern: /^[A-Za-z0-9_-]{48}$/ }, + URL_TOKEN: { + options: { length: 64, format: "base64url" }, + pattern: /^[A-Za-z0-9_-]{64}$/, + }, + HEX_TOKEN: { + options: { length: 33, format: "hex" }, + pattern: /^[0-9a-f]{33}$/, + }, + PLAIN_TOKEN: { + options: { length: 128, format: "alphanumeric" }, + pattern: /^[A-Za-z0-9]{128}$/, + }, + SHORT_TOKEN: { + options: { length: 16, format: "alphanumeric" }, + pattern: /^[A-Za-z0-9]{16}$/, + }, + } as const; + const generate = Object.fromEntries( + Object.entries(formats).map(([name, { options }]) => [name, options]), + ); + + const first = await createCaller().applyChanges({ + scope: "compose", + id: "compose-1", + generate, + }); + const firstValues = parse(storedEnv(compose) ?? ""); + const second = await createCaller().applyChanges({ + scope: "compose", + id: "compose-1", + generate, + }); + const secondValues = parse(storedEnv(compose) ?? ""); + + for (const [name, { pattern }] of Object.entries(formats)) { + expect(firstValues[name]).toMatch(pattern); + expect(secondValues[name]).toMatch(pattern); + expect(secondValues[name]).not.toBe(firstValues[name]); + expect(JSON.stringify(first)).not.toContain(firstValues[name]); + expect(JSON.stringify(second)).not.toContain(secondValues[name]); + } + expect(first.variables.map(({ action }) => action)).toEqual( + Object.keys(formats).map(() => "created"), + ); + expect(second.variables.map(({ action }) => action)).toEqual( + Object.keys(formats).map(() => "updated"), + ); + expect(first.variables.every(({ source }) => source === "generate")).toBe( + true, + ); + expect(first.revision).toBe( + getComposeEnvRevision( + "compose-1", + Object.entries(firstValues) + .map(([name, value]) => `${name}=${value}`) + .join("\n"), + ), + ); + expect(JSON.stringify(mocks.audit.mock.calls)).not.toContain( + secondValues.DEFAULT_TOKEN, + ); + }); + + it("generates nothing on a dry run and reports the would-be actions", async () => { + const before = storedEnv(applications); + const result = await createCaller().applyChanges({ + scope: "application", + id: "app-1", + generate: { SERVICE_TOKEN: {}, NEW_TOKEN: { format: "hex" } }, + set: { SERVICE_URL: "https://next.example.test" }, + remove: ["EMPTY_FLAG"], + dryRun: true, + }); + + expect(result).toEqual({ + scope: "application", + id: "app-1", + field: "env", + changed: true, + revision: getApplicationEnvRevision("app-1", before), + dryRun: true, + variables: [ + { + name: "SERVICE_URL", + action: "updated", + source: "set", + secret: false, + }, + { + name: "SERVICE_TOKEN", + action: "updated", + source: "generate", + secret: true, + }, + { + name: "NEW_TOKEN", + action: "created", + source: "generate", + secret: true, + }, + { + name: "EMPTY_FLAG", + action: "removed", + source: "remove", + secret: false, + }, + ], + }); + expect(storedEnv(applications)).toBe(before); + expect(mocks.audit).not.toHaveBeenCalled(); + }); + + it("rejects generate options outside the allowed range", async () => { + for (const options of [ + { length: 15 }, + { length: 129 }, + { length: 32.5 }, + { format: "base64" }, + ]) { + await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-1", + generate: { NEW_TOKEN: options as never }, + }), + "BAD_REQUEST", + ); + } + }); + + it("copies a variable from another scope without returning it", async () => { + const result = await createCaller().applyChanges({ + scope: "application", + id: "app-1", + copy: { + UPSTREAM_TOKEN: { + fromScope: "project", + fromId: "project-1", + fromName: "SHARED_TOKEN", + }, + REPLICA_PASSWORD: { + fromScope: "postgres", + fromId: "postgres-1", + fromName: "REPLICATION_PASSWORD", + }, + SERVICE_TOKEN: { + fromScope: "environment", + fromId: "environment-1", + fromName: "STAGE_TOKEN", + }, + }, + }); + + const values = parse(storedEnv(applications) ?? ""); + expect(values.UPSTREAM_TOKEN).toBe(SENTINEL); + expect(values.REPLICA_PASSWORD).toBe(OTHER_SENTINEL); + expect(result.variables).toEqual([ + { + name: "UPSTREAM_TOKEN", + action: "created", + source: "copy", + secret: true, + }, + { + name: "REPLICA_PASSWORD", + action: "created", + source: "copy", + secret: true, + }, + { + name: "SERVICE_TOKEN", + action: "unchanged", + source: "copy", + secret: true, + }, + ]); + expect(JSON.stringify(result)).not.toContain(SENTINEL); + expect(JSON.stringify(result)).not.toContain(OTHER_SENTINEL); + expect(JSON.stringify(mocks.audit.mock.calls)).not.toContain(SENTINEL); + + expect(mocks.checkServicePermissionAndAccess.mock.calls).toEqual([ + [ctxMatcher, "app-1", { envVars: ["write"] }], + [ctxMatcher, "postgres-1", { envVars: ["read"] }], + ]); + expect(mocks.checkPermission.mock.calls).toEqual([ + [ctxMatcher, { projectEnvVars: ["read"] }], + [ctxMatcher, { environmentEnvVars: ["read"] }], + ]); + expect(mocks.checkEnvironmentAccess).toHaveBeenCalledExactlyOnceWith( + ctxMatcher, + "environment-1", + "read", + ); + expect(mocks.checkProjectAccess).not.toHaveBeenCalled(); + expect(fake.locks).toEqual([{ table: "application", strength: "update" }]); + }); + + it("renames a variable by copying and removing within one field", async () => { + const result = await createCaller().applyChanges({ + scope: "application", + id: "app-1", + copy: { + RENAMED_TOKEN: { + fromScope: "application", + fromId: "app-1", + fromName: "SERVICE_TOKEN", + }, + }, + remove: ["SERVICE_TOKEN"], + }); + + expect(parse(storedEnv(applications) ?? "")).toEqual({ + SERVICE_URL: "https://override.example.test", + EMPTY_FLAG: "", + RENAMED_TOKEN: SENTINEL, + }); + expect(result.variables).toEqual([ + { + name: "RENAMED_TOKEN", + action: "created", + source: "copy", + secret: true, + }, + { + name: "SERVICE_TOKEN", + action: "removed", + source: "remove", + secret: true, + }, + ]); + expect(mocks.checkServicePermissionAndAccess.mock.calls).toEqual([ + [ctxMatcher, "app-1", { envVars: ["write"] }], + [ctxMatcher, "app-1", { envVars: ["read"] }], + ]); + }); + + it("copies between fields of one application", async () => { + await createCaller().applyChanges({ + scope: "application", + id: "app-1", + field: "buildSecrets", + copy: { + BUILD_TOKEN: { + fromScope: "application", + fromId: "app-1", + fromName: "SERVICE_TOKEN", + }, + BUILD_MODE: { + fromScope: "application", + fromId: "app-1", + fromField: "buildArgs", + fromName: "BUILD_MODE", + }, + }, + }); + + expect(storedEnv(applications, "buildSecrets")).toBe( + `BUILD_TOKEN=${SENTINEL}\nBUILD_MODE=release`, + ); + expect(storedEnv(applications, "buildArgs")).toBe("BUILD_MODE=release\n"); + }); + + it("rejects a copy whose source variable does not exist, naming only names", async () => { + const before = storedEnv(applications); + const error = await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-1", + set: { NEW_NAME: OTHER_SENTINEL }, + copy: { + UPSTREAM_TOKEN: { + fromScope: "project", + fromId: "project-1", + fromName: "ABSENT_NAME", + }, + }, + }), + "BAD_REQUEST", + ); + + expect(error.message).toBe( + "Variable ABSENT_NAME does not exist in env of project project-1", + ); + expect(storedEnv(applications)).toBe(before); + expect(mocks.audit).not.toHaveBeenCalled(); + }); + + it("requires read access to the organization of a copy source", async () => { + const before = storedEnv(applications); + const copyFrom = (fromScope: "application" | "project", fromId: string) => + createCaller().applyChanges({ + scope: "application", + id: "app-1", + copy: { + STOLEN_TOKEN: { fromScope, fromId, fromName: "FOREIGN_TOKEN" }, + }, + }); + + await expectTrpcError(copyFrom("application", "app-2"), "UNAUTHORIZED"); + await expectTrpcError(copyFrom("project", "project-2"), "UNAUTHORIZED"); + await expectTrpcError(copyFrom("project", "missing"), "NOT_FOUND"); + expect(storedEnv(applications)).toBe(before); + expect(fake.locks).toEqual([]); + }); + + it("does not copy when the read permission on the source is denied", async () => { + mocks.checkServicePermissionAndAccess.mockImplementation( + async (_ctx, _id, permissions) => { + if (permissions.envVars.includes("read")) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Permission denied", + }); + } + }, + ); + const before = storedEnv(applications); + + await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-1", + copy: { + REPLICA_PASSWORD: { + fromScope: "postgres", + fromId: "postgres-1", + fromName: "REPLICATION_PASSWORD", + }, + }, + }), + "UNAUTHORIZED", + ); + expect(storedEnv(applications)).toBe(before); + }); + + it("rejects a name that appears in more than one operation list", async () => { + const copy = { + fromScope: "project", + fromId: "project-1", + fromName: "SHARED_TOKEN", + } as const; + const conflicts = [ + { set: { SAME_NAME: "1" }, remove: ["SAME_NAME"] }, + { set: { SAME_NAME: "1" }, generate: { SAME_NAME: {} } }, + { set: { SAME_NAME: "1" }, copy: { SAME_NAME: copy } }, + { generate: { SAME_NAME: {} }, copy: { SAME_NAME: copy } }, + { generate: { SAME_NAME: {} }, remove: ["SAME_NAME"] }, + { copy: { SAME_NAME: copy }, remove: ["SAME_NAME"] }, + ]; + + for (const operations of conflicts) { + await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-1", + ...operations, + }), + "BAD_REQUEST", + ); + } + expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled(); + }); + + it("requires at least one operation and valid variable names", async () => { + const invalid = [ + {}, + { set: {}, remove: [], generate: {}, copy: {} }, + { set: { "1INVALID": "1" } }, + { remove: ["HAS-DASH"] }, + { generate: { "HAS SPACE": {} } }, + { + copy: { + VALID_NAME: { + fromScope: "project", + fromId: "project-1", + fromName: "has.dot", + }, + }, + }, + ]; + + for (const operations of invalid) { + await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-1", + ...(operations as object), + }), + "BAD_REQUEST", + ); + } + }); + + it("rejects redacted placeholders instead of storing them", async () => { + const before = storedEnv(applications); + + for (const placeholder of ["__DOKPLOY_REDACTED_SECRET__", "[REDACTED]"]) { + const error = await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-1", + set: { SERVICE_TOKEN: placeholder, SERVICE_URL: "https://x.test" }, + }), + "BAD_REQUEST", + ); + expect(error.message).toBe( + "Environment variables contain redacted secret placeholders: SERVICE_TOKEN", + ); + } + expect(storedEnv(applications)).toBe(before); + expect(fake.locks).toEqual([]); + }); + + it("rejects a stale expected revision and accepts the current one", async () => { + const before = storedEnv(postgres); + const current = getEnvFieldRevision( + "postgres", + "postgres-1", + "env", + before, + ); + + const error = await expectTrpcError( + createCaller().applyChanges({ + scope: "postgres", + id: "postgres-1", + set: { ADDED_NAME: "1" }, + expectedRevision: getEnvFieldRevision( + "postgres", + "postgres-1", + "env", + "OLDER=1", + ), + }), + "CONFLICT", + ); + expect(error.message).toBe("Environment revision does not match"); + expect(storedEnv(postgres)).toBe(before); + + const result = await createCaller().applyChanges({ + scope: "postgres", + id: "postgres-1", + set: { ADDED_NAME: "1" }, + expectedRevision: current, + }); + expect(result.revision).toBe( + getEnvFieldRevision("postgres", "postgres-1", "env", storedEnv(postgres)), + ); + expect(result.revision).not.toBe(current); + }); + + it("shares revisions with the application and compose upsert endpoints", async () => { + const application = await createCaller().applyChanges({ + scope: "application", + id: "app-1", + set: { ADDED_NAME: "1" }, + expectedRevision: getApplicationEnvRevision( + "app-1", + storedEnv(applications), + ), + }); + const composeResult = await createCaller().applyChanges({ + scope: "compose", + id: "compose-1", + set: { ADDED_NAME: "1" }, + expectedRevision: getComposeEnvRevision("compose-1", null), + }); + + expect(application.revision).toBe( + getApplicationEnvRevision("app-1", storedEnv(applications)), + ); + expect(composeResult.revision).toBe( + getComposeEnvRevision("compose-1", "ADDED_NAME=1"), + ); + expect(storedEnv(compose)).toBe("ADDED_NAME=1"); + }); + + it("edits build args and build secrets of an application only", async () => { + const result = await createCaller().applyChanges({ + scope: "application", + id: "app-1", + field: "buildArgs", + set: { BUILD_MODE: "debug", BUILD_TOKEN: SENTINEL }, + }); + + expect(storedEnv(applications, "buildArgs")).toBe( + `BUILD_MODE=debug\nBUILD_TOKEN=${SENTINEL}\n`, + ); + expect(result.field).toBe("buildArgs"); + expect(result.revision).toBe( + getEnvFieldRevision( + "application", + "app-1", + "buildArgs", + storedEnv(applications, "buildArgs"), + ), + ); + expect(storedEnv(applications)).toContain("SERVICE_URL="); + + for (const scope of ["compose", "postgres", "environment"] as const) { + await expectTrpcError( + createCaller().applyChanges({ + scope, + id: "any-id", + field: "buildSecrets", + set: { BUILD_TOKEN: "1" }, + }), + "BAD_REQUEST", + ); + } + await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-1", + copy: { + BUILD_TOKEN: { + fromScope: "project", + fromId: "project-1", + fromField: "buildArgs", + fromName: "SHARED_TOKEN", + }, + }, + }), + "BAD_REQUEST", + ); + }); + + it("applies the project update checks and the project variable permission", async () => { + await createCaller("member").applyChanges({ + scope: "project", + id: "project-1", + set: { SHARED_URL: "https://next.example.test" }, + }); + + expect(parse(storedEnv(projects) ?? "").SHARED_URL).toBe( + "https://next.example.test", + ); + expect(mocks.checkProjectAccess).toHaveBeenCalledExactlyOnceWith( + ctxMatcher, + "update", + "project-1", + ); + expect(mocks.findMemberByUserId).toHaveBeenCalledWith("user-1", "org-1"); + expect(mocks.checkPermission).toHaveBeenCalledExactlyOnceWith(ctxMatcher, { + projectEnvVars: ["write"], + }); + expect(fake.locks).toEqual([{ table: "project", strength: "update" }]); + }); + + it("applies the environment update checks and the environment variable permission", async () => { + await createCaller().applyChanges({ + scope: "environment", + id: "environment-1", + remove: ["STAGE_TOKEN"], + }); + + expect(storedEnv(environments)).toBe(""); + expect(mocks.checkEnvironmentAccess).toHaveBeenCalledExactlyOnceWith( + ctxMatcher, + "environment-1", + "update", + ); + expect(mocks.checkPermission).toHaveBeenCalledExactlyOnceWith(ctxMatcher, { + environmentEnvVars: ["write"], + }); + }); + + it("does not write to another organization or without the write permission", async () => { + await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-2", + set: { ADDED_NAME: "1" }, + }), + "UNAUTHORIZED", + ); + expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled(); + + mocks.checkServicePermissionAndAccess.mockRejectedValue( + new TRPCError({ code: "UNAUTHORIZED", message: "Permission denied" }), + ); + await expectTrpcError( + createCaller().applyChanges({ + scope: "application", + id: "app-1", + set: { ADDED_NAME: "1" }, + }), + "UNAUTHORIZED", + ); + + expect(storedEnv(applications, "env", 0)).not.toContain("ADDED_NAME"); + expect(storedEnv(applications, "env", 1)).not.toContain("ADDED_NAME"); + expect(fake.locks).toEqual([]); + expect(mocks.audit).not.toHaveBeenCalled(); + }); + + it("reports a missing target", async () => { + await expectTrpcError( + createCaller().applyChanges({ + scope: "compose", + id: "missing", + set: { ADDED_NAME: "1" }, + }), + "NOT_FOUND", + ); + }); + + it("refuses to edit a field that could not be decrypted", async () => { + const ciphertext = "enc:v1:bm90LXJlYWxseS1lbmNyeXB0ZWQ="; + fake.rows(postgres)[0]!.env = ciphertext; + + await expectTrpcError( + createCaller().applyChanges({ + scope: "postgres", + id: "postgres-1", + set: { ADDED_NAME: "1" }, + }), + "PRECONDITION_FAILED", + ); + expect(storedEnv(postgres)).toBe(ciphertext); + }); + + it("refuses an edit that the stored text cannot take safely", async () => { + fake.rows(postgres)[0]!.env = `BROKEN_QUOTE="${SENTINEL}`; + + const error = await expectTrpcError( + createCaller().applyChanges({ + scope: "postgres", + id: "postgres-1", + set: { NEXT_NAME: " # note" }, + }), + "BAD_REQUEST", + ); + + expect(error.message).toBe( + "Environment variables cannot be edited safely: BROKEN_QUOTE, NEXT_NAME", + ); + expect(storedEnv(postgres)).toBe(`BROKEN_QUOTE="${SENTINEL}`); + expect(mocks.audit).not.toHaveBeenCalled(); + }); +}); + +describe("REST routes", () => { + it("serves GET /env.keys from query parameters", async () => { + const response = await callRest("GET", "env.keys", { + scope: "application", + id: "app-1", + field: "buildArgs", + }); + + expect(response).toEqual({ + status: 200, + body: { + scope: "application", + id: "app-1", + field: "buildArgs", + revision: getEnvFieldRevision( + "application", + "app-1", + "buildArgs", + "BUILD_MODE=release\n", + ), + variables: [{ name: "BUILD_MODE", secret: false, empty: false }], + }, + }); + }); + + it("serves POST /env.apply from a JSON body", async () => { + const response = await callRest("POST", "env.apply", { + scope: "application", + id: "app-1", + set: { SERVICE_URL: "https://next.example.test" }, + generate: { NEW_TOKEN: { length: 24, format: "hex" } }, + copy: { + UPSTREAM_TOKEN: { + fromScope: "project", + fromId: "project-1", + fromName: "SHARED_TOKEN", + }, + }, + remove: ["EMPTY_FLAG"], + }); + + const values = parse(storedEnv(applications) ?? ""); + expect(values).toEqual({ + SERVICE_URL: "https://next.example.test", + SERVICE_TOKEN: SENTINEL, + NEW_TOKEN: expect.stringMatching(/^[0-9a-f]{24}$/), + UPSTREAM_TOKEN: SENTINEL, + }); + expect(response).toEqual({ + status: 200, + body: { + scope: "application", + id: "app-1", + field: "env", + changed: true, + revision: getApplicationEnvRevision("app-1", storedEnv(applications)), + dryRun: false, + variables: [ + { + name: "SERVICE_URL", + action: "updated", + source: "set", + secret: false, + }, + { + name: "NEW_TOKEN", + action: "created", + source: "generate", + secret: true, + }, + { + name: "UPSTREAM_TOKEN", + action: "created", + source: "copy", + secret: true, + }, + { + name: "EMPTY_FLAG", + action: "removed", + source: "remove", + secret: false, + }, + ], + }, + }); + expect(JSON.stringify(response)).not.toContain(SENTINEL); + expect(JSON.stringify(response)).not.toContain(values.NEW_TOKEN); + }); + + it("maps errors to HTTP statuses without echoing values", async () => { + const conflict = await callRest("POST", "env.apply", { + scope: "application", + id: "app-1", + set: { ADDED_NAME: OTHER_SENTINEL }, + expectedRevision: "stale", + }); + const invalid = await callRest("POST", "env.apply", { + scope: "application", + id: "app-1", + set: { SAME_NAME: OTHER_SENTINEL }, + remove: ["SAME_NAME"], + }); + const missing = await callRest("GET", "env.keys", { + scope: "compose", + id: "missing", + }); + + expect(conflict.status).toBe(409); + expect(invalid.status).toBe(400); + expect(missing.status).toBe(404); + expect(JSON.stringify([conflict, invalid, missing])).not.toContain( + OTHER_SENTINEL, + ); + }); + + it("has no REST route under the tRPC procedure name", async () => { + const response = await callRest("POST", "env.applyChanges", { + scope: "application", + id: "app-1", + set: { ADDED_NAME: "1" }, + }); + + expect(response.status).toBe(404); + expect(storedEnv(applications)).not.toContain("ADDED_NAME"); + }); +}); diff --git a/apps/dokploy/__test__/helpers/fake-drizzle-db.ts b/apps/dokploy/__test__/helpers/fake-drizzle-db.ts index b873a63b6cd..bf27d9b59a1 100644 --- a/apps/dokploy/__test__/helpers/fake-drizzle-db.ts +++ b/apps/dokploy/__test__/helpers/fake-drizzle-db.ts @@ -23,11 +23,13 @@ const comparable = (value: unknown) => /** * In-memory stand-in for the subset of drizzle used by the super password * service: relational findFirst/findMany, insert (with onConflictDoUpdate), - * update ... returning and delete, with eq/ne/gt/lt/isNull/isNotNull/and/or - * conditions evaluated against stored rows. + * select ... where (optionally for update), update ... returning and delete, + * with eq/ne/gt/lt/isNull/isNotNull/and/or conditions evaluated against stored + * rows. */ export const createFakeDrizzleDb = (tables: Record) => { const rows = new Map(); + const locks: { table: string; strength: string }[] = []; const columnKeys = new Map(); const tableByName = new Map(); @@ -181,7 +183,8 @@ export const createFakeDrizzleDb = (tables: Record) => { }); // Only one-to-one relations named after a registered table whose id column - // is `Id` (notification.email / notification.resend). + // is `Id` (notification.email / notification.resend), nested through + // the relation's own `with`. const withRelations = (row: Row, relations?: Record) => { const result: Row = { ...row }; for (const name of Object.keys(relations ?? {})) { @@ -194,7 +197,12 @@ export const createFakeDrizzleDb = (tables: Record) => { candidate[foreignKey] === row[foreignKey], ) : undefined; - result[name] = related ? { ...related } : null; + const nested = relations?.[name] as + | { with?: Record } + | true; + result[name] = related + ? withRelations(related, nested === true ? undefined : nested?.with) + : null; } return result; }; @@ -223,6 +231,22 @@ export const createFakeDrizzleDb = (tables: Record) => { const db: Record = { query, transaction: async (run: (tx: unknown) => Promise) => run(db), + select: (columns?: Record) => ({ + from: (table: Table) => ({ + where: (condition: unknown) => { + const find = () => + (rows.get(table) ?? []) + .filter((row) => evaluate(condition, row)) + .map((row) => project(row, columns)); + return lazy(find, { + for: async (strength: string) => { + locks.push({ table: getTableName(table), strength }); + return find(); + }, + }); + }, + }), + }), insert: (table: Table) => ({ values: (values: Row) => lazy(() => [insertRow(table, values)], { @@ -288,9 +312,11 @@ export const createFakeDrizzleDb = (tables: Record) => { return { db, + locks, rows: (table: Table) => rows.get(table) ?? [], seed: (table: Table, values: Row) => insertRow(table, values), reset: () => { + locks.length = 0; for (const table of rows.keys()) { rows.set(table, []); } diff --git a/apps/dokploy/__test__/security/super-session-blind-env.test.ts b/apps/dokploy/__test__/security/super-session-blind-env.test.ts new file mode 100644 index 00000000000..b5020b5cc39 --- /dev/null +++ b/apps/dokploy/__test__/security/super-session-blind-env.test.ts @@ -0,0 +1,165 @@ +import { + applications, + environments, + projects, +} from "@dokploy/server/db/schema"; +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/db", async () => { + const schema = await import("@dokploy/server/db/schema"); + const { createFakeDrizzleDb } = await import("../helpers/fake-drizzle-db"); + const fake = createFakeDrizzleDb({ + superPassword: schema.superPassword, + superSession: schema.superSession, + account: schema.account, + applications: schema.applications, + project: schema.projects, + environment: schema.environments, + webServerSettings: schema.webServerSettings, + }); + return { db: fake.db, fake }; +}); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkEnvironmentAccess: vi.fn(), + checkPermission: vi.fn(), + checkProjectAccess: vi.fn(), + checkServicePermissionAndAccess: vi.fn(), + findMemberByUserId: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ audit: vi.fn() })); + +const { fake } = (await import("@dokploy/server/db")) as unknown as { + fake: ReturnType< + typeof import("../helpers/fake-drizzle-db")["createFakeDrizzleDb"] + >; +}; +const service = await import("@dokploy/server/services/super-password"); +const { envRouter } = await import("@/server/api/routers/env"); +const { createTRPCRouter } = await import("@/server/api/trpc"); + +const router = createTRPCRouter({ env: envRouter }); + +const createCaller = (authMethod: "api-key" | "session") => + router.createCaller({ + db: {}, + req: {}, + res: {}, + session: { + id: "session-1", + userId: "user-1", + activeOrganizationId: "org-1", + authMethod, + }, + user: { id: "user-1", role: "owner", email: "owner@example.test" }, + } as never).env; + +const target = { scope: "application", id: "app-1" } as const; + +const storedEnv = () => fake.rows(applications)[0]?.env; + +describe("blind env procedures and the super session", () => { + beforeEach(() => { + fake.reset(); + fake.seed(projects, { + projectId: "project-1", + name: "Project One", + organizationId: "org-1", + }); + fake.seed(environments, { + environmentId: "environment-1", + name: "production", + projectId: "project-1", + }); + fake.seed(applications, { + applicationId: "app-1", + name: "App One", + appName: "app-one", + environmentId: "environment-1", + env: "SERVICE_URL=https://app.example.test\n", + }); + }); + + it("keeps API key writes working while no super password is set", async () => { + const result = await createCaller("api-key").applyChanges({ + ...target, + set: { ADDED_NAME: "1" }, + }); + + expect(result.changed).toBe(true); + expect(storedEnv()).toContain("ADDED_NAME=1"); + }); + + it("rejects API key writes while the super session is closed but still lists keys", async () => { + await service.setSuperPassword({ + userId: "user-1", + password: "super-password-1", + }); + + const error = await createCaller("api-key") + .applyChanges({ ...target, generate: { SERVICE_TOKEN: {} } }) + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("FORBIDDEN"); + expect((error as TRPCError).message).toBe( + service.SUPER_SESSION_MESSAGES["api-key-write-locked"], + ); + expect(storedEnv()).toBe("SERVICE_URL=https://app.example.test\n"); + + const keys = await createCaller("api-key").keys(target); + expect(keys.variables).toEqual([ + { name: "SERVICE_URL", secret: false, empty: false }, + ]); + }); + + it("rejects API key dry runs while the super session is closed", async () => { + await service.setSuperPassword({ + userId: "user-1", + password: "super-password-1", + }); + + await expect( + createCaller("api-key").applyChanges({ + ...target, + set: { SERVICE_URL: "https://guess.example.test" }, + dryRun: true, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("accepts API key writes while the super session is open", async () => { + await service.setSuperPassword({ + userId: "user-1", + password: "super-password-1", + }); + await service.openSuperSession({ + userId: "user-1", + sessionId: "session-1", + }); + + const result = await createCaller("api-key").applyChanges({ + ...target, + set: { ADDED_NAME: "1" }, + }); + + expect(result.changed).toBe(true); + expect(storedEnv()).toContain("ADDED_NAME=1"); + }); + + it("lets a browser session write without an open super session", async () => { + await service.setSuperPassword({ + userId: "user-1", + password: "super-password-1", + }); + + const result = await createCaller("session").applyChanges({ + ...target, + set: { ADDED_NAME: "1" }, + }); + + expect(result.changed).toBe(true); + }); +}); diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index 5c970f62824..ff226348822 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -15,6 +15,7 @@ import { dockerDiskUsageRouter } from "./routers/docker-disk-usage"; import { dockerImageRouter } from "./routers/docker-image"; import { dockerVolumeRouter } from "./routers/docker-volume"; import { domainRouter } from "./routers/domain"; +import { envRouter } from "./routers/env"; import { environmentRouter } from "./routers/environment"; import { gitProviderRouter } from "./routers/git-provider"; import { giteaRouter } from "./routers/gitea"; @@ -81,6 +82,7 @@ export const appRouter = createTRPCRouter({ dockerImage: dockerImageRouter, dockerVolume: dockerVolumeRouter, domain: domainRouter, + env: envRouter, gitea: giteaRouter, gitProvider: gitProviderRouter, github: githubRouter, diff --git a/apps/dokploy/server/api/routers/env.ts b/apps/dokploy/server/api/routers/env.ts new file mode 100644 index 00000000000..687ffbb2ed8 --- /dev/null +++ b/apps/dokploy/server/api/routers/env.ts @@ -0,0 +1,131 @@ +import type { AuditResourceType, EnvScope } from "@dokploy/server/db/schema"; +import { + apiEnvApply, + apiEnvApplyResponse, + apiEnvKeys, + apiEnvKeysResponse, +} from "@dokploy/server/db/schema"; +import { + applyEnvVariableChanges, + findEnvVariablesOwner, + listEnvVariableKeys, +} from "@dokploy/server/services/env-variables"; +import { + checkEnvironmentAccess, + checkPermission, + checkProjectAccess, + checkServicePermissionAndAccess, + findMemberByUserId, +} from "@dokploy/server/services/permission"; +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { audit } from "@/server/api/utils/audit"; + +type EnvAccessCtx = { + user: { id: string; role: string }; + session: { activeOrganizationId: string }; +}; + +const auditResourceType = (scope: EnvScope): AuditResourceType => + scope === "application" || + scope === "compose" || + scope === "project" || + scope === "environment" + ? scope + : "service"; + +const assertEnvAccess = async ( + ctx: EnvAccessCtx, + scope: EnvScope, + id: string, + action: "read" | "write", +) => { + const owner = await findEnvVariablesOwner(scope, id); + + if (owner.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: `You are not authorized to access this ${scope}`, + }); + } + + if (scope === "project") { + if (action === "write") { + await checkProjectAccess(ctx, "update", id); + } + if (ctx.user.role !== "owner" && ctx.user.role !== "admin") { + const { accessedProjects } = await findMemberByUserId( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (!accessedProjects.includes(id)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this project", + }); + } + } + await checkPermission(ctx, { projectEnvVars: [action] }); + } else if (scope === "environment") { + await checkEnvironmentAccess( + ctx, + id, + action === "write" ? "update" : "read", + ); + await checkPermission(ctx, { environmentEnvVars: [action] }); + } else { + await checkServicePermissionAndAccess(ctx, id, { envVars: [action] }); + } + + return owner; +}; + +export const envRouter = createTRPCRouter({ + keys: protectedProcedure + .input(apiEnvKeys) + .output(apiEnvKeysResponse) + .query(async ({ input, ctx }) => { + await assertEnvAccess(ctx, input.scope, input.id, "read"); + return await listEnvVariableKeys(input); + }), + applyChanges: protectedProcedure + // tRPC reserves `apply` as a procedure name, so the REST route and its + // operation id are pinned here to keep the HTTP API at POST /env.apply. + .meta({ + openapi: { + method: "POST", + path: "/env.apply", + operationId: "env-apply", + tags: ["env"], + override: true, + }, + }) + .input(apiEnvApply) + .output(apiEnvApplyResponse) + .mutation(async ({ input, ctx }) => { + const owner = await assertEnvAccess(ctx, input.scope, input.id, "write"); + + const sources = new Map( + Object.values(input.copy ?? {}).map(({ fromScope, fromId }) => [ + `${fromScope}:${fromId}`, + { scope: fromScope, id: fromId }, + ]), + ); + for (const source of sources.values()) { + await assertEnvAccess(ctx, source.scope, source.id, "read"); + } + + const result = await applyEnvVariableChanges(input); + + if (!result.dryRun && result.changed) { + await audit(ctx, { + action: "update", + resourceType: auditResourceType(input.scope), + resourceId: input.id, + resourceName: owner.resourceName, + }); + } + + return result; + }), +}); diff --git a/apps/dokploy/server/api/utils/procedure-access.ts b/apps/dokploy/server/api/utils/procedure-access.ts index 4cc7778f49f..dba6d6da27e 100644 --- a/apps/dokploy/server/api/utils/procedure-access.ts +++ b/apps/dokploy/server/api/utils/procedure-access.ts @@ -270,6 +270,8 @@ export const PROCEDURE_ACCESS: Record = { "domain.toggleEnable": "write", "domain.update": "write", "domain.validateDomain": "write", + "env.applyChanges": "write", + "env.keys": "read", "environment.byProjectId": "read", "environment.create": "write", "environment.duplicate": "write", diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 4708128c355..6c31e10270f 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -16,6 +16,7 @@ import { z } from "zod"; import { bitbucket } from "./bitbucket"; import { deployments } from "./deployment"; import { domains } from "./domain"; +import { ENV_VARIABLE_NAME_REGEX } from "./env-variables"; import { environments } from "./environment"; import { gitea } from "./gitea"; import { github } from "./github"; @@ -598,8 +599,6 @@ export const apiSaveEnvironmentVariables = createSchema }) .required(); -const ENV_VARIABLE_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; - export const apiUpsertApplicationEnv = z.object({ applicationId: z.string().min(1), variables: z diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 0a01676e841..f9cd5bcb992 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -14,6 +14,7 @@ import { backups } from "./backups"; import { bitbucket } from "./bitbucket"; import { deploymentOperations, deployments } from "./deployment"; import { domains } from "./domain"; +import { ENV_VARIABLE_NAME_REGEX } from "./env-variables"; import { environments } from "./environment"; import { gitea } from "./gitea"; import { github } from "./github"; @@ -306,8 +307,6 @@ export const apiSaveEnvironmentVariablesCompose = createSchema createEnvFile: z.boolean().optional(), }); -const ENV_VARIABLE_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; - export const apiUpsertComposeEnv = z.object({ composeId: z.string().min(1), variables: z diff --git a/packages/server/src/db/schema/env-variables.ts b/packages/server/src/db/schema/env-variables.ts new file mode 100644 index 00000000000..41230ba25dd --- /dev/null +++ b/packages/server/src/db/schema/env-variables.ts @@ -0,0 +1,171 @@ +import { z } from "zod"; + +export const ENV_VARIABLE_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export const ENV_SCOPES = [ + "application", + "compose", + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + "libsql", + "project", + "environment", +] as const; + +export const ENV_FIELDS = ["env", "buildArgs", "buildSecrets"] as const; + +export const ENV_GENERATE_FORMATS = [ + "base64url", + "hex", + "alphanumeric", +] as const; + +export type EnvScope = (typeof ENV_SCOPES)[number]; +export type EnvField = (typeof ENV_FIELDS)[number]; +export type EnvGenerateFormat = (typeof ENV_GENERATE_FORMATS)[number]; + +const envScope = z.enum(ENV_SCOPES); +const envField = z.enum(ENV_FIELDS); +const envVariableName = z + .string() + .regex( + ENV_VARIABLE_NAME_REGEX, + "Environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores", + ); + +export const isEnvFieldSupported = (scope: EnvScope, field: EnvField) => + field === "env" || scope === "application"; + +const unsupportedFieldMessage = (scope: EnvScope, field: EnvField) => + `Field ${field} is only available for the application scope, not ${scope}`; + +export const apiEnvKeys = z + .object({ + scope: envScope, + id: z.string().min(1), + field: envField.default("env"), + }) + .superRefine((input, ctx) => { + if (!isEnvFieldSupported(input.scope, input.field)) { + ctx.addIssue({ + code: "custom", + path: ["field"], + message: unsupportedFieldMessage(input.scope, input.field), + }); + } + }); + +export const apiEnvKeysResponse = z.object({ + scope: envScope, + id: z.string(), + field: envField, + revision: z.string(), + variables: z.array( + z.object({ + name: z.string(), + secret: z.boolean(), + empty: z.boolean(), + }), + ), +}); + +export const apiEnvApply = z + .object({ + scope: envScope, + id: z.string().min(1), + field: envField.default("env"), + set: z.record(envVariableName, z.string()).optional(), + remove: z.array(envVariableName).optional(), + generate: z + .record( + envVariableName, + z.object({ + length: z.number().int().min(16).max(128).default(48), + format: z.enum(ENV_GENERATE_FORMATS).default("base64url"), + }), + ) + .optional(), + copy: z + .record( + envVariableName, + z.object({ + fromScope: envScope, + fromId: z.string().min(1), + fromField: envField.default("env"), + fromName: envVariableName, + }), + ) + .optional(), + dryRun: z.boolean().optional(), + expectedRevision: z.string().optional(), + }) + .superRefine((input, ctx) => { + if (!isEnvFieldSupported(input.scope, input.field)) { + ctx.addIssue({ + code: "custom", + path: ["field"], + message: unsupportedFieldMessage(input.scope, input.field), + }); + } + + for (const [name, source] of Object.entries(input.copy ?? {})) { + if (!isEnvFieldSupported(source.fromScope, source.fromField)) { + ctx.addIssue({ + code: "custom", + path: ["copy", name, "fromField"], + message: unsupportedFieldMessage(source.fromScope, source.fromField), + }); + } + } + + const operations = { + set: Object.keys(input.set ?? {}), + remove: input.remove ?? [], + generate: Object.keys(input.generate ?? {}), + copy: Object.keys(input.copy ?? {}), + }; + const listsByName = new Map>(); + for (const [list, names] of Object.entries(operations)) { + for (const name of names) { + listsByName.set(name, (listsByName.get(name) ?? new Set()).add(list)); + } + } + + if (listsByName.size === 0) { + ctx.addIssue({ + code: "custom", + message: + "At least one of set, remove, generate or copy must name a variable", + }); + } + + for (const [name, lists] of listsByName) { + if (lists.size > 1) { + ctx.addIssue({ + code: "custom", + path: [[...lists][0] ?? "set", name], + message: `Variable ${name} appears in more than one of: ${[...lists].join(", ")}`, + }); + } + } + }); + +export const apiEnvApplyResponse = z.object({ + scope: envScope, + id: z.string(), + field: envField, + changed: z.boolean(), + revision: z.string(), + dryRun: z.boolean(), + variables: z.array( + z.object({ + name: z.string(), + action: z.enum(["created", "updated", "unchanged", "removed", "missing"]), + source: z.enum(["set", "generate", "copy", "remove"]), + secret: z.boolean(), + }), + ), +}); diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts index abba9c13266..52788199bde 100644 --- a/packages/server/src/db/schema/index.ts +++ b/packages/server/src/db/schema/index.ts @@ -10,6 +10,7 @@ export * from "./deployment"; export * from "./destination"; export * from "./dns-provider"; export * from "./domain"; +export * from "./env-variables"; export * from "./environment"; export * from "./forward-auth"; export * from "./git-provider"; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c1c52b4d253..69a0f66036b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -22,6 +22,7 @@ export * from "./services/docker"; export * from "./services/docker-image"; export * from "./services/docker-volume"; export * from "./services/domain"; +export * from "./services/env-variables"; export * from "./services/environment"; export * from "./services/git-provider"; export * from "./services/gitea"; diff --git a/packages/server/src/services/env-variables.ts b/packages/server/src/services/env-variables.ts new file mode 100644 index 00000000000..34a22f63e1b --- /dev/null +++ b/packages/server/src/services/env-variables.ts @@ -0,0 +1,380 @@ +import { randomBytes, randomInt } from "node:crypto"; +import { db } from "@dokploy/server/db"; +import { + type apiEnvApply, + type apiEnvKeys, + applications, + compose, + type EnvField, + type EnvGenerateFormat, + type EnvScope, + environments, + libsql, + mariadb, + mongo, + mysql, + postgres, + projects, + redis, +} from "@dokploy/server/db/schema"; +import { isEncrypted } from "@dokploy/server/lib/encryption"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import type { AnyPgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { z } from "zod"; +import { + applyEnvChanges, + assertNoRedactedSecretPlaceholders, + type EnvChange, + getApplicationEnvRevision, + getComposeEnvRevision, + getEnvFieldRevision, + getEnvVariableValue, + listEnvVariables, +} from "../utils/env-upsert"; + +type EnvTarget = { + scope: EnvScope; + id: string; + field: EnvField; +}; + +type EnvReader = Pick; + +const ENV_TABLES: Record< + EnvScope, + { + table: PgTable; + idColumn: AnyPgColumn; + fields: Partial>; + } +> = { + application: { + table: applications, + idColumn: applications.applicationId, + fields: { + env: applications.env, + buildArgs: applications.buildArgs, + buildSecrets: applications.buildSecrets, + }, + }, + compose: { + table: compose, + idColumn: compose.composeId, + fields: { env: compose.env }, + }, + postgres: { + table: postgres, + idColumn: postgres.postgresId, + fields: { env: postgres.env }, + }, + mysql: { + table: mysql, + idColumn: mysql.mysqlId, + fields: { env: mysql.env }, + }, + mariadb: { + table: mariadb, + idColumn: mariadb.mariadbId, + fields: { env: mariadb.env }, + }, + mongo: { + table: mongo, + idColumn: mongo.mongoId, + fields: { env: mongo.env }, + }, + redis: { + table: redis, + idColumn: redis.redisId, + fields: { env: redis.env }, + }, + libsql: { + table: libsql, + idColumn: libsql.libsqlId, + fields: { env: libsql.env }, + }, + project: { + table: projects, + idColumn: projects.projectId, + fields: { env: projects.env }, + }, + environment: { + table: environments, + idColumn: environments.environmentId, + fields: { env: environments.env }, + }, +}; + +const serviceOwner = { + columns: { name: true, appName: true }, + with: { + environment: { + columns: { environmentId: true }, + with: { project: { columns: { organizationId: true } } }, + }, + }, +} as const; + +const SERVICE_OWNERS = { + application: (id: string) => + db.query.applications.findFirst({ + where: eq(applications.applicationId, id), + ...serviceOwner, + }), + compose: (id: string) => + db.query.compose.findFirst({ + where: eq(compose.composeId, id), + ...serviceOwner, + }), + postgres: (id: string) => + db.query.postgres.findFirst({ + where: eq(postgres.postgresId, id), + ...serviceOwner, + }), + mysql: (id: string) => + db.query.mysql.findFirst({ + where: eq(mysql.mysqlId, id), + ...serviceOwner, + }), + mariadb: (id: string) => + db.query.mariadb.findFirst({ + where: eq(mariadb.mariadbId, id), + ...serviceOwner, + }), + mongo: (id: string) => + db.query.mongo.findFirst({ + where: eq(mongo.mongoId, id), + ...serviceOwner, + }), + redis: (id: string) => + db.query.redis.findFirst({ + where: eq(redis.redisId, id), + ...serviceOwner, + }), + libsql: (id: string) => + db.query.libsql.findFirst({ + where: eq(libsql.libsqlId, id), + ...serviceOwner, + }), +}; + +const ALPHANUMERIC = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + +const notFound = (scope: EnvScope) => + new TRPCError({ + code: "NOT_FOUND", + message: `No ${scope} found with this id`, + }); + +const badRequest = (error: unknown) => + new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error ? error.message : "Invalid environment variables", + }); + +export const findEnvVariablesOwner = async (scope: EnvScope, id: string) => { + if (scope === "project") { + const project = await db.query.projects.findFirst({ + where: eq(projects.projectId, id), + columns: { name: true, organizationId: true }, + }); + if (!project) { + throw notFound(scope); + } + return { + organizationId: project.organizationId, + resourceName: project.name, + }; + } + + if (scope === "environment") { + const environment = await db.query.environments.findFirst({ + where: eq(environments.environmentId, id), + columns: { name: true }, + with: { project: { columns: { organizationId: true } } }, + }); + if (!environment) { + throw notFound(scope); + } + return { + organizationId: environment.project.organizationId, + resourceName: environment.name, + }; + } + + const service = await SERVICE_OWNERS[scope](id); + if (!service) { + throw notFound(scope); + } + return { + organizationId: service.environment.project.organizationId, + resourceName: scope === "compose" ? service.name : service.appName, + }; +}; + +const getEnvTargetRevision = ({ scope, id, field }: EnvTarget, env: string) => { + if (scope === "application" && field === "env") { + return getApplicationEnvRevision(id, env); + } + if (scope === "compose") { + return getComposeEnvRevision(id, env); + } + return getEnvFieldRevision(scope, id, field, env); +}; + +const getEnvColumn = ({ scope, field }: EnvTarget) => { + const column = ENV_TABLES[scope].fields[field]; + if (!column) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Field ${field} is only available for the application scope, not ${scope}`, + }); + } + return column; +}; + +const readEnvText = async ( + reader: EnvReader, + target: EnvTarget, + lock: boolean, +) => { + const { table, idColumn } = ENV_TABLES[target.scope]; + const query = reader + .select({ text: getEnvColumn(target) }) + .from(table) + .where(eq(idColumn, target.id)); + const [row] = await (lock ? query.for("update") : query); + + if (!row) { + throw notFound(target.scope); + } + + const text = typeof row.text === "string" ? row.text : ""; + // The column read falls back to the ciphertext when it cannot be decrypted; + // editing that would store the new variables next to it in plain text. + if (isEncrypted(text)) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `The stored ${target.field} of this ${target.scope} cannot be decrypted, so it cannot be read or edited`, + }); + } + + return text; +}; + +const generateEnvValue = (length: number, format: EnvGenerateFormat) => { + if (format === "alphanumeric") { + return Array.from( + { length }, + () => ALPHANUMERIC[randomInt(ALPHANUMERIC.length)], + ).join(""); + } + return randomBytes(length).toString(format).slice(0, length); +}; + +export const listEnvVariableKeys = async ( + input: z.infer, +) => { + const target = { scope: input.scope, id: input.id, field: input.field }; + const env = await readEnvText(db, target, false); + + return { + ...target, + revision: getEnvTargetRevision(target, env), + variables: listEnvVariables(env), + }; +}; + +export const applyEnvVariableChanges = async ( + input: z.infer, +) => { + const target = { scope: input.scope, id: input.id, field: input.field }; + const dryRun = input.dryRun ?? false; + + try { + assertNoRedactedSecretPlaceholders(input.set ?? {}); + } catch (error) { + throw badRequest(error); + } + + return await db.transaction(async (tx) => { + // The env columns are encrypted with a random IV, so they can't be compared + // in SQL; the row lock is what makes the revision check and write atomic. + const currentEnv = await readEnvText(tx, target, true); + const currentRevision = getEnvTargetRevision(target, currentEnv); + + if (input.expectedRevision && input.expectedRevision !== currentRevision) { + throw new TRPCError({ + code: "CONFLICT", + message: "Environment revision does not match", + }); + } + + const sourceKey = (source: EnvTarget) => + `${source.scope}:${source.field}:${source.id}`; + const sourceEnvs = new Map([[sourceKey(target), currentEnv]]); + const changes: EnvChange[] = Object.entries(input.set ?? {}).map( + ([name, value]) => ({ name, source: "set", value }), + ); + + for (const [name, options] of Object.entries(input.generate ?? {})) { + changes.push({ + name, + source: "generate", + value: dryRun ? null : generateEnvValue(options.length, options.format), + }); + } + + for (const [name, from] of Object.entries(input.copy ?? {})) { + const source = { + scope: from.fromScope, + id: from.fromId, + field: from.fromField, + }; + const sourceEnv = + sourceEnvs.get(sourceKey(source)) ?? + (await readEnvText(tx, source, false)); + sourceEnvs.set(sourceKey(source), sourceEnv); + + const value = getEnvVariableValue(sourceEnv, from.fromName); + if (value === undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Variable ${from.fromName} does not exist in ${source.field} of ${source.scope} ${source.id}`, + }); + } + changes.push({ name, source: "copy", value }); + } + + for (const name of new Set(input.remove ?? [])) { + changes.push({ name, source: "remove" }); + } + + let result: ReturnType; + try { + result = applyEnvChanges(currentEnv, changes); + } catch (error) { + throw badRequest(error); + } + + const summary = { + ...target, + changed: result.changed, + dryRun, + variables: result.variables, + }; + if (dryRun || !result.changed) { + return { ...summary, revision: currentRevision }; + } + + const { table, idColumn } = ENV_TABLES[target.scope]; + await tx + .update(table) + .set({ [target.field]: result.env }) + .where(eq(idColumn, target.id)); + + return { ...summary, revision: getEnvTargetRevision(target, result.env) }; + }); +}; diff --git a/packages/server/src/utils/env-upsert.ts b/packages/server/src/utils/env-upsert.ts index 872b4f36ee5..d09c14bdffd 100644 --- a/packages/server/src/utils/env-upsert.ts +++ b/packages/server/src/utils/env-upsert.ts @@ -71,6 +71,22 @@ export const getComposeEnvRevision = ( .update(env ?? "") .digest("base64url")}`; +export const getEnvFieldRevision = ( + scope: string, + id: string, + field: string, + env: string | null | undefined, +) => { + const hmac = createHmac("sha256", betterAuthSecret); + for (const part of [scope, id, field, env ?? ""]) { + hmac + .update(String(Buffer.byteLength(part))) + .update(":") + .update(part); + } + return `env-field-v1:${hmac.digest("base64url")}`; +}; + const serializeEnvValue = (value: string) => { if (value === "") { return ""; @@ -164,3 +180,237 @@ export const upsertEnvVariables = ( variables: variableResults, }; }; + +export type EnvVariableKey = { + name: string; + secret: boolean; + empty: boolean; +}; + +export type EnvChange = + | { name: string; source: "set" | "copy"; value: string } + // A null value previews a generated variable: the action is reported and the + // text is left alone, so a dry run never has to create a secret. + | { name: string; source: "generate"; value: string | null } + | { name: string; source: "remove" }; + +export type EnvChangeAction = + | "created" + | "updated" + | "unchanged" + | "removed" + | "missing"; + +export type EnvChangeVariableResult = { + name: string; + action: EnvChangeAction; + source: EnvChange["source"]; + secret: boolean; +}; + +export type EnvChangeResult = { + env: string; + changed: boolean; + variables: EnvChangeVariableResult[]; +}; + +// dotenv's own line grammar with every part captured. Deploys read these fields +// through dotenv, so an edit has to cover exactly what dotenv treats as one +// assignment, including a quoted value that runs over several physical lines. +const ENV_ENTRY_REGEX = + /^(\s*)((?:export\s+)?)([\w.-]+)(\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/gm; + +type EnvEntry = { + name: string; + lineStart: number; + valueStart: number; + valueEnd: number; + lineEnd: number; +}; + +const normalizeEnvText = (env: string | null | undefined) => + (env ?? "").replace(/\r\n?/g, "\n"); + +const parseEnvValues = (text: string) => new Map(Object.entries(parse(text))); + +const scanEnvEntries = (text: string): EnvEntry[] => { + const lineBreak = /[\n\u2028\u2029]/g; + + return Array.from(text.matchAll(ENV_ENTRY_REGEX), (match) => { + const [, lead = "", prefix = "", name = "", separator = "", rawValue = ""] = + match; + const start = match.index + lead.length; + const indent = /[^\n\u2028\u2029]*$/.exec(lead)?.[0].length ?? 0; + const separatorEnd = start + prefix.length + name.length + separator.length; + const value = rawValue.trim(); + const valueStart = value + ? separatorEnd + rawValue.length - rawValue.trimStart().length + : separatorEnd; + const valueEnd = valueStart + value.length; + lineBreak.lastIndex = valueEnd; + + return { + name, + lineStart: start - indent, + valueStart, + valueEnd, + lineEnd: lineBreak.exec(text)?.index ?? text.length, + }; + }); +}; + +// dotenv only turns \n and \r back inside double quotes and never unescapes a +// quote or a backslash, so a value holding either one needs another form that +// dotenv reads back character for character. +const serializeExactEnvValue = (name: string, value: string) => { + if (!/["\\]/.test(value)) { + return serializeEnvValue(value); + } + + const quotable = !value.endsWith("\\") && !value.includes("\r"); + for (const quote of ["'", "`"]) { + if (quotable && !value.includes(quote)) { + return `${quote}${value}${quote}`; + } + } + if (quotable && !value.includes('"') && !/\\[nr]/.test(value)) { + return `"${value}"`; + } + if ( + !/[#\r\n]/.test(value) && + value === value.trim() && + !/^['"`]/.test(value) + ) { + return value; + } + + throw new Error( + `Environment variable value cannot be stored safely: ${name}`, + ); +}; + +export const listEnvVariables = ( + currentEnv: string | null | undefined, +): EnvVariableKey[] => { + const text = normalizeEnvText(currentEnv); + const values = parseEnvValues(text); + const names = new Set(scanEnvEntries(text).map((entry) => entry.name)); + + return [...names] + .filter((name) => values.has(name)) + .map((name) => ({ + name, + secret: isSecretEnvName(name), + empty: values.get(name) === "", + })); +}; + +export const getEnvVariableValue = ( + currentEnv: string | null | undefined, + name: string, +) => parseEnvValues(normalizeEnvText(currentEnv)).get(name); + +export const applyEnvChanges = ( + currentEnv: string | null | undefined, + changes: EnvChange[], +): EnvChangeResult => { + const original = normalizeEnvText(currentEnv); + const trailingNewline = original.endsWith("\n"); + const body = trailingNewline ? original.slice(0, -1) : original; + const currentValues = parseEnvValues(body); + const entries = scanEnvEntries(body); + const expectedValues = new Map(currentValues); + const removedNames = new Set(); + const replacements = new Map(); + const appendedLines: string[] = []; + const variables: EnvChangeVariableResult[] = []; + + for (const change of changes) { + const { name, source } = change; + const exists = currentValues.has(name); + const secret = isSecretEnvName(name); + + if (change.source === "remove") { + variables.push({ + name, + action: exists ? "removed" : "missing", + source, + secret, + }); + if (exists) { + removedNames.add(name); + expectedValues.delete(name); + } + continue; + } + + const action = !exists + ? "created" + : change.source !== "generate" && currentValues.get(name) === change.value + ? "unchanged" + : "updated"; + variables.push({ name, action, source, secret }); + + if (action === "unchanged" || change.value === null) { + continue; + } + + expectedValues.set(name, change.value); + const serializedValue = serializeExactEnvValue(name, change.value); + const effectiveEntry = entries + .filter((entry) => entry.name === name) + .at(-1); + if (effectiveEntry) { + replacements.set(effectiveEntry, serializedValue); + } else { + appendedLines.push(`${name}=${serializedValue}`); + } + } + + const changed = variables.some( + ({ action }) => action !== "unchanged" && action !== "missing", + ); + if (!changed) { + return { env: currentEnv ?? "", changed, variables }; + } + + const parts: string[] = []; + let cursor = 0; + let removedLastLine = false; + for (const entry of entries) { + const replacement = replacements.get(entry); + if (removedNames.has(entry.name)) { + parts.push(body.slice(cursor, entry.lineStart)); + cursor = Math.min(entry.lineEnd + 1, body.length); + removedLastLine = entry.lineEnd >= body.length; + } else if (replacement !== undefined) { + parts.push(body.slice(cursor, entry.valueStart), replacement); + cursor = entry.valueEnd; + } + } + parts.push(body.slice(cursor)); + + let nextBody = parts.join(""); + if (removedLastLine && nextBody.endsWith("\n")) { + nextBody = nextBody.slice(0, -1); + } + for (const line of appendedLines) { + nextBody = nextBody.length > 0 ? `${nextBody}\n${line}` : line; + } + const nextEnv = + trailingNewline && nextBody.length > 0 ? `${nextBody}\n` : nextBody; + + // The caller cannot read the values back, so the edited text has to parse to + // exactly the intended variables before it is allowed to be stored. + const nextValues = parseEnvValues(nextEnv); + const brokenNames = [ + ...new Set([...expectedValues.keys(), ...nextValues.keys()]), + ].filter((name) => expectedValues.get(name) !== nextValues.get(name)); + if (brokenNames.length > 0) { + throw new Error( + `Environment variables cannot be edited safely: ${brokenNames.join(", ")}`, + ); + } + + return { env: nextEnv, changed, variables }; +}; From 3b1dc9e3cbdf1d991c933ad56ae06d0c77857b10 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:36:12 -0400 Subject: [PATCH 2/2] fix(env): leave fragments of an unquoted key block out of the variable listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - `env.keys` не возвращает «имена», похожие на кусок закодированных данных: от 20 символов, без подчёркивания, со строчными, заглавными буквами и цифрами. - Тест супер-сессии для слепых env-процедур подменяет сервис известных секретов: ответы по API-ключу проходят фильтр, который читает секреты из таблиц, отсутствующих в фальшивой базе этого теста. Зачем: - Если блок ключа вставлен в env без кавычек, dotenv читает его base64-строки с `=` на конце как присваивания, и строка секрета попала бы в список имён переменных, единственный ответ, где имена отдаются открыто. - Тест должен проходить и вместе с фильтром ответов для API-ключей, и без него. Риски: - Настоящая переменная с таким необычным именем (20+ символов, смешанный регистр, цифры, без подчёркивания) не попадёт в список; менять её по имени по-прежнему можно. - Хвост блока короче 20 символов этим правилом не отсекается. Проверки: - Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run __test__/security/super-session-blind-env.test.ts __test__/env` - 250 passed; `biome check` по изменённым файлам - 0 ошибок. - Ограничения: на живой панели не проверялось. What: - `env.keys` does not return "names" that look like a piece of encoded data: 20 characters or more, no underscore, with lowercase letters, uppercase letters and digits. - The super session test of the blind env procedures stubs the known secrets service: API key responses go through a filter that reads secrets from tables this test's fake database does not have. Why: - When a key block is pasted into env without quotes, dotenv reads its base64 lines ending in `=` as assignments, and a line of the secret would land in the list of variable names, the one response where names are returned in clear. - The test has to pass both with the API key response filter and without it. Risks: - A real variable with such an unusual name (20+ characters, mixed case, digits, no underscore) is left out of the listing; it can still be changed by name. - A tail of the block shorter than 20 characters is not caught by this rule. Checks: - Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run __test__/security/super-session-blind-env.test.ts __test__/env` - 250 passed; `biome check` on the changed files - 0 errors. - Limitations: not exercised against a live panel. Co-Authored-By: Claude Opus 5.5 --- apps/dokploy/__test__/env/apply-changes.test.ts | 17 +++++++++++++++++ .../security/super-session-blind-env.test.ts | 7 +++++++ packages/server/src/utils/env-upsert.ts | 12 +++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/__test__/env/apply-changes.test.ts b/apps/dokploy/__test__/env/apply-changes.test.ts index 02d05d8126d..b5dbf667187 100644 --- a/apps/dokploy/__test__/env/apply-changes.test.ts +++ b/apps/dokploy/__test__/env/apply-changes.test.ts @@ -47,6 +47,23 @@ AFTER_BLOCK=1`); ]); }); + it("does not list lines of an unquoted key block as variable names", () => { + const fragment = + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj"; + const variables = listEnvVariables(`SERVICE_KEY=-----BEGIN PRIVATE KEY----- +${fragment}= +-----END PRIVATE KEY----- +AFTER_BLOCK=1 +ApiUrlVersion2=x`); + + expect(variables.map(({ name }) => name)).toEqual([ + "SERVICE_KEY", + "AFTER_BLOCK", + "ApiUrlVersion2", + ]); + expect(JSON.stringify(variables)).not.toContain(fragment); + }); + it("treats a missing field as empty text", () => { expect(listEnvVariables(null)).toEqual([]); expect(listEnvVariables(undefined)).toEqual([]); diff --git a/apps/dokploy/__test__/security/super-session-blind-env.test.ts b/apps/dokploy/__test__/security/super-session-blind-env.test.ts index b5020b5cc39..01afbd57a64 100644 --- a/apps/dokploy/__test__/security/super-session-blind-env.test.ts +++ b/apps/dokploy/__test__/security/super-session-blind-env.test.ts @@ -31,6 +31,13 @@ vi.mock("@dokploy/server/services/permission", () => ({ vi.mock("@/server/api/utils/audit", () => ({ audit: vi.fn() })); +// API key responses are scrubbed of the secrets the panel stores, which are +// read from tables this suite's fake database does not have. +vi.mock("@dokploy/server/services/known-secrets", () => ({ + getKnownSecretMatcher: async () => ({ size: 0, pattern: null }), + resetKnownSecretMatcherCache: () => {}, +})); + const { fake } = (await import("@dokploy/server/db")) as unknown as { fake: ReturnType< typeof import("../helpers/fake-drizzle-db")["createFakeDrizzleDb"] diff --git a/packages/server/src/utils/env-upsert.ts b/packages/server/src/utils/env-upsert.ts index d09c14bdffd..9d482d18c05 100644 --- a/packages/server/src/utils/env-upsert.ts +++ b/packages/server/src/utils/env-upsert.ts @@ -289,6 +289,16 @@ const serializeExactEnvValue = (name: string, value: string) => { ); }; +// A key block pasted without quotes makes dotenv read its base64 lines that +// end in `=` as assignments. Such a "name" is a piece of the secret, so a name +// that looks like encoded data is left out of the listing. +const isEncodedDataFragment = (name: string) => + name.length >= 20 && + !name.includes("_") && + /[a-z]/.test(name) && + /[A-Z]/.test(name) && + /\d/.test(name); + export const listEnvVariables = ( currentEnv: string | null | undefined, ): EnvVariableKey[] => { @@ -297,7 +307,7 @@ export const listEnvVariables = ( const names = new Set(scanEnvEntries(text).map((entry) => entry.name)); return [...names] - .filter((name) => values.has(name)) + .filter((name) => values.has(name) && !isEncodedDataFragment(name)) .map((name) => ({ name, secret: isSecretEnvName(name),