diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts index 689bf9b06b1..19ca3460b1f 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { createSecretSchema } from "./secret.js"; +import { + createSecretProviderConfigSchema, + createSecretSchema, + secretProviderConfigPayloadSchema, + updateSecretProviderConfigSchema, +} from "./secret.js"; describe("secret validators", () => { it("rejects externalRef on managed secrets", () => { @@ -22,4 +27,77 @@ describe("secret validators", () => { expect(parsed.externalRef).toContain(":secret:shared/other"); }); + + it("accepts non-sensitive local and AWS provider vault metadata", () => { + expect(() => + createSecretProviderConfigSchema.parse({ + provider: "local_encrypted", + displayName: "Local", + config: { backupReminderAcknowledged: true }, + }), + ).not.toThrow(); + + expect(() => + createSecretProviderConfigSchema.parse({ + provider: "aws_secrets_manager", + displayName: "AWS", + config: { + region: "us-east-1", + namespace: "production", + secretNamePrefix: "paperclip", + }, + }), + ).not.toThrow(); + }); + + it("accepts origin-only Vault provider vault addresses", () => { + expect(() => + createSecretProviderConfigSchema.parse({ + provider: "vault", + displayName: "Vault draft", + config: { address: " https://vault.example.com/ " }, + }), + ).not.toThrow(); + + const parsed = secretProviderConfigPayloadSchema.parse({ + provider: "vault", + config: { address: " https://vault.example.com/ " }, + }); + + expect(parsed.provider).toBe("vault"); + if (parsed.provider !== "vault") throw new Error("Expected vault provider payload"); + expect(parsed.config.address).toBe("https://vault.example.com"); + }); + + it.each([ + "https://user:pass@vault.example.com", + "https://vault.example.com?token=hvs.x", + "https://vault.example.com#token=hvs.x", + "https://vault.example.com/v1/secret", + ])("rejects credential-bearing or non-origin Vault addresses: %s", (address) => { + expect(() => + createSecretProviderConfigSchema.parse({ + provider: "vault", + displayName: "Vault draft", + config: { address }, + }), + ).toThrow(/origin-only HTTP\(S\) URL/i); + }); + + it("rejects unsafe Vault addresses in provider payload validation used by updates", () => { + expect(() => + secretProviderConfigPayloadSchema.parse({ + provider: "vault", + config: { address: "https://vault.example.com?client_token=hvs.x" }, + }), + ).toThrow(/origin-only HTTP\(S\) URL/i); + }); + + it("rejects unsafe Vault addresses in provider vault update payloads", () => { + expect(() => + updateSecretProviderConfigSchema.parse({ + config: { address: "https://vault.example.com#token=hvs.x" }, + }), + ).toThrow(/origin-only HTTP\(S\) URL/i); + }); }); diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index ee2c5a45fe2..f78cfc70af1 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -141,8 +141,46 @@ export const gcpSecretManagerProviderConfigSchema = z.object({ secretNamePrefix: optionalSafeShortText, }).strict(); +const vaultAddressSchema = z.preprocess( + (value) => typeof value === "string" ? value.trim() : value, + z.string().url().superRefine((value, ctx) => { + let url: URL; + try { + url = new URL(value); + } catch { + return; + } + const hasPath = url.pathname !== "" && url.pathname !== "/"; + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username || + url.password || + url.search || + url.hash || + hasPath + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Vault address must be an origin-only HTTP(S) URL without credentials, path, query, or fragment", + }); + } + }).transform((value) => new URL(value).origin), +); + +function rejectUnsafeVaultAddress(value: unknown, ctx: z.RefinementCtx) { + if (value === undefined || value === null) return; + const parsed = vaultAddressSchema.safeParse(value); + if (parsed.success) return; + for (const issue of parsed.error.issues) { + ctx.addIssue({ + ...issue, + path: ["config", "address", ...issue.path], + }); + } +} + export const vaultProviderConfigSchema = z.object({ - address: z.string().trim().url().optional().nullable(), + address: vaultAddressSchema.optional().nullable(), namespace: optionalSafeShortText, mountPath: optionalSafeShortText, secretPathPrefix: optionalSafeShortText, @@ -202,6 +240,7 @@ export const updateSecretProviderConfigSchema = z.object({ }).superRefine((value, ctx) => { if (value.config !== undefined) { rejectSensitiveProviderConfigKeys(value.config, ctx); + rejectUnsafeVaultAddress(value.config.address, ctx); } if ((value.status === "coming_soon" || value.status === "disabled") && value.isDefault) { ctx.addIssue({ diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index da6f084a0fb..1b70a544100 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -141,6 +141,48 @@ describe("secret routes", () => { expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled(); }); + it("rejects credential-bearing Vault provider vault addresses before persistence", async () => { + const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ + provider: "vault", + displayName: "Vault draft", + config: { + address: "https://user:pass@vault.example.com", + }, + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i); + expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled(); + }); + + it.each([ + "https://vault.example.com?token=hvs.x", + "https://vault.example.com#token=hvs.x", + ])("rejects token-bearing Vault provider vault address %s before persistence", async (address) => { + const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ + provider: "vault", + displayName: "Vault draft", + config: { address }, + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i); + expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled(); + }); + + it("rejects unsafe Vault provider vault address patches before persistence", async () => { + const res = await request(createApp()).patch("/api/secret-provider-configs/vault-1").send({ + config: { + address: "https://vault.example.com#token=hvs.x", + }, + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i); + expect(mockSecretService.getProviderConfigById).not.toHaveBeenCalled(); + expect(mockSecretService.updateProviderConfig).not.toHaveBeenCalled(); + }); + it("creates provider vaults and logs safe activity details", async () => { const createdAt = new Date("2026-05-06T00:00:00.000Z"); mockSecretService.createProviderConfig.mockResolvedValue({