diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index f1cb4121f..ec62538cf 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -96,6 +96,34 @@ function describeHost(host: number | PluginSshHost): string { return typeof host === "number" ? `host ${host}` : `host ${host.id}`; } +const OVERRIDE_FIELDS = [ + "password", + "key", + "privateKey", + "keyPassword", + "passphrase", +] as const; + +/** + * A secret the plugin set on its copy (a password the user just typed) wins + * over the stored one; everything else comes from what core resolved. + */ +function withOverrides( + resolved: SshConnectHost, + given: PluginSshHost, +): SshConnectHost { + if (given === (resolved as unknown)) return resolved; + const source = given as unknown as Record; + const set = OVERRIDE_FIELDS.filter( + (field) => typeof source[field] === "string" && source[field] !== "", + ); + if (set.length === 0) return resolved; + const merged = { ...resolved } as unknown as Record; + for (const field of set) merged[field] = source[field]; + if (typeof source.authType === "string") merged.authType = source.authType; + return merged as unknown as SshConnectHost; +} + const LOGIN_FAILED = /all configured authentication methods failed|permission denied|authentication failed/i; @@ -134,8 +162,37 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { { host: SshConnectHost; userId: string } >(); - const remember = (key: object, host: SshConnectHost, userId: string) => - resolvedHosts.set(key, { host, userId }); + // Plugins often copy a host before connecting ({ ...host, port }). Spread + // copies enumerable symbol keys, so the copy keeps this tag. Its value is an + // opaque token, not the host, so the secrets stay out of the plugin's reach. + const tag = Symbol("resolvedHost"); + const tokens = new WeakMap< + object, + { host: SshConnectHost; userId: string } + >(); + + const remember = (key: object, host: SshConnectHost, userId: string) => { + const entry = { host, userId }; + resolvedHosts.set(key, entry); + const token = Object.freeze({}); + tokens.set(token, entry); + if (Object.isExtensible(key)) { + Object.defineProperty(key, tag, { + value: token, + enumerable: true, + configurable: true, + }); + } + }; + + const lookup = (host: object) => { + const known = resolvedHosts.get(host); + if (known) return known; + const token = (host as Record)[tag]; + return token && typeof token === "object" + ? tokens.get(token as object) + : undefined; + }; const handOut = (host: SshConnectHost, userId: string): PluginSshHost => { const redacted = redactHostSecrets( @@ -148,9 +205,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { /** The acting user for a call about this host. */ const userFor = (host: number | PluginSshHost | undefined): string => actingUser( - host && typeof host === "object" - ? resolvedHosts.get(host)?.userId - : undefined, + host && typeof host === "object" ? lookup(host)?.userId : undefined, ); /** @@ -163,8 +218,8 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { userId: string, ): number | SshConnectHost => { if (typeof host === "number") return host; - const known = resolvedHosts.get(host); - if (known) return known.host; + const known = lookup(host); + if (known) return withOverrides(known.host, host); return { ...(host as unknown as SshConnectHost), userId }; }; @@ -173,8 +228,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { * auth or host key failure counts: a timeout says nothing about the login. */ const reportLogin = (host: number | PluginSshHost, error?: unknown) => { - const hostId = - typeof host === "number" ? host : resolvedHosts.get(host)?.host.id; + const hostId = typeof host === "number" ? host : lookup(host)?.host.id; if (!Number.isInteger(hostId) || hostId <= 0) return; let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true }; if (error !== undefined) { diff --git a/src/backend/tests/plugins/ctx-ssh-auth.test.ts b/src/backend/tests/plugins/ctx-ssh-auth.test.ts index ff10f1cba..274589ea4 100644 --- a/src/backend/tests/plugins/ctx-ssh-auth.test.ts +++ b/src/backend/tests/plugins/ctx-ssh-auth.test.ts @@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({ return { client, jumpClient: null, dispose: vi.fn(() => client.end()) }; }, })); +vi.mock("../../hosts/host-resolver.js", () => ({ + resolveHostById: async (hostId: number, userId: string) => ({ + id: hostId, + userId, + ip: "10.0.0.5", + port: 22, + username: "root", + authType: "password", + password: "stored-secret", + }), + resolveHostBySyncId: async () => null, +})); vi.mock("../../hosts/status/host-status-service.js", () => ({ hostStatusService: { reportLogin: (hostId: number, outcome: unknown) => @@ -253,6 +265,50 @@ describe("ctx.ssh", () => { expect(h.connects[0].target).toMatchObject({ userId: "caller" }); }); + it("connects a copied redacted host with its stored login", async () => { + h.granted = new Set(["ssh:connect", "credentials:use"]); + const ssh = createPluginSsh({ + manifest: manifest(["ssh:connect", "credentials:use"]), + bag: new DisposableBag("fixture"), + audit: vi.fn(async () => {}), + }); + const host = (await ssh.resolveHost(5))!; + expect((host as Record).password).toBeUndefined(); + expect(JSON.stringify(host)).not.toContain("stored-secret"); + for (const symbol of Object.getOwnPropertySymbols(host)) { + expect(JSON.stringify((host as never)[symbol])).not.toContain( + "stored-secret", + ); + } + + await ssh.connect({ ...host, port: host.port || 22 }); + expect(h.connects[0].target).toMatchObject({ password: "stored-secret" }); + + await ssh.connect({ ...host, password: "typed", authType: "password" }); + expect(h.connects[1].target).toMatchObject({ password: "typed" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]); + }); + + it("does not honor another plugin's resolved host", async () => { + h.granted = new Set(["ssh:connect", "credentials:use"]); + const deps = { + manifest: manifest(["ssh:connect", "credentials:use"]), + audit: vi.fn(async () => {}), + }; + const first = createPluginSsh({ + ...deps, + bag: new DisposableBag("fixture"), + }); + const second = createPluginSsh({ + ...deps, + bag: new DisposableBag("fixture"), + }); + const host = (await first.resolveHost(5))!; + await second.connect({ ...host }); + expect(h.connects[0].target).not.toHaveProperty("password"); + }); + it("passes a given stream through to the pipeline, gated like any connect", async () => { const stream = { throughSource: true }; const ssh = createPluginSsh({