Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/e2e-test/routes/v3/secret-reference.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,53 @@ describe("Secret expansion", () => {
await Promise.all(secrets.map((el) => deleteSecretV2(el)));
});

test("Local secret reference to non-existent secret keeps literal reference", async () => {
const secrets = [
{
environmentSlug: seedData1.environment.slug,
workspaceId: projectId,
secretPath: "/",
authToken: jwtAuthToken,
key: "TEST",
// eslint-disable-next-line
value: "hello ${NON_EXISTENT_SECRET}"
}
];

for (const secret of secrets) {
// eslint-disable-next-line no-await-in-loop
await createSecretV2(secret);
}

const expandedSecret = await getSecretByNameV2({
environmentSlug: seedData1.environment.slug,
workspaceId: projectId,
secretPath: "/",
authToken: jwtAuthToken,
key: "TEST"
});
// eslint-disable-next-line
expect(expandedSecret.secretValue).toBe("hello ${NON_EXISTENT_SECRET}");

const listSecrets = await getSecretsV2({
environmentSlug: seedData1.environment.slug,
workspaceId: projectId,
secretPath: "/",
authToken: jwtAuthToken
});
expect(listSecrets.secrets).toEqual(
expect.arrayContaining([
expect.objectContaining({
secretKey: "TEST",
// eslint-disable-next-line
secretValue: "hello ${NON_EXISTENT_SECRET}"
})
])
);

await Promise.all(secrets.map((el) => deleteSecretV2(el)));
});

test("Cross environment secret reference", async () => {
const secrets = [
{
Expand Down
32 changes: 23 additions & 9 deletions backend/src/services/secret-v2-bridge/secret-reference-fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,22 @@ export const expandSecretReferencesFactory = ({
const secretCache: Record<string, Record<string, { value: string; tags: string[] }>> = {};
const getCacheUniqueKey = (environment: string, secretPath: string) => `${environment}-${secretPath}`;

const fetchSecret = async (environment: string, secretPath: string, secretKey: string) => {
const fetchSecret = async (
environment: string,
secretPath: string,
secretKey: string
): Promise<{ value: string; tags: string[]; exists: boolean }> => {
const cacheKey = getCacheUniqueKey(environment, secretPath);

if (secretCache?.[cacheKey]) {
return secretCache[cacheKey][secretKey] || { value: "", tags: [] };
const cachedSecret = secretCache[cacheKey][secretKey];
if (cachedSecret) return { ...cachedSecret, exists: true };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not mark negative cache hits as existing

When a missing reference is fetched below, its { exists: false } result is written into secretCache; on the next lookup this branch sees that truthy cached object and overwrites it to exists: true. A value such as ${MISSING} ${MISSING} (or two secrets expanded by the same factory that both reference MISSING) will therefore remove the second/future occurrence(s) as an empty string instead of preserving the literal, which is exactly the case this fix is meant to protect.

Useful? React with 👍 / 👎.

return { value: "", tags: [], exists: false };
}

try {
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return { value: "", tags: [] };
if (!folder) return { value: "", tags: [], exists: false };
// When userId is provided, findByFolderId returns both shared and personal secrets.
// Personal overrides will take precedence over shared secrets in the reduce below.
const secrets = await secretDAL.findByFolderId({ folderId: folder.id, userId });
Expand All @@ -122,10 +128,12 @@ export const expandSecretReferencesFactory = ({

secretCache[cacheKey] = decryptedSecret;

return secretCache[cacheKey][secretKey] || { value: "", tags: [] };
const fetchedSecret = secretCache[cacheKey][secretKey];
if (fetchedSecret) return { ...fetchedSecret, exists: true };
return { value: "", tags: [], exists: false };
} catch (error) {
secretCache[cacheKey] = {};
return { value: "", tags: [] };
return { value: "", tags: [], exists: false };
}
};

Expand Down Expand Up @@ -174,6 +182,7 @@ export const expandSecretReferencesFactory = ({
let referencedSecretKey = "";
let referencedSecretEnvironmentSlug = "";
let referencedSecretValue = "";
let referencedSecretExists = false;

if (entities.length === 1) {
const [secretKey] = entities;
Expand All @@ -190,6 +199,7 @@ export const expandSecretReferencesFactory = ({
secretCache[cacheKey][secretKey] = referredValue;

referencedSecretValue = referredValue.value;
referencedSecretExists = referredValue.exists;
referencedSecretKey = secretKey;
referencedSecretPath = secretPath;
referencedSecretEnvironmentSlug = environment;
Expand All @@ -210,6 +220,7 @@ export const expandSecretReferencesFactory = ({
secretCache[cacheKey][secretReferenceKey] = referedValue;

referencedSecretValue = referedValue.value;
referencedSecretExists = referedValue.exists;
referencedSecretKey = secretReferenceKey;
referencedSecretPath = secretReferencePath;
referencedSecretEnvironmentSlug = secretReferenceEnvironment;
Expand Down Expand Up @@ -247,10 +258,13 @@ export const expandSecretReferencesFactory = ({
stack.push({ ...node, visitedSecrets: newVisitedSecrets });
}

expandedValue = expandedValue.replaceAll(
interpolationSyntax,
() => referencedSecretValue // prevents special characters from triggering replacement patterns
);
if (referencedSecretExists) {
expandedValue = expandedValue.replaceAll(
interpolationSyntax,
() => referencedSecretValue // prevents special characters from triggering replacement patterns
);
}
// when the referenced secret does not exist, leave the literal ${REF} untouched
}
}
}
Expand Down
Loading