From 0c1dfe978c237ff91cdff0e64ac0d21fa9c39c0d Mon Sep 17 00:00:00 2001 From: Artur Spatari Date: Thu, 10 Sep 2026 10:26:52 +0300 Subject: [PATCH 1/2] fix(vault): read Infisical secrets pulled in through an import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #5413. A secret brought into a folder with "Import Secrets" was reported as not found. Two reasons, and the first is easy to miss: The list endpoint takes the flag in **snake_case**. `includeImports` is silently ignored — the request still returns 200, with `imports` present but empty — so the imported secret looked like it simply did not exist. Measured against app.infisical.com with a registered import (confirmed via GET /api/v1/secret-imports): expandSecretReferences=true imports[] empty expandSecretReferences=true&includeImports=true imports[] empty expandSecretReferences=true&include_imports=true imports[] has the secret Second, imported secrets never appear in `secrets` — they come back in a separate `imports` array, one entry per source path, which the client did not read at all. Imported entries are merged before the folder's own, so a name defined in both resolves to the local value, matching how Infisical resolves it. Also measured, for whoever looks next: `/api/v4/secrets` returns imports with no flag at all, and single-secret reads (`/raw/{name}`) never see an imported key — 404 on v3, no such route on v4. So a folder listing is the only way to reach them. vault.test.ts: 57 passed. Reverting the fix fails the new merge test. Co-Authored-By: Claude Opus 5 (1M context) --- apps/dokploy/__test__/env/vault.test.ts | 44 ++++++++++++++++++++ packages/server/src/utils/vault/infisical.ts | 14 +++++++ 2 files changed, 58 insertions(+) diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index 98fd7e4d9..eff5634ef 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -472,6 +472,50 @@ describe("infisical client", () => { expect(params.get("secretPath")).toBe("/frontend"); }); + it("asks for imported secrets and merges them", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [], + imports: [ + { + secretPath: "/shared", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://imported" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://imported" }); + const [listUrl] = mockFetch.mock.calls[1] as [string]; + // snake_case on purpose: `includeImports` is silently ignored and the + // response comes back with an empty `imports` array. + expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true"); + }); + + it("lets a folder's own secret win over an imported one of the same name", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }], + imports: [ + { + secretPath: "/shared", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://imported" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://local" }); + }); + it("throws a clear error for a missing secret", async () => { mockFetch .mockResolvedValueOnce(loginResponse()) diff --git a/packages/server/src/utils/vault/infisical.ts b/packages/server/src/utils/vault/infisical.ts index 9af3bca2f..7696d15ab 100644 --- a/packages/server/src/utils/vault/infisical.ts +++ b/packages/server/src/utils/vault/infisical.ts @@ -79,6 +79,12 @@ const readPath = async ( // still reports success. Single secrets read via /raw/{name} expand by // default, which makes the difference easy to miss in the UI. expandSecretReferences: "true", + // Secrets pulled in through "Import Secrets" are not part of `secrets`: + // they come back in a separate `imports` array, and only when this is + // asked for. Note the snake_case — `includeImports` is silently ignored + // (the request still returns 200 with an empty `imports`), which is why + // an imported secret looked like it simply did not exist. + include_imports: "true", }); const response = await vaultFetch( `${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`, @@ -93,9 +99,17 @@ const readPath = async ( const body = (await response.json()) as { secrets?: { secretKey: string; secretValue: string }[]; + imports?: { secrets?: { secretKey: string; secretValue: string }[] }[]; }; const secrets: Record = {}; + // Imported first, then the folder's own: Infisical resolves a name defined + // in both in favour of the local one, so writing local last preserves that. + for (const imported of body.imports ?? []) { + for (const secret of imported.secrets ?? []) { + secrets[secret.secretKey] = secret.secretValue; + } + } for (const secret of body.secrets ?? []) { secrets[secret.secretKey] = secret.secretValue; } From e70c880612db27544be6d37500b3e3d4894233a8 Mon Sep 17 00:00:00 2001 From: Artur Spatari Date: Thu, 10 Sep 2026 10:32:09 +0300 Subject: [PATCH 2/2] test(vault): lock the precedence between two imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged that multi-import collisions were untested. The order the code implements is the documented one — Infisical: "If two imports carry a secret with the same name, the value from the bottom-most import wins" — and the response lists imports in that order, so sequential assignment matches it. Added a case with two imports defining the same key. Reversing the merge order in the client fails it, so the test pins the behaviour rather than restating the implementation. vault.test.ts: 58 passed. Co-Authored-By: Claude Opus 5 (1M context) --- apps/dokploy/__test__/env/vault.test.ts | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index eff5634ef..b1381cd05 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -496,6 +496,33 @@ describe("infisical client", () => { expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true"); }); + it("lets the later import win when two define the same key", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [], + imports: [ + { + secretPath: "/base", + secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }], + }, + { + secretPath: "/override", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://override" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + // Infisical documents this order: "If two imports carry a secret with the + // same name, the value from the bottom-most import wins." The response + // lists imports in that order, so assigning them in sequence matches it. + expect(result).toEqual({ DB_URL: "postgres://override" }); + }); + it("lets a folder's own secret win over an imported one of the same name", async () => { mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( jsonResponse({