Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"postinstall": "prisma generate",
"generate:openapi": "tsx scripts/generate-openapi.ts"
"generate:openapi": "tsx scripts/generate-openapi.ts",
"backfill:channel-secrets": "tsx scripts/backfill-channel-secrets.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
Expand Down
28 changes: 28 additions & 0 deletions scripts/backfill-channel-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env tsx
import { prisma } from "@/lib/prisma";
import { encryptChannelConfig, SENSITIVE_FIELDS_BY_TYPE } from "@/server/services/channel-secrets";

async function main() {
const channels = await prisma.notificationChannel.findMany({
select: { id: true, type: true, config: true },
});

let updated = 0, skipped = 0;
for (const ch of channels) {
if (!SENSITIVE_FIELDS_BY_TYPE[ch.type]) { skipped++; continue; }
const config = ch.config as Record<string, unknown>;
const encrypted = encryptChannelConfig(ch.type, config);
// Only write if at least one field actually changed (idempotent skip)
const changed = Object.keys(encrypted).some((k) => encrypted[k] !== config[k]);
if (!changed) { skipped++; continue; }
await prisma.notificationChannel.update({
where: { id: ch.id },
data: { config: encrypted as never },
});
updated++;
console.log(`encrypted channel ${ch.id} (${ch.type})`);
}
console.log(`done — updated=${updated} skipped=${skipped} total=${channels.length}`);
}

main().catch((e) => { console.error(e); process.exit(1); });
82 changes: 82 additions & 0 deletions src/server/routers/__tests__/alert-channels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ vi.mock("@/server/services/channels", () => ({

import { prisma } from "@/lib/prisma";
import { alertChannelsRouter } from "@/server/routers/alert-channels";
import { encrypt, ENCRYPTION_DOMAINS } from "@/server/services/crypto";

const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
const caller = t.createCallerFactory(alertChannelsRouter)({
Expand Down Expand Up @@ -237,6 +238,25 @@ describe("alertChannelsRouter", () => {
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});

it("encrypts hmacSecret before persisting webhook channel", async () => {
prismaMock.environment.findUnique.mockResolvedValue({ id: "env-1" } as never);
prismaMock.notificationChannel.create.mockResolvedValue(
makeChannel({ type: "webhook" }) as never,
);

await caller.createChannel({
environmentId: "env-1",
name: "Webhook With Secret",
type: "webhook",
config: { url: "https://example.com/hook", hmacSecret: "raw-secret" },
});

const createCall = prismaMock.notificationChannel.create.mock.calls[0][0];
const persisted = (createCall as { data: { config: Record<string, unknown> } }).data.config;
expect(persisted.hmacSecret).toMatch(/^v2:/);
expect(persisted.url).toBe("https://example.com/hook");
});
});

// ─── updateChannel ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -289,6 +309,43 @@ describe("alertChannelsRouter", () => {

expect(mockValidatePublicUrl).toHaveBeenCalledWith("https://hooks.slack.com/new");
});

it("re-encrypts hmacSecret when rotated via update", async () => {
const existing = makeChannel({
type: "webhook",
config: { url: "https://x", hmacSecret: "v2:OLDCIPHERTEXT" },
});
prismaMock.notificationChannel.findUnique.mockResolvedValue(existing as never);
prismaMock.notificationChannel.update.mockResolvedValue(existing as never);

await caller.updateChannel({
id: "ch-1",
config: { url: "https://x", hmacSecret: "new-raw" },
});

const updateCall = prismaMock.notificationChannel.update.mock.calls[0][0];
const persisted = (updateCall as { data: { config: Record<string, unknown> } }).data.config;
expect(persisted.hmacSecret).toMatch(/^v2:/);
expect(persisted.hmacSecret).not.toBe("v2:OLDCIPHERTEXT");
});

it("preserves existing encrypted hmacSecret when not in input", async () => {
const existing = makeChannel({
type: "webhook",
config: { url: "https://x", hmacSecret: "v2:OLDCIPHERTEXT" },
});
prismaMock.notificationChannel.findUnique.mockResolvedValue(existing as never);
prismaMock.notificationChannel.update.mockResolvedValue(existing as never);

await caller.updateChannel({
id: "ch-1",
config: { url: "https://newurl" },
});

const updateCall = prismaMock.notificationChannel.update.mock.calls[0][0];
const persisted = (updateCall as { data: { config: Record<string, unknown> } }).data.config;
expect(persisted.hmacSecret).toBe("v2:OLDCIPHERTEXT");
});
});

