From ee7d5831192d305dfae14b54847b6b89285831e7 Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Thu, 17 Sep 2026 19:19:00 +0800 Subject: [PATCH] fix(settings): clear revealed DB connection DSN when switching DB nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #355. Revealing a connection string on the DB Settings pane left the DSN in useRevealedRow state for 30s while the pane retargets in place, and the row key was connection.id (private/public) — identical across every DB Service. Switching nodes within the reveal window made the next DB's connection row display (and copy) the previous DB's full DSN. Scope the settings rows' keys by workload identity so a reveal can never match another DB's row, and clear the revealed row when the identity changes (useRevealedRow now exposes clearRevealedRow) so the secret does not linger in state after the switch. --- .../db/db-settings-node-switch.test.tsx | 83 ++++++++++++++++++- .../db/db-settings-sections.tsx | 21 ++++- apps/ui/src/lib/use-revealed-row.ts | 4 +- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx b/apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx index f4b0db71..a3540c42 100644 --- a/apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx +++ b/apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { render } from "@testing-library/react/pure"; +import { fireEvent, render } from "@testing-library/react/pure"; import type { ReactNode } from "react"; import type { ProjectDbTarget } from "@/features/panes/target-identity"; import { @@ -16,6 +16,8 @@ import { } from "../settings-provider-db"; const NAMESPACE = "ns-switch-test"; +const PGSQL_HOST_PATTERN = /PGSQL-LEAK-HOST/; +const REDIS_HOST_PATTERN = /REDIS-HOST/; const RESOURCES_SECTION_PATTERN = /Replicas & Resources/; function dbClaim(input: { @@ -98,6 +100,79 @@ function providerElement(input: { ); } +test("a revealed connection DSN does not leak onto another DB node's rows", async () => { + await withTestDom(async (actAndDrain) => { + const POSTGRES_DSN = "postgresql://u:pw@PGSQL-LEAK-HOST:5432/db"; + const REDIS_DSN = "redis://u:pw@REDIS-HOST:6379/0"; + const { override } = stubFetch((url) => { + if (url.includes("connection-string")) { + return jsonResponse({ + value: url.includes("affine-redis") ? REDIS_DSN : POSTGRES_DSN, + }); + } + if (url.includes("affine-redis")) { + return jsonResponse(REDIS_CLAIM); + } + if (url.includes("affine-postgresql")) { + return jsonResponse(POSTGRES_CLAIM); + } + return jsonResponse({}); + }); + let rendered: ReturnType | undefined; + + try { + await actAndDrain(() => { + rendered = render( + providerElement({ + kubeconfig: "kubeconfig-switch-test", + target: postgresTarget(), + }) + ); + }); + assert.ok(rendered, "initial render"); + const view = rendered; + + await actAndDrain(() => { + fireEvent.click(view.getByLabelText("Reveal Private Connection")); + }); + assert.match( + view.container.textContent ?? "", + PGSQL_HOST_PATTERN, + "the postgres DSN is revealed on its own pane" + ); + + await actAndDrain(() => { + view.rerender( + providerElement({ + kubeconfig: "kubeconfig-switch-test", + target: redisTarget(), + }) + ); + }); + assert.doesNotMatch( + view.container.textContent ?? "", + PGSQL_HOST_PATTERN, + "the postgres DSN must not render on the redis pane" + ); + + await actAndDrain(() => { + fireEvent.click(view.getByLabelText("Reveal Private Connection")); + }); + assert.match( + view.container.textContent ?? "", + REDIS_HOST_PATTERN, + "revealing on the redis pane resolves the redis DSN" + ); + } finally { + await actAndDrain(() => { + rendered?.unmount(); + }); + restoreGlobal(override); + } + await actAndDrain(() => undefined); + }); +}); + test("dbSettingsDataFromExactResource only accepts claims that match the target", () => { const target = postgresTarget(); const matching = dbSettingsDataFromExactResource(POSTGRES_CLAIM, target); @@ -179,6 +254,9 @@ test("DB settings provider ignores a fetched claim that belongs to another DB", "the settings sections stay in their loading/unavailable state" ); } finally { + await actAndDrain(() => { + rendered?.unmount(); + }); restoreGlobal(override); } await actAndDrain(() => undefined); @@ -256,6 +334,9 @@ test("DB settings provider revalidates a quickly revisited node inside SWR's ded "a quick revisit must not serve the stale cached claim" ); } finally { + await actAndDrain(() => { + rendered?.unmount(); + }); restoreGlobal(override); } await actAndDrain(() => undefined); diff --git a/apps/ui/src/features/resource-settings/db/db-settings-sections.tsx b/apps/ui/src/features/resource-settings/db/db-settings-sections.tsx index 7ac38991..fdfb8935 100644 --- a/apps/ui/src/features/resource-settings/db/db-settings-sections.tsx +++ b/apps/ui/src/features/resource-settings/db/db-settings-sections.tsx @@ -275,6 +275,7 @@ function DatabaseSettingsConnectionAddressRow({ publicConnectionEnabled, revealAvailable, revealedValue, + rowKeyScope, }: { connection: DatabaseNodeConnection; controlsDisabled: boolean; @@ -285,8 +286,10 @@ function DatabaseSettingsConnectionAddressRow({ publicConnectionEnabled: boolean; revealAvailable: boolean; revealedValue?: string; + /** Workload identity prefix so reveal state never crosses DB Services. */ + rowKeyScope: string; }) { - const rowKey = getDatabaseNodeConnectionKey(connection, index); + const rowKey = `${rowKeyScope}:${getDatabaseNodeConnectionKey(connection, index)}`; return ( {visibleConnections.map((connection, index) => { - const rowKey = getDatabaseNodeConnectionKey(connection, index); + const rowKey = `${rowKeyScope}:${getDatabaseNodeConnectionKey(connection, index)}`; return ( ); })} @@ -502,11 +508,18 @@ export function useDatabaseSettingsSections({ const workloadName = data.workload.name.trim(); const workloadNamespace = data.workload.namespace.trim(); const workload = data.workload; + const identityKey = `${workloadNamespace}/${workloadName}`; const { authReady: revealAvailable, resolveConnectionString } = useDbConnectionStringResolver({ kubeconfig: readOnly ? undefined : kubeconfig, }); - const { revealedRow, toggleRevealedRow } = useRevealedRow(); + const { clearRevealedRow, revealedRow, toggleRevealedRow } = useRevealedRow(); + // The pane retargets in place when the user switches DB nodes; a revealed + // connection DSN belongs to one DB Service and must not survive the switch. + // biome-ignore lint/correctness/useExhaustiveDependencies(identityKey): the effect re-runs precisely on DB Service switches to drop the revealed DSN. + useEffect(() => { + clearRevealedRow(); + }, [clearRevealedRow, identityKey]); const revealConnection = useCallback( (connection, rowKey) => { toggleRevealedRow(rowKey, () => @@ -530,7 +543,6 @@ export function useDatabaseSettingsSections({ const desiredMemoryLimit = desired?.memoryLimit; const desiredReplicas = desired?.replicas; const desiredStorageSize = desired?.storageSize; - const identityKey = `${workloadNamespace}/${workloadName}`; const submissionStore = useMemo( () => getBrowserSettingsSubmissionStore(), [] @@ -1050,6 +1062,7 @@ export function useDatabaseSettingsSections({ publicConnectionEnabled={draft.exposeNodePort} revealAvailable={revealAvailable} revealedRow={revealedRow} + rowKeyScope={identityKey} /> ), icon: Network, diff --git a/apps/ui/src/lib/use-revealed-row.ts b/apps/ui/src/lib/use-revealed-row.ts index 63bb7c03..ac9e1b6d 100644 --- a/apps/ui/src/lib/use-revealed-row.ts +++ b/apps/ui/src/lib/use-revealed-row.ts @@ -19,6 +19,8 @@ export interface RevealedRow { * silently, since nothing failed and there is nothing to show. */ export function useRevealedRow(): { + /** Hides (and discards) the revealed row immediately. */ + clearRevealedRow: () => void; revealedRow: RevealedRow | null; toggleRevealedRow: ( key: string, @@ -75,5 +77,5 @@ export function useRevealedRow(): { [clearHideTimeout, hideRevealedRow] ); - return { revealedRow, toggleRevealedRow }; + return { clearRevealedRow: hideRevealedRow, revealedRow, toggleRevealedRow }; }