mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
fix: plugins losing the saved ssh login when copying a host (#1391)
This commit is contained in:
1 parent
d433718c35
commit
6f442c5863
2 files changed
+119
-9
No files matched your search
@@ -96,6 +96,34 @@ function describeHost(host: number | PluginSshHost): string {
|
|||||||
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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 =
|
const LOGIN_FAILED =
|
||||||
/all configured authentication methods failed|permission denied|authentication failed/i;
|
/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 }
|
{ host: SshConnectHost; userId: string }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const remember = (key: object, host: SshConnectHost, userId: string) =>
|
// Plugins often copy a host before connecting ({ ...host, port }). Spread
|
||||||
resolvedHosts.set(key, { host, userId });
|
// 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<symbol, unknown>)[tag];
|
||||||
|
return token && typeof token === "object"
|
||||||
|
? tokens.get(token as object)
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
|
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
|
||||||
const redacted = redactHostSecrets(
|
const redacted = redactHostSecrets(
|
||||||
@@ -148,9 +205,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
|||||||
/** The acting user for a call about this host. */
|
/** The acting user for a call about this host. */
|
||||||
const userFor = (host: number | PluginSshHost | undefined): string =>
|
const userFor = (host: number | PluginSshHost | undefined): string =>
|
||||||
actingUser(
|
actingUser(
|
||||||
host && typeof host === "object"
|
host && typeof host === "object" ? lookup(host)?.userId : undefined,
|
||||||
? resolvedHosts.get(host)?.userId
|
|
||||||
: undefined,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,8 +218,8 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): number | SshConnectHost => {
|
): number | SshConnectHost => {
|
||||||
if (typeof host === "number") return host;
|
if (typeof host === "number") return host;
|
||||||
const known = resolvedHosts.get(host);
|
const known = lookup(host);
|
||||||
if (known) return known.host;
|
if (known) return withOverrides(known.host, host);
|
||||||
return { ...(host as unknown as SshConnectHost), userId };
|
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.
|
* auth or host key failure counts: a timeout says nothing about the login.
|
||||||
*/
|
*/
|
||||||
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
|
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
|
||||||
const hostId =
|
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
|
||||||
typeof host === "number" ? host : resolvedHosts.get(host)?.host.id;
|
|
||||||
if (!Number.isInteger(hostId) || hostId <= 0) return;
|
if (!Number.isInteger(hostId) || hostId <= 0) return;
|
||||||
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
|
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
|
||||||
if (error !== undefined) {
|
if (error !== undefined) {
|
||||||
|
|||||||
@@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({
|
|||||||
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
|
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", () => ({
|
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||||
hostStatusService: {
|
hostStatusService: {
|
||||||
reportLogin: (hostId: number, outcome: unknown) =>
|
reportLogin: (hostId: number, outcome: unknown) =>
|
||||||
@@ -253,6 +265,50 @@ describe("ctx.ssh", () => {
|
|||||||
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
|
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<string, unknown>).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 () => {
|
it("passes a given stream through to the pipeline, gated like any connect", async () => {
|
||||||
const stream = { throughSource: true };
|
const stream = { throughSource: true };
|
||||||
const ssh = createPluginSsh({
|
const ssh = createPluginSsh({
|
||||||
|
|||||||
Reference in new issue
Block a user