// ─── deleteChannel ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -352,5 +409,30 @@ describe("alertChannelsRouter", () => {
caller.testChannel({ id: "ch-missing" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});

it("decrypts hmacSecret before passing config to driver.test", async () => {
const encryptedSecret = encrypt("raw-secret", ENCRYPTION_DOMAINS.SECRETS);
expect(encryptedSecret.startsWith("v2:")).toBe(true);

prismaMock.notificationChannel.findUnique.mockResolvedValue(
makeChannel({
type: "webhook",
config: {
url: "https://hooks.example.com/x",
hmacSecret: encryptedSecret,
},
}) as never,
);
mockChannelTest.mockResolvedValue({ success: true });

await caller.testChannel({ id: "ch-1" });

expect(mockChannelTest).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://hooks.example.com/x",
hmacSecret: "raw-secret",
}),
);
});
});
});
14 changes: 10 additions & 4 deletions src/server/routers/alert-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { prisma } from "@/lib/prisma";
import { withAudit } from "@/server/middleware/audit";
import { validatePublicUrl, validateSmtpHost } from "@/server/services/url-validation";
import { getDriver } from "@/server/services/channels";
import { encryptChannelConfig, decryptChannelConfig } from "@/server/services/channel-secrets";

export const alertChannelsRouter = router({
listChannels: protectedProcedure
Expand Down Expand Up @@ -111,7 +112,7 @@ export const alertChannelsRouter = router({
environmentId: input.environmentId,
name: input.name,
type: input.type,
config: input.config as Prisma.InputJsonValue,
config: encryptChannelConfig(input.type, input.config) as Prisma.InputJsonValue,
},
});
}),
Expand Down Expand Up @@ -194,12 +195,15 @@ export const alertChannelsRouter = router({
}
}

const encryptedConfig =
config !== undefined ? encryptChannelConfig(existing.type, config) : undefined;

