Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion backend/e2e-test/routes/v3/secret-rotations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const formatSqlUsername = (username: string) => `${username}_${uuidv4().slice(0,

const getSecretValue = async (secretKey: string) => {
const passwordSecret = await testServer.inject({
url: `/api/v3/secrets/raw/${secretKey}`,
url: `/api/v3/secrets/raw/${encodeURIComponent(secretKey)}`,
Copy link
Contributor

Choose a reason for hiding this comment

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

Test coverage gap: Secret keys with / are never exercised

The encodeURIComponent addition is correct and defensive, but the test cases do not actually validate keys containing /. All secret names are generated via formatSqlUsername() (e.g., MYSQL_PASSWORD_ABCD1234), which only produces alphanumeric characters and underscores.

To verify the fix works for its intended purpose, add a dedicated test case where a secret key contains / (e.g., by passing a static key like "aws/parameter/store" instead of a formatted username) and assert that the secret retrieval succeeds end-to-end.

method: "GET",
query: {
workspaceId: seedData1.projectV3.id,
Expand Down
17 changes: 17 additions & 0 deletions backend/e2e-test/routes/v4/secrets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,23 @@ describe.each([{ auth: AuthMode.JWT }, { auth: AuthMode.IDENTITY_ACCESS_TOKEN }]
"TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gU2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2lkaWR1bnQgdXQgbGFib3JlIGV0IGRvbG9yZSBtYWduYSBhbGlxdWEuIFV0IGVuaW0gYWQgbWluaW0gdmVuaWFtLCBxdWlzIG5vc3RydWQgZXhlcmNpdGF0aW9uCg==",
comment: ""
}
},
{
path: "/",
secret: {
// Encoding the key simulates the actual HTTP request format
key: encodeURIComponent("path/to/secret"),
value: "slash-value",
comment: "Testing forward slashes in keys"
}
Comment on lines +162 to +165
Copy link
Contributor

Choose a reason for hiding this comment

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

Test encodes the key at parse time, creating a literal percent-encoded string

This test stores encodeURIComponent("path/to/secret"), which evaluates to the literal string "path%2Fto%2Fsecret" at test definition time. The test then passes this already-encoded string into the URL without further encoding. The round-trip succeeds because both create and retrieve operations use the same encoded key.

However, the actual use case — a secret key containing a real forward slash character — is never tested. To verify the feature works, the test data should use the raw slash-containing key, and the createSecret helper should apply URL encoding when constructing the endpoint URL.

},
{
path: "/nested1/nested2/folder",
secret: {
key: encodeURIComponent("another/key/with/slashes"),
value: "nested-slash-value",
comment: "Testing slashes in nested paths"
}
}
];

Expand Down
4 changes: 2 additions & 2 deletions backend/src/server/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,6 @@ export const GenericResourceNameSchema = z
export const BaseSecretNameSchema = z.string().trim().min(1);

export const SecretNameSchema = BaseSecretNameSchema.refine(
(el) => !el.includes(":") && !el.includes("/"),
"Secret name cannot contain colon or forward slash."
(el) => !el.includes(":"),
"Secret name cannot contain colon"
);
50 changes: 25 additions & 25 deletions backend/src/server/routes/v4/secret-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {

server.route({
method: "GET",
url: "/:secretName",
url: "/*",
config: {
rateLimit: secretsLimit
},
Expand All @@ -284,7 +284,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
secretName: z.string().trim().describe(RAW_SECRETS.GET.secretName)
"*": z.string().trim().describe(RAW_SECRETS.GET.secretName)
}),
querystring: z.object({
projectId: z.string().trim().describe(RAW_SECRETS.GET.projectId),
Expand Down Expand Up @@ -336,7 +336,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
projectId,
viewSecretValue: req.query.viewSecretValue,
path: secretPath,
secretName: req.params.secretName,
secretName: req.params["*"],
type: req.query.type,
includeImports: req.query.includeImports,
version: req.query.version
Expand All @@ -351,7 +351,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
environment,
secretPath: req.query.secretPath,
secretId: secret.id,
secretKey: req.params.secretName,
secretKey: req.params["*"],
secretVersion: secret.version,
secretMetadata: secret.secretMetadata?.map((meta) => ({
key: meta.key,
Expand Down Expand Up @@ -383,7 +383,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {

server.route({
method: "POST",
url: "/:secretName",
url: "/*",
config: {
rateLimit: secretsLimit
},
Expand All @@ -398,7 +398,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
secretName: SecretNameSchema.describe(RAW_SECRETS.CREATE.secretName)
"*": SecretNameSchema.describe(RAW_SECRETS.CREATE.secretName)
}),
body: z.object({
projectId: z.string().trim().describe(RAW_SECRETS.CREATE.projectId),
Expand Down Expand Up @@ -449,7 +449,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
actorAuthMethod: req.permission.authMethod,
projectId: req.body.projectId,
secretPath: req.body.secretPath,
secretName: req.params.secretName,
secretName: req.params["*"],
type: req.body.type,
secretValue: req.body.secretValue,
skipMultilineEncoding: req.body.skipMultilineEncoding,
Expand All @@ -471,7 +471,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
secretApprovalRequestSlug: secretOperation.approval.slug,
secretPath: req.body.secretPath,
environment: req.body.environment,
secretKey: req.params.secretName,
secretKey: req.params["*"],
eventType: SecretApprovalEvent.Create
}
}
Expand All @@ -490,7 +490,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
environment: req.body.environment,
secretPath: req.body.secretPath,
secretId: secret.id,
secretKey: req.params.secretName,
secretKey: req.params["*"],
secretVersion: secret.version,
secretMetadata: req.body.secretMetadata?.map((meta) => ({
key: meta.key,
Expand Down Expand Up @@ -522,7 +522,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {

server.route({
method: "PATCH",
url: "/:secretName",
url: "/*",
config: {
rateLimit: secretsLimit
},
Expand All @@ -537,7 +537,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
secretName: BaseSecretNameSchema.describe(RAW_SECRETS.UPDATE.secretName)
"*": BaseSecretNameSchema.describe(RAW_SECRETS.UPDATE.secretName)
}),
body: z.object({
projectId: z.string().trim().describe(RAW_SECRETS.UPDATE.projectId),
Expand Down Expand Up @@ -594,7 +594,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
environment: req.body.environment,
projectId: req.body.projectId,
secretPath: req.body.secretPath,
secretName: req.params.secretName,
secretName: req.params["*"],
type: req.body.type,
secretValue: req.body.secretValue,
skipMultilineEncoding: req.body.skipMultilineEncoding,
Expand All @@ -620,7 +620,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
secretApprovalRequestSlug: secretOperation.approval.slug,
secretPath: req.body.secretPath,
environment: req.body.environment,
secretKey: req.params.secretName,
secretKey: req.params["*"],
eventType: SecretApprovalEvent.Update
}
}
Expand All @@ -639,7 +639,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
environment: req.body.environment,
secretPath: req.body.secretPath,
secretId: secret.id,
secretKey: req.params.secretName,
secretKey: req.params["*"],
secretVersion: secret.version,
secretMetadata: req.body.secretMetadata?.map((meta) => ({
key: meta.key,
Expand Down Expand Up @@ -670,7 +670,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {

server.route({
method: "DELETE",
url: "/:secretName",
url: "/*",
config: {
rateLimit: secretsLimit
},
Expand All @@ -685,7 +685,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
secretName: z.string().min(1).describe(RAW_SECRETS.DELETE.secretName)
"*": z.string().min(1).describe(RAW_SECRETS.DELETE.secretName)
}),
body: z.object({
projectId: z.string().trim().describe(RAW_SECRETS.DELETE.projectId),
Expand Down Expand Up @@ -719,7 +719,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
environment: req.body.environment,
projectId: req.body.projectId,
secretPath: req.body.secretPath,
secretName: req.params.secretName,
secretName: req.params["*"],
type: req.body.type
});
if (secretOperation.type === SecretProtectionType.Approval) {
Expand All @@ -734,7 +734,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
secretApprovalRequestSlug: secretOperation.approval.slug,
secretPath: req.body.secretPath,
environment: req.body.environment,
secretKey: req.params.secretName,
secretKey: req.params["*"],
eventType: SecretApprovalEvent.Delete
}
}
Expand All @@ -754,7 +754,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
environment: req.body.environment,
secretPath: req.body.secretPath,
secretId: secret.id,
secretKey: req.params.secretName,
secretKey: req.params["*"],
secretVersion: secret.version
}
}
Expand Down Expand Up @@ -1273,7 +1273,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {

server.route({
method: "GET",
url: "/:secretName/reference-dependency-tree",
url: "/*/reference-dependency-tree",
config: {
rateLimit: secretsLimit
},
Expand All @@ -1288,7 +1288,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
secretName: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.secretName)
"*": z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.secretName)
}),
querystring: z.object({
projectId: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.projectId),
Expand All @@ -1308,7 +1308,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { secretName } = req.params;
const { secretName } = req.params["*"],
const { secretPath, environment, projectId } = req.query;
Comment on lines +1311 to 1312
Copy link
Contributor

Choose a reason for hiding this comment

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

Syntax error: destructuring from a string always yields undefined

req.params["*"] is a string value (e.g., "path%2Fto%2Fsecret"), not an object with a secretName property. Attempting to destructure const { secretName } = req.params["*"] will always leave secretName as undefined, breaking the getSecretReferenceDependencyTree call that follows at line 1314.

The same error occurs at line 1367 in the secret-reference-tree handler.

Suggested change
const { secretName } = req.params["*"],
const { secretPath, environment, projectId } = req.query;
const secretName = req.params["*"];
const { secretPath, environment, projectId } = req.query;


const { tree } = await server.services.secret.getSecretReferenceDependencyTree({
Expand All @@ -1328,7 +1328,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {

server.route({
method: "GET",
url: "/:secretName/secret-reference-tree",
url: "/*/secret-reference-tree",
config: {
rateLimit: secretsLimit
},
Expand All @@ -1343,7 +1343,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
secretName: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.secretName)
"*": z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.secretName)
}),
querystring: z.object({
projectId: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.projectId),
Expand All @@ -1364,7 +1364,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { secretName } = req.params;
const { secretName } = req.params["*"],
const { secretPath, environment, projectId } = req.query;
const { tree, value, secret } = await server.services.secret.getSecretReferenceTree({
actorId: req.permission.id,
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/hooks/api/secrets/mutations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const useCreateSecretV3 = ({
skipMultilineEncoding,
tagIds
}) => {
const { data } = await apiRequest.post(`/api/v4/secrets/${secretKey}`, {
const { data } = await apiRequest.post(`/api/v4/secrets/${encodeURIComponent(secretKey)}`, {
Copy link
Contributor

Choose a reason for hiding this comment

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

POST endpoint will still reject secret names containing / after encoding

The backend v4 POST route at /:secretName uses SecretNameSchema (in backend/src/server/lib/schemas.ts:45-48) which explicitly rejects names containing /:

export const SecretNameSchema = BaseSecretNameSchema.refine(
  (el) => !el.includes(":") && !el.includes("/"),
  "Secret name cannot contain colon or forward slash."
);

The frontend correctly encodes the secret key as %2F before sending the request, but Fastify's router URL-decodes the path parameter before schema validation. So %2F becomes / and fails the SecretNameSchema check with a 400 error.

Impact: The fix works for GET, PATCH, and DELETE operations (which use permissive validators like z.string().trim() or BaseSecretNameSchema), but creating a new secret with / in the key will still fail on the backend. This PR fixes retrieval/update/deletion for secrets that already have / in their keys (e.g., synced from AWS Parameter Store), but not new creation.

To support creation: The POST route's SecretNameSchema validation should be updated to permit / in the key, or Fastify/find-my-way should be configured to skip decoding %2F for path parameters before validation.

secretPath,
type,
environment,
Expand Down Expand Up @@ -106,7 +106,7 @@ export const useUpdateSecretV3 = ({
skipMultilineEncoding,
secretMetadata
}) => {
const { data } = await apiRequest.patch(`/api/v4/secrets/${secretKey}`, {
const { data } = await apiRequest.patch(`/api/v4/secrets/${encodeURIComponent(secretKey)}`, {
projectId,
environment,
type,
Expand Down Expand Up @@ -163,7 +163,7 @@ export const useDeleteSecretV3 = ({

return useMutation<object, object, TDeleteSecretsV3DTO>({
mutationFn: async ({ secretPath = "/", type, environment, projectId, secretKey, secretId }) => {
const { data } = await apiRequest.delete(`/api/v4/secrets/${secretKey}`, {
const { data } = await apiRequest.delete(`/api/v4/secrets/${encodeURIComponent(secretKey)}`, {
data: {
projectId,
environment,
Expand Down Expand Up @@ -443,7 +443,7 @@ export const useMoveSecrets = ({
};

export const createSecret = async (dto: TCreateSecretsV3DTO) => {
const { data } = await apiRequest.post(`/api/v4/secrets/${dto.secretKey}`, dto);
const { data } = await apiRequest.post(`/api/v4/secrets/${encodeURIComponent(dto.secretKey)}`, dto);
return data;
};

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/api/secrets/queries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ export const useGetSecretAccessList = (dto: TGetSecretAccessListDTO) =>
groups: SecretAccessListEntry[];
identities: SecretAccessListEntry[];
users: SecretAccessListEntry[];
}>(`/api/v1/secrets/${dto.secretKey}/access-list`, {
}>(`/api/v1/secrets/${encodeURIComponent(dto.secretKey)}/access-list`, {
params: {
projectId: dto.projectId,
environment: dto.environment,
Expand All @@ -322,7 +322,7 @@ const fetchSecretReferenceTree = async ({
environmentSlug
}: TGetSecretReferenceTreeDTO) => {
const { data } = await apiRequest.get<{ tree: TSecretReferenceTraceNode; value: string }>(
`/api/v4/secrets/${secretKey}/secret-reference-tree`,
`/api/v4/secrets/${encodeURIComponent(secretKey)}/secret-reference-tree`,
{
params: {
secretPath,
Expand Down