mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Reject unsafe Vault provider addresses
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6000f0cb6a
commit
92047d531c
3 files changed
+161
-2
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user