return prisma.notificationChannel.update({
where: { id },
data: {
...rest,
...(config !== undefined
? { config: config as Prisma.InputJsonValue }
...(encryptedConfig !== undefined
? { config: encryptedConfig as Prisma.InputJsonValue }
: {}),
},
});
Expand Down Expand Up @@ -243,9 +247,11 @@ export const alertChannelsRouter = router({

try {
const driver = getDriver(channel.type);
const result = await driver.test(
const decrypted = decryptChannelConfig(
channel.type,
channel.config as Record<string, unknown>,
);
const result = await driver.test(decrypted);
return {
success: result.success,
error: result.error,
Expand Down
52 changes: 52 additions & 0 deletions src/server/services/__tests__/channel-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeAll } from "vitest";
import { encryptChannelConfig, decryptChannelConfig } from "../channel-secrets";

beforeAll(() => {
process.env.NEXTAUTH_SECRET = "test-secret-for-vitest-runs-only";
});

describe("encryptChannelConfig", () => {
it("encrypts webhook hmacSecret as v2:-prefixed ciphertext", () => {
const out = encryptChannelConfig("webhook", {
url: "https://example.com/hook",
hmacSecret: "raw-hmac-secret",
});
expect(out.url).toBe("https://example.com/hook");
expect(typeof out.hmacSecret).toBe("string");
expect(out.hmacSecret as string).toMatch(/^v2:/);
});
});

describe("encrypt+decrypt round trip", () => {
it.each([
["webhook", { url: "https://x.test/h", hmacSecret: "s1", headers: { Authorization: "Bearer abc" } }],
["slack", { webhookUrl: "https://hooks.slack.com/services/T/B/XYZ" }],
["pagerduty", { integrationKey: "ROUTING-KEY-123", severityMap: { error: "warning" } }],
["email", { smtpHost: "smtp.x", smtpUser: "u", smtpPass: "p", recipients: ["a@b"] }],
])("round-trips %s", (type, plain) => {
const enc = encryptChannelConfig(type, plain);
const dec = decryptChannelConfig(type, enc);
expect(dec).toEqual(plain);
});
});

describe("idempotency", () => {
it("does not re-encrypt already v2-prefixed values", () => {
const once = encryptChannelConfig("webhook", { hmacSecret: "raw" });
const twice = encryptChannelConfig("webhook", once);
expect(twice.hmacSecret).toBe(once.hmacSecret);
});

it("decrypt is no-op on plaintext", () => {
const out = decryptChannelConfig("webhook", { hmacSecret: "plain" });
expect(out.hmacSecret).toBe("plain");
});
});

describe("unknown type", () => {
it("returns config unchanged for unknown channel type", () => {
const cfg = { foo: "bar" };
expect(encryptChannelConfig("unknown", cfg)).toEqual(cfg);
expect(decryptChannelConfig("unknown", cfg)).toEqual(cfg);
});
});
57 changes: 57 additions & 0 deletions src/server/services/channel-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { encrypt, decrypt, ENCRYPTION_DOMAINS } from "./crypto";

export const SENSITIVE_FIELDS_BY_TYPE: Record<string, readonly string[]> = {
webhook: ["hmacSecret", "headers"],
slack: ["webhookUrl"],
Comment on lines +4 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Decrypt encrypted config fields before exposing edit config

Adding webhook.headers and slack.webhookUrl to the encrypted field map causes those values to be stored as v2: ciphertext, but listChannels still returns channel config values as-is for those keys. The UI edit flow reads them directly (formFromConfig) and then re-validates as URL/JSON-object on submit, so edited Slack channels and webhook channels with headers can fail updates unless users manually re-enter those values. This introduces a regression in the channel-edit workflow after encryption is enabled.

Useful? React with 👍 / 👎.

pagerduty: ["integrationKey"],
email: ["smtpPass"],
};

const V2_PREFIX = "v2:";

function isAlreadyEncrypted(value: unknown): boolean {
return typeof value === "string" && value.startsWith(V2_PREFIX);
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 Replace prefix-only check for already-encrypted secrets

The isAlreadyEncrypted heuristic treats any string beginning with v2: as ciphertext, so legitimate plaintext secrets like v2:token are skipped during encryption and stored unencrypted. At delivery/test time, decryptChannelConfig attempts to decrypt those values and throws, which breaks channel test/delivery for affected configs. This should use a verifiable ciphertext format check (or explicit metadata) rather than a bare prefix test.

Useful? React with 👍 / 👎.

}

function serializeForEncryption(value: unknown): string {
return typeof value === "string" ? value : JSON.stringify(value);
}

export function encryptChannelConfig(
type: string,
config: Record<string, unknown>,
): Record<string, unknown> {
const fields = SENSITIVE_FIELDS_BY_TYPE[type];
if (!fields) return config;

const out: Record<string, unknown> = { ...config };
for (const field of fields) {
const value = out[field];
if (value === undefined || value === null || value === "") continue;
if (isAlreadyEncrypted(value)) continue;
out[field] = encrypt(serializeForEncryption(value), ENCRYPTION_DOMAINS.SECRETS);
}
return out;
}

export function decryptChannelConfig(
type: string,
config: Record<string, unknown>,
): Record<string, unknown> {
const fields = SENSITIVE_FIELDS_BY_TYPE[type];
if (!fields) return config;

const out: Record<string, unknown> = { ...config };
for (const field of fields) {
const value = out[field];
if (typeof value !== "string" || !value.startsWith(V2_PREFIX)) continue;
const plaintext = decrypt(value, ENCRYPTION_DOMAINS.SECRETS);
// headers was originally an object; restore it
if (field === "headers") {
out[field] = JSON.parse(plaintext);
} else {
out[field] = plaintext;
}
}
return out;
}
Loading
Loading