diff --git a/src/components/admin/status-card-grid.tsx b/src/components/admin/status-card-grid.tsx index edc819b15..47094c43c 100644 --- a/src/components/admin/status-card-grid.tsx +++ b/src/components/admin/status-card-grid.tsx @@ -157,7 +157,7 @@ export function buildCards(data: StatusOverview): StatusCardProps[] { { label: "Push", value: data.integrations.webPush }, ], href: "/admin#section-admin-umami", - cta: "Configure", + cta: "Open integrations", }, { title: "Monitoring", @@ -187,7 +187,7 @@ export function buildCards(data: StatusOverview): StatusCardProps[] { { label: "Retain", value: `${data.backups.retentionDays}d` }, ], href: "/admin#section-system-status", - cta: "View backups", + cta: "Open system status", }, { title: "Maintenance", diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index fafb8727d..e830c235a 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -362,7 +362,7 @@ function InsightsSettingsCard({ {settings?.privacyMode === "raw" && ( -
+
{t("settings.rawDataWarning")}
)} diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index 866e8762b..4d73e6eb0 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -300,6 +300,24 @@ describe("withIdempotency body-content exclusion (P12)", () => { expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); }); + it.each([ + ["task-id substring", '{"error":"task-id must not contain spaces"}'], + ["risk-management word", '{"data":{"note":"risk-management"}}'], + ["disk-io metric", '{"data":{"metric":"disk-io"}}'], + ])( + "still caches a benign payload whose text contains %s (no false positive)", + async (_label, body) => { + const handler = vi.fn( + async () => new NextResponse(body, { status: 422 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + await wrapped( + makeRequest("POST", { "idempotency-key": "key-12345678" }), + ); + expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + }, + ); + it("still caches a normal payload that does not carry a secret", async () => { const handler = vi.fn( async () => diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 8c6131675..f9c3a8852 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -224,14 +224,15 @@ export function withIdempotency< // with, but if a future caller forgets we refuse to leak. // `hlk_` = our access tokens // `hlr_` = our refresh tokens - // `sk-` = OpenAI keys (also matches `sk-ant-` Anthropic keys) + // `sk-…` = OpenAI / Anthropic keys (full token form, not the + // raw substring — a 422 body explaining "task-id…" + // or any other word containing `sk-` would otherwise + // silently break idempotency for benign retries). + const SECRET_PATTERN = + /(?:\b(?:hlk_|hlr_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; const cloned = response.clone(); const text = await cloned.text(); - if ( - !text.includes("hlk_") && - !text.includes("hlr_") && - !text.includes("sk-") - ) { + if (!SECRET_PATTERN.test(text)) { await persistCached(ctx, response, text); } }