mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 09:35:24 +00:00
* feat: let the login page send the token back to the mobile app * feat: add host-scripts entry to the plugin sdk * feat: let plugins say how they show in the mobile app * feat: frame host for running one plugin view outside the shell * fix: build the SDK host modules in production mode * chore: note the SDK host build fix in the changelog * feat: list plugin engine ranges for the mobile app * feat: host color and icon columns that sync * chore: update translations and plugin pins * fix: test * fix: show source address when rejecting proxy auth * fix: deleting your own account removes all of its data * fix(deps): update body-parser to 2.4.0 (#1736) * fix: body-parser 보안 패치 반영 * chore: 자동 검사를 막던 기존 JSON 서식 정리 --------- Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> * fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) * fix: honor shared credentials on shared hosts (#1730) * fix: honor shared credentials on shared hosts and in personal SSH auth * fix: snapshot shared SSH auth from a credential shared with the host owner * fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 * fix: unlock passphrase keys once per connect burst (#1737) * feat: give ssh transports the host origin and sync id (#1717) * fix: wake other clients after a sync push (Plugin-Sync#1) * feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) * feat: rail items can show a status dot (Plugin-Sync#5) * fix: add the desktop shared copy banner text (#1709) * feat: sidebar preference for alternating rows (#1712) * fix: striped rows text * feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) * fix: show the logo in the macos menu bar (#1728) * fix: number shortcuts skip the home tab (#1724) * fix: show plugins that could not be downloaded as failed (#1714) * fix: pass the host id as text when opening its editor * fix: retry a plugin bundle that was not ready yet (#1708) * fix: let commonjs plugins fall back to core packages on the desktop * fix: let non docker installs switch nginx to https (#1711) * feat: remove a folder share from its hosts too (#1719) * chore: changelog for merged pull requests * fix: accept the release .sig when installing a plugin from a file * feat: folder logins for shared hosts, read only plugin sync, full desktop builds * fix: test * fix: stop dev runner restart loops and narrow plugin watchers * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.6 --------- Co-authored-by: renovys <renovy@gmail.com> Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> Co-authored-by: Sebastian Cao <cycsmail@gmail.com> Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com> Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
514 lines
15 KiB
TypeScript
514 lines
15 KiB
TypeScript
import crypto from "crypto";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const ownerDEK = crypto.randomBytes(32);
|
|
const targetDEK = crypto.randomBytes(32);
|
|
|
|
const state = vi.hoisted(() => ({
|
|
hosts: new Map<number, Record<string, unknown>>(),
|
|
credentials: new Map<number, Record<string, unknown>>(),
|
|
secretRows: [] as Array<Record<string, unknown>>,
|
|
// hostAccessId -> hostId, used by findForHostUserProtocol
|
|
accessToHost: new Map<number, number>(),
|
|
grants: [] as Array<Record<string, unknown>>,
|
|
roleMembers: new Map<number, string[]>(),
|
|
// hostId -> that host's plugin protocol logins, decrypted
|
|
logins: new Map<number, Array<Record<string, unknown>>>(),
|
|
}));
|
|
|
|
vi.mock("../../hosts/usable-credential.js", () => ({
|
|
findUsableCredential: async (credentialId: number, userId: string) => {
|
|
const credential = state.credentials.get(credentialId);
|
|
if (!credential) return null;
|
|
const sharedWith = (credential.sharedWith as string[] | undefined) ?? [];
|
|
return credential.userId === userId || sharedWith.includes(userId)
|
|
? credential
|
|
: null;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentHostProtocolAuthRepository: () => ({
|
|
listForHost: async (hostId: number) => state.logins.get(hostId) ?? [],
|
|
}),
|
|
createCurrentHostResolutionRepository: () => ({
|
|
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
|
findHostOwnerId: async (hostId: number) =>
|
|
(state.hosts.get(hostId)?.userId as string) ?? null,
|
|
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
|
state.credentials.get(credentialId)?.userId === userId
|
|
? state.credentials.get(credentialId)
|
|
: null,
|
|
}),
|
|
createCurrentSharedHostSecretsRepository: () => ({
|
|
upsert: async (row: Record<string, unknown>) => {
|
|
const existing = state.secretRows.find(
|
|
(r) =>
|
|
r.hostAccessId === row.hostAccessId &&
|
|
r.targetUserId === row.targetUserId &&
|
|
r.protocol === row.protocol,
|
|
);
|
|
if (existing) Object.assign(existing, row);
|
|
else state.secretRows.push({ id: state.secretRows.length + 1, ...row });
|
|
},
|
|
deleteForHostAccessAndTarget: async (
|
|
hostAccessId: number,
|
|
targetUserId: string,
|
|
keepProtocols: string[],
|
|
) => {
|
|
state.secretRows = state.secretRows.filter(
|
|
(r) =>
|
|
!(
|
|
r.hostAccessId === hostAccessId &&
|
|
r.targetUserId === targetUserId &&
|
|
!keepProtocols.includes(r.protocol as string)
|
|
),
|
|
);
|
|
},
|
|
findForHostUserProtocol: async (
|
|
hostId: number,
|
|
targetUserId: string,
|
|
protocol: string,
|
|
) =>
|
|
state.secretRows.find(
|
|
(r) =>
|
|
state.accessToHost.get(r.hostAccessId as number) === hostId &&
|
|
r.targetUserId === targetUserId &&
|
|
r.protocol === protocol,
|
|
) ?? null,
|
|
deleteByHostAccessId: async () => 0,
|
|
deleteByTargetUserId: async () => 0,
|
|
deleteByOriginalCredentialId: async () => 0,
|
|
findHostIdsReferencingCredential: async (
|
|
_ownerId: string,
|
|
credentialId: number,
|
|
) =>
|
|
[...state.hosts.values()]
|
|
.filter((h) => h.credentialId === credentialId)
|
|
.map((h) => h.id as number),
|
|
}),
|
|
createCurrentRbacAccessRepository: () => ({
|
|
listActiveHostAccessGrants: async (hostId: number) =>
|
|
state.grants.filter((g) => g.hostId === hostId),
|
|
listRoleHostAccessCredentialSources: async (roleId: number) =>
|
|
state.grants
|
|
.filter((g) => g.roleId === roleId)
|
|
.map((g) => ({
|
|
hostAccessId: g.id,
|
|
hostId: g.hostId,
|
|
hostOwnerId: state.hosts.get(g.hostId as number)?.userId,
|
|
})),
|
|
}),
|
|
createCurrentRoleRepository: () => ({
|
|
listRoleUserIds: async (roleId: number) =>
|
|
state.roleMembers.get(roleId) ?? [],
|
|
listUserRoleIds: async () => [],
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../utils/data-crypto.js", () => ({
|
|
DataCrypto: {
|
|
validateUserAccess: (userId: string) => {
|
|
if (userId === "owner") return ownerDEK;
|
|
if (userId === "target" || userId === "member-1") return targetDEK;
|
|
throw new Error(`User ${userId} has no data encryption key`);
|
|
},
|
|
getUserDataKey: (userId: string) =>
|
|
userId === "owner"
|
|
? ownerDEK
|
|
: userId === "target" || userId === "member-1"
|
|
? targetDEK
|
|
: null,
|
|
canUserAccessData: () => true,
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../utils/logger.js", () => ({
|
|
databaseLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { FieldCrypto } from "../../utils/field-crypto.js";
|
|
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
|
|
|
|
const manager = SharedHostSecretsManager.getInstance();
|
|
|
|
function baseHost(overrides: Record<string, unknown> = {}) {
|
|
// Records come back from the resolution repository already decrypted.
|
|
return {
|
|
id: 42,
|
|
userId: "owner",
|
|
connectionType: "ssh",
|
|
name: "prod",
|
|
ip: "10.0.0.42",
|
|
username: "root",
|
|
authType: "password",
|
|
password: "hunter2",
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
credentialId: null,
|
|
shareSshAuth: false,
|
|
enableSsh: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** A plugin protocol login as the repository hands it back, decrypted. */
|
|
function login(protocol: string, overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
protocol,
|
|
authType: "direct",
|
|
credentialId: null,
|
|
username: null,
|
|
password: null,
|
|
fields: {},
|
|
secretFields: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
state.hosts.clear();
|
|
state.credentials.clear();
|
|
state.secretRows = [];
|
|
state.accessToHost = new Map([[7, 42]]);
|
|
state.grants = [];
|
|
state.roleMembers.clear();
|
|
state.logins.clear();
|
|
});
|
|
|
|
describe("SharedHostSecretsManager", () => {
|
|
it("keeps an inline-password SSH host private by default", async () => {
|
|
state.hosts.set(42, baseHost());
|
|
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
|
|
expect(state.secretRows).toHaveLength(0);
|
|
expect(await manager.getSecretForUser(42, "target", "ssh")).toBeNull();
|
|
});
|
|
|
|
it("snapshots inline SSH authentication when the owner opts in", async () => {
|
|
state.hosts.set(42, baseHost({ shareSshAuth: true }));
|
|
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
|
|
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
|
|
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
|
|
username: "root",
|
|
authType: "password",
|
|
password: "hunter2",
|
|
});
|
|
});
|
|
|
|
it("snapshots opted-in SSH credential auth alongside plugin protocol logins", async () => {
|
|
state.credentials.set(123, {
|
|
id: 123,
|
|
userId: "owner",
|
|
username: "cred-user",
|
|
authType: "key",
|
|
password: null,
|
|
privateKey: "PRIVATE-KEY",
|
|
key: null,
|
|
keyPassword: "kp",
|
|
keyType: "ssh-ed25519",
|
|
});
|
|
state.hosts.set(
|
|
42,
|
|
baseHost({
|
|
authType: "credential",
|
|
credentialId: 123,
|
|
shareSshAuth: true,
|
|
password: null,
|
|
}),
|
|
);
|
|
state.logins.set(42, [
|
|
login("spice", {
|
|
username: "spice-admin",
|
|
password: "spice-pass",
|
|
fields: { display: "2" },
|
|
secretFields: { ticket: "t-1" },
|
|
}),
|
|
login("x2go", { username: "x-user", password: "x-pass" }),
|
|
]);
|
|
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
|
|
expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([
|
|
"spice",
|
|
"ssh",
|
|
"x2go",
|
|
]);
|
|
|
|
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
|
|
username: "cred-user",
|
|
authType: "key",
|
|
key: "PRIVATE-KEY",
|
|
keyPassword: "kp",
|
|
keyType: "ssh-ed25519",
|
|
});
|
|
|
|
const spice = await manager.getSecretForUser(42, "target", "spice");
|
|
expect(spice).toMatchObject({
|
|
username: "spice-admin",
|
|
password: "spice-pass",
|
|
fields: { display: "2", ticket: "t-1" },
|
|
authType: "direct",
|
|
});
|
|
|
|
const x2go = await manager.getSecretForUser(42, "target", "x2go");
|
|
expect(x2go).toMatchObject({
|
|
username: "x-user",
|
|
password: "x-pass",
|
|
});
|
|
});
|
|
|
|
it("snapshots SSH auth from a credential shared with the host owner", async () => {
|
|
state.credentials.set(123, {
|
|
id: 123,
|
|
userId: "cred-owner",
|
|
sharedWith: ["owner"],
|
|
username: "cred-user",
|
|
authType: "password",
|
|
password: "cred-pass",
|
|
privateKey: null,
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
});
|
|
state.hosts.set(
|
|
42,
|
|
baseHost({
|
|
authType: "credential",
|
|
credentialId: 123,
|
|
shareSshAuth: true,
|
|
password: null,
|
|
}),
|
|
);
|
|
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
|
|
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
|
|
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
|
|
username: "cred-user",
|
|
authType: "password",
|
|
password: "cred-pass",
|
|
});
|
|
});
|
|
|
|
it("snapshots a credential-backed protocol login from the credential", async () => {
|
|
state.credentials.set(55, {
|
|
id: 55,
|
|
userId: "owner",
|
|
username: "cred-viewer",
|
|
password: "cred-pass",
|
|
});
|
|
state.hosts.set(42, baseHost());
|
|
state.logins.set(42, [
|
|
login("spice", {
|
|
authType: "credential",
|
|
credentialId: 55,
|
|
fields: { display: "1" },
|
|
}),
|
|
]);
|
|
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
|
|
expect(state.secretRows[0]).toMatchObject({
|
|
protocol: "spice",
|
|
sourceType: "credential",
|
|
originalCredentialId: 55,
|
|
});
|
|
expect(await manager.getSecretForUser(42, "target", "spice")).toMatchObject(
|
|
{
|
|
authType: "credential",
|
|
username: "cred-viewer",
|
|
password: "cred-pass",
|
|
fields: { display: "1" },
|
|
},
|
|
);
|
|
});
|
|
|
|
it("takes no snapshot of a login that asks at connect time", async () => {
|
|
state.hosts.set(42, baseHost());
|
|
state.logins.set(42, [login("spice", { authType: "none" })]);
|
|
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
|
|
expect(state.secretRows).toHaveLength(0);
|
|
});
|
|
|
|
it("reads a snapshot taken before 2.9.0 with its domain column", async () => {
|
|
const recordId = "shared-7-target-legacy";
|
|
state.secretRows.push({
|
|
id: 1,
|
|
hostAccessId: 7,
|
|
targetUserId: "target",
|
|
protocol: "legacy",
|
|
encryptedUsername: FieldCrypto.encryptField(
|
|
"admin",
|
|
targetDEK,
|
|
recordId,
|
|
"username",
|
|
),
|
|
encryptedAuthType: "direct",
|
|
encryptedDomain: FieldCrypto.encryptField(
|
|
"CORP",
|
|
targetDEK,
|
|
recordId,
|
|
"domain",
|
|
),
|
|
});
|
|
|
|
expect(
|
|
await manager.getSecretForUser(42, "target", "legacy"),
|
|
).toMatchObject({ username: "admin", fields: { domain: "CORP" } });
|
|
});
|
|
|
|
it("drops a protocol's snapshot once its login is gone", async () => {
|
|
state.hosts.set(42, baseHost());
|
|
state.logins.set(42, [login("spice", { username: "u", password: "p" })]);
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
expect(state.secretRows).toHaveLength(1);
|
|
|
|
state.logins.set(42, []);
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
expect(state.secretRows).toHaveLength(0);
|
|
});
|
|
|
|
it("produces no snapshot rows for secret-less auth types", async () => {
|
|
state.hosts.set(
|
|
42,
|
|
baseHost({
|
|
authType: "opkssh",
|
|
password: null,
|
|
shareSshAuth: true,
|
|
}),
|
|
);
|
|
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
expect(state.secretRows).toHaveLength(0);
|
|
});
|
|
|
|
it("removes the SSH snapshot when the owner disables sharing", async () => {
|
|
state.hosts.set(42, baseHost({ shareSshAuth: true }));
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
expect(state.secretRows).toHaveLength(1);
|
|
|
|
// Owner makes SSH authentication private again.
|
|
state.hosts.set(42, baseHost());
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
expect(state.secretRows).toHaveLength(0);
|
|
});
|
|
|
|
it("fails fast when a participant has no DEK", async () => {
|
|
state.hosts.set(42, baseHost());
|
|
await expect(
|
|
manager.snapshotForUser(7, 42, "locked-user", "owner"),
|
|
).rejects.toThrow(/no data encryption key/);
|
|
expect(state.secretRows).toHaveLength(0);
|
|
});
|
|
|
|
it("cannot be decrypted with the wrong DEK", async () => {
|
|
state.hosts.set(42, baseHost());
|
|
state.logins.set(42, [
|
|
login("spice", {
|
|
username: "spice-admin",
|
|
password: "spice-pass",
|
|
secretFields: { ticket: "t-1" },
|
|
}),
|
|
]);
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
|
|
const row = state.secretRows[0];
|
|
for (const [column, field] of [
|
|
["encryptedPassword", "password"],
|
|
["encryptedFields", "fields"],
|
|
]) {
|
|
expect(() =>
|
|
FieldCrypto.decryptField(
|
|
row[column] as string,
|
|
ownerDEK,
|
|
"shared-7-target-spice",
|
|
field,
|
|
),
|
|
).toThrow();
|
|
}
|
|
});
|
|
|
|
it("refuses a tampered snapshot rather than handing it out", async () => {
|
|
state.hosts.set(42, baseHost());
|
|
state.logins.set(42, [login("spice", { password: "spice-pass" })]);
|
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
|
|
|
const row = state.secretRows[0];
|
|
const envelope = JSON.parse(row.encryptedPassword as string);
|
|
envelope.data = envelope.data.replace(/^./, (c: string) =>
|
|
c === "0" ? "1" : "0",
|
|
);
|
|
row.encryptedPassword = JSON.stringify(envelope);
|
|
|
|
await expect(
|
|
manager.getSecretForUser(42, "target", "spice"),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("resyncHost re-snapshots direct grants and role members", async () => {
|
|
state.hosts.set(42, baseHost());
|
|
state.logins.set(42, [
|
|
login("spice", { username: "spice-admin", password: "spice-pass" }),
|
|
]);
|
|
state.accessToHost = new Map([
|
|
[1, 42],
|
|
[2, 42],
|
|
]);
|
|
state.grants = [
|
|
{ id: 1, hostId: 42, userId: "target", roleId: null },
|
|
{ id: 2, hostId: 42, userId: null, roleId: 9 },
|
|
];
|
|
state.roleMembers.set(9, ["member-1", "owner"]);
|
|
|
|
await manager.resyncHost(42);
|
|
|
|
// target via grant 1, member-1 via grant 2; owner skipped.
|
|
expect(
|
|
state.secretRows.map((row) => [row.hostAccessId, row.targetUserId]),
|
|
).toEqual([
|
|
[1, "target"],
|
|
[2, "member-1"],
|
|
]);
|
|
|
|
// Owner rotates the non-SSH password; resync updates those copies.
|
|
state.logins.set(42, [
|
|
login("spice", { username: "spice-admin", password: "rotated" }),
|
|
]);
|
|
await manager.resyncHost(42);
|
|
|
|
const secret = await manager.getSecretForUser(42, "target", "spice");
|
|
expect(secret?.password).toBe("rotated");
|
|
});
|
|
|
|
it("snapshotForRoleMember fans out from role grants", async () => {
|
|
state.hosts.set(42, baseHost());
|
|
state.logins.set(42, [
|
|
login("spice", { username: "spice-admin", password: "spice-pass" }),
|
|
]);
|
|
state.accessToHost = new Map([[2, 42]]);
|
|
state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }];
|
|
|
|
await manager.snapshotForRoleMember(9, "member-1");
|
|
|
|
expect(state.secretRows).toHaveLength(1);
|
|
expect(state.secretRows[0]).toMatchObject({
|
|
hostAccessId: 2,
|
|
targetUserId: "member-1",
|
|
protocol: "spice",
|
|
});
|
|
});
|
|
});
|