From 77ad926424eaae7d57be848bea482623d01c45c2 Mon Sep 17 00:00:00 2001 From: Jaydeep Pipaliya Date: Sat, 16 May 2026 12:43:41 +0530 Subject: [PATCH] fix(secret-references): resolve dotted local secret names before env.path.key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secret names may contain dots (e.g. when targeting `appsettings.json`-style transforms in Azure DevOps), but `${A.B}` was always parsed as a nested reference to env=A, secret=B at root — making `${Secret.Reference}` fail to resolve a local secret literally named `Secret.Reference`. The expansion step now tries the dotted local name first and only falls back to env.path.key when no local secret with that exact name exists. Existing nested references (e.g. `${prod.deep.KEY}`) are unaffected. Fixes #5962 --- .../secret-reference-fns.test.ts | 134 ++++++++++++++++++ .../secret-v2-bridge/secret-reference-fns.ts | 26 +++- 2 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 backend/src/services/secret-v2-bridge/secret-reference-fns.test.ts diff --git a/backend/src/services/secret-v2-bridge/secret-reference-fns.test.ts b/backend/src/services/secret-v2-bridge/secret-reference-fns.test.ts new file mode 100644 index 00000000000..fe3bd79ffd9 --- /dev/null +++ b/backend/src/services/secret-v2-bridge/secret-reference-fns.test.ts @@ -0,0 +1,134 @@ +import { expandSecretReferencesFactory, getAllSecretReferences } from "./secret-reference-fns"; + +const makeFolderDAL = (folders: Record) => ({ + findBySecretPath: vi.fn().mockImplementation((_projectId: string, env: string, secretPath: string) => { + const id = folders[`${env}:${secretPath}`]; + return id ? Promise.resolve({ id }) : Promise.resolve(undefined); + }) +}); + +const makeSecretDAL = (secretsByFolder: Record>) => ({ + findByFolderId: vi.fn().mockImplementation(({ folderId }: { folderId: string }) => + Promise.resolve( + (secretsByFolder[folderId] ?? []).map((s) => ({ + key: s.key, + encryptedValue: Buffer.from(s.value), + tags: [] + })) + ) + ) +}); + +const makeFactoryDeps = (overrides?: { canExpandValue?: () => boolean }) => ({ + projectId: "p1", + decryptSecretValue: (buf?: Buffer | null) => (buf ? buf.toString() : undefined), + canExpandValue: overrides?.canExpandValue ?? (() => true), + userId: undefined as string | undefined +}); + +/* eslint-disable no-template-curly-in-string */ +describe("getAllSecretReferences", () => { + test("classifies single-token references as local", () => { + const { localReferences, nestedReferences } = getAllSecretReferences("hello ${HELLO}"); + expect(localReferences).toEqual(["HELLO"]); + expect(nestedReferences).toEqual([]); + }); + + test("classifies multi-token references as nested with env/path/key split", () => { + const { nestedReferences } = getAllSecretReferences("${prod.deep.nested.KEY}"); + expect(nestedReferences).toEqual([{ environment: "prod", secretPath: "/deep/nested", secretKey: "KEY" }]); + }); +}); + +describe("expandSecretReferencesFactory", () => { + /** + * @see https://github.com/Infisical/infisical/issues/5962 + */ + test("resolves a local secret whose name contains a dot before falling back to env.path.key", async () => { + const folderDAL = makeFolderDAL({ "dev:/": "folder-dev-root" }); + const secretDAL = makeSecretDAL({ + "folder-dev-root": [ + { key: "Secret.Reference", value: "test" }, + // eslint-disable-next-line no-template-curly-in-string + { key: "Secret_Test", value: "${Secret.Reference}" } + ] + }); + + const { expandSecretReferences } = expandSecretReferencesFactory({ + ...makeFactoryDeps(), + folderDAL: folderDAL as never, + secretDAL: secretDAL as never + }); + + const expanded = await expandSecretReferences({ + // eslint-disable-next-line no-template-curly-in-string + value: "${Secret.Reference}", + environment: "dev", + secretPath: "/", + secretKey: "Secret_Test" + }); + + expect(expanded).toBe("test"); + }); + + test("still resolves nested env.path.key references when no local dotted name exists", async () => { + const folderDAL = makeFolderDAL({ + "dev:/": "folder-dev-root", + "prod:/": "folder-prod-root" + }); + const secretDAL = makeSecretDAL({ + "folder-prod-root": [{ key: "API_KEY", value: "prod-secret" }], + "folder-dev-root": [ + // eslint-disable-next-line no-template-curly-in-string + { key: "DEV_REF", value: "${prod.API_KEY}" } + ] + }); + + const { expandSecretReferences } = expandSecretReferencesFactory({ + ...makeFactoryDeps(), + folderDAL: folderDAL as never, + secretDAL: secretDAL as never + }); + + const expanded = await expandSecretReferences({ + // eslint-disable-next-line no-template-curly-in-string + value: "${prod.API_KEY}", + environment: "dev", + secretPath: "/", + secretKey: "DEV_REF" + }); + + expect(expanded).toBe("prod-secret"); + }); + + test("prefers the local dotted match over a coincidental nested env match", async () => { + // Both interpretations are valid here: + // - local secret literally named "prod.API_KEY" in dev + // - nested ref to env=prod, secret=API_KEY at root + // The local match must win to match the issue's expected behavior. + const folderDAL = makeFolderDAL({ + "dev:/": "folder-dev-root", + "prod:/": "folder-prod-root" + }); + const secretDAL = makeSecretDAL({ + "folder-dev-root": [{ key: "prod.API_KEY", value: "local-wins" }], + "folder-prod-root": [{ key: "API_KEY", value: "nested-loses" }] + }); + + const { expandSecretReferences } = expandSecretReferencesFactory({ + ...makeFactoryDeps(), + folderDAL: folderDAL as never, + secretDAL: secretDAL as never + }); + + const expanded = await expandSecretReferences({ + // eslint-disable-next-line no-template-curly-in-string + value: "${prod.API_KEY}", + environment: "dev", + secretPath: "/", + secretKey: "consumer" + }); + + expect(expanded).toBe("local-wins"); + }); +}); diff --git a/backend/src/services/secret-v2-bridge/secret-reference-fns.ts b/backend/src/services/secret-v2-bridge/secret-reference-fns.ts index 92e9ed78ff6..b22d25c7277 100644 --- a/backend/src/services/secret-v2-bridge/secret-reference-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-reference-fns.ts @@ -173,11 +173,29 @@ export const expandSecretReferencesFactory = ({ let referencedSecretEnvironmentSlug = ""; let referencedSecretValue = ""; - if (entities.length === 1) { - const [secretKey] = entities; + // Resolve the reference. Secret names may contain dots + // (e.g. `appsettings.json`-style transforms), so `${A.B}` could mean + // either a local secret literally named `A.B` or a nested reference + // to env=A, secret=B at root. Prefer the local match when one exists, + // and fall back to env.path.key only when no local secret is found. + // @see https://github.com/Infisical/infisical/issues/5962 + const dottedSecretKey = interpolationKey.trim(); + let localCandidate: { value: string; tags: string[] } | null = null; + if (entities.length > 1 && dottedSecretKey !== "") { + // eslint-disable-next-line no-await-in-loop + localCandidate = await fetchSecret(environment, secretPath, dottedSecretKey); + } - // eslint-disable-next-line no-continue,no-await-in-loop - const referredValue = await fetchSecret(environment, secretPath, secretKey); + if (entities.length === 1 || localCandidate?.value) { + const secretKey = entities.length === 1 ? entities[0] : dottedSecretKey; + + let referredValue: { value: string; tags: string[] }; + if (localCandidate) { + referredValue = localCandidate; + } else { + // eslint-disable-next-line no-await-in-loop + referredValue = await fetchSecret(environment, secretPath, secretKey); + } if (!canExpandValue(environment, secretPath, secretKey, referredValue.tags)) throw new ForbiddenRequestError({ message: `You do not have permission to read secret '${secretKey}' in environment '${environment}' at path '${secretPath}', which is referenced by secret '${dto.secretKey}' in environment '${dto.environment}' at path '${dto.secretPath}'.`