mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
fix: don't auto-enable synced plugins signed only by a server-sent key (#1623)
* fix: don't auto-enable synced plugins signed only by a server-sent key Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust. * fix: enforce local plugin trust across remote source labels * fix: require positive local trust for synced plugin keys
This commit is contained in:
1 parent
e610aba0e5
commit
7d13b2a9fe
4 files changed
+168
-21
No files matched your search
@@ -19,6 +19,9 @@ import {
|
|||||||
type RegistrySource,
|
type RegistrySource,
|
||||||
} from "./registry-index.js";
|
} from "./registry-index.js";
|
||||||
import {
|
import {
|
||||||
|
COMMUNITY_PLUGIN_KEYS,
|
||||||
|
TRUSTED_PLUGIN_KEYS,
|
||||||
|
getCustomPluginKeys,
|
||||||
isPluginPublicKey,
|
isPluginPublicKey,
|
||||||
pluginKeyId,
|
pluginKeyId,
|
||||||
setCustomPluginKeys,
|
setCustomPluginKeys,
|
||||||
@@ -28,6 +31,8 @@ import {
|
|||||||
const CONFIG_KEY = "plugin_registries";
|
const CONFIG_KEY = "plugin_registries";
|
||||||
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
||||||
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
||||||
|
/** `addedIn` of a key only the linked server vouches for. */
|
||||||
|
const SYNCED = "synced";
|
||||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
const MAX_ID_LENGTH = 40;
|
const MAX_ID_LENGTH = 40;
|
||||||
const MAX_NAME_LENGTH = 80;
|
const MAX_NAME_LENGTH = 80;
|
||||||
@@ -117,8 +122,8 @@ async function writeRegistriesConfig(config: RegistriesConfig): Promise<void> {
|
|||||||
await applyCustomKeys(config);
|
await applyCustomKeys(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toKey(publicKey: string): TrustedPluginKey {
|
function toKey(publicKey: string, addedIn = "admin"): TrustedPluginKey {
|
||||||
return { id: pluginKeyId(publicKey), publicKey, addedIn: "admin" };
|
return { id: pluginKeyId(publicKey), publicKey, addedIn };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readSyncedKeys(): Promise<string[]> {
|
async function readSyncedKeys(): Promise<string[]> {
|
||||||
@@ -134,19 +139,32 @@ async function readSyncedKeys(): Promise<string[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trusts the key of every custom registry, on or off: a plugin installed
|
* Trusts the key of every custom registry, on or off: a plugin installed
|
||||||
* from a registry that was later turned off still has to load.
|
* from a registry that was later turned off still has to load. A key added
|
||||||
|
* here wins over the same key synced from a server.
|
||||||
*/
|
*/
|
||||||
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
||||||
const keys = new Map<string, TrustedPluginKey>();
|
const keys = new Map<string, TrustedPluginKey>();
|
||||||
|
for (const publicKey of await readSyncedKeys()) {
|
||||||
|
keys.set(pluginKeyId(publicKey), toKey(publicKey, SYNCED));
|
||||||
|
}
|
||||||
for (const entry of config.custom) {
|
for (const entry of config.custom) {
|
||||||
keys.set(entry.keyId, toKey(entry.publicKey));
|
keys.set(entry.keyId, toKey(entry.publicKey));
|
||||||
}
|
}
|
||||||
for (const publicKey of await readSyncedKeys()) {
|
|
||||||
keys.set(pluginKeyId(publicKey), toKey(publicKey));
|
|
||||||
}
|
|
||||||
setCustomPluginKeys([...keys.values()]);
|
setCustomPluginKeys([...keys.values()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this app trusts the key independently of its linked server. */
|
||||||
|
export function isLocallyTrustedPluginKey(keyId: string): boolean {
|
||||||
|
return (
|
||||||
|
[...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS].some(
|
||||||
|
(key) => key.id === keyId,
|
||||||
|
) ||
|
||||||
|
getCustomPluginKeys().some(
|
||||||
|
(key) => key.id === keyId && key.addedIn !== SYNCED,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Called before the boot scan so plugins from custom registries load. */
|
/** Called before the boot scan so plugins from custom registries load. */
|
||||||
export async function loadCustomRegistryKeys(): Promise<void> {
|
export async function loadCustomRegistryKeys(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
unloadPlugin,
|
unloadPlugin,
|
||||||
} from "../../plugins/index.js";
|
} from "../../plugins/index.js";
|
||||||
import { getPluginsDir } from "../../plugins/paths.js";
|
import { getPluginsDir } from "../../plugins/paths.js";
|
||||||
import { setSyncedRegistryKeys } from "../../plugins/registries.js";
|
import {
|
||||||
|
isLocallyTrustedPluginKey,
|
||||||
|
setSyncedRegistryKeys,
|
||||||
|
} from "../../plugins/registries.js";
|
||||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||||
import {
|
import {
|
||||||
createCurrentPluginPermissionGrantRepository,
|
createCurrentPluginPermissionGrantRepository,
|
||||||
@@ -57,6 +60,14 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
|
|||||||
return record?.state === "enabled";
|
return record?.state === "enabled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed by a key this desktop trusts on its own. A key the server sent
|
||||||
|
* would let the server's admin sign anything, so it does not count.
|
||||||
|
*/
|
||||||
|
function vouchedHere(local: LoadedPlugin): boolean {
|
||||||
|
return !!local.signedBy && isLocallyTrustedPluginKey(local.signedBy);
|
||||||
|
}
|
||||||
|
|
||||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
|
||||||
/** Downloads next to the installed copy, which stays until the swap. */
|
/** Downloads next to the installed copy, which stays until the swap. */
|
||||||
@@ -155,7 +166,7 @@ async function matchGrants(plugin: RemotePlugin): Promise<void> {
|
|||||||
if (!local || local.source !== "user") return;
|
if (!local || local.source !== "user") return;
|
||||||
// Grants are only copied for code a trusted key signed. Anything else the
|
// Grants are only copied for code a trusted key signed. Anything else the
|
||||||
// desktop's own admin has to review, as with any plugin dropped on disk.
|
// desktop's own admin has to review, as with any plugin dropped on disk.
|
||||||
if (!local.signedBy) return;
|
if (!vouchedHere(local)) return;
|
||||||
const declared = new Set(local.manifest.capabilities);
|
const declared = new Set(local.manifest.capabilities);
|
||||||
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
||||||
const existing = await repository.listByPlugin(plugin.id);
|
const existing = await repository.listByPlugin(plugin.id);
|
||||||
@@ -196,7 +207,7 @@ async function alertUnsigned(plugin: RemotePlugin): Promise<void> {
|
|||||||
alertedVersions.add(key);
|
alertedVersions.add(key);
|
||||||
await sendCoreAlert({
|
await sendCoreAlert({
|
||||||
title: "Review a feature from your server",
|
title: "Review a feature from your server",
|
||||||
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed, so it stays off here until you turn it on.`,
|
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed by a key this app trusts, so it stays off here until you turn it on.`,
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
category: "termix.sync.unsigned_plugin",
|
category: "termix.sync.unsigned_plugin",
|
||||||
dedupeKey: `sync:${key}`,
|
dedupeKey: `sync:${key}`,
|
||||||
@@ -239,7 +250,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
await alertOlderDesktop(plugin);
|
await alertOlderDesktop(plugin);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (wasEnabled) await setPluginEnabled(plugin.id, true);
|
if (wasEnabled && local.source === "bundled") {
|
||||||
|
await setPluginEnabled(plugin.id, true);
|
||||||
|
}
|
||||||
} else if (newer) {
|
} else if (newer) {
|
||||||
await alertOlderDesktop(plugin);
|
await alertOlderDesktop(plugin);
|
||||||
}
|
}
|
||||||
@@ -263,19 +276,19 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await matchGrants(plugin);
|
await matchGrants(plugin);
|
||||||
// Unsigned code is only ever switched off from here, never on.
|
|
||||||
if (!local.signedBy) {
|
|
||||||
const enabledHere = await isEnabledLocally(plugin.id);
|
|
||||||
if (!plugin.enabled && enabledHere) {
|
|
||||||
await setPluginEnabled(plugin.id, false);
|
|
||||||
} else if (plugin.enabled && !enabledHere) {
|
|
||||||
await alertUnsigned(plugin);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!local) return;
|
if (!local) return;
|
||||||
|
// Remote metadata cannot confer bundled trust on a locally loaded user plugin.
|
||||||
|
if (local.source === "user" && !vouchedHere(local)) {
|
||||||
|
const enabledHere = await isEnabledLocally(plugin.id);
|
||||||
|
if (!plugin.enabled && enabledHere) {
|
||||||
|
await setPluginEnabled(plugin.id, false);
|
||||||
|
} else if (plugin.enabled && !enabledHere) {
|
||||||
|
await alertUnsigned(plugin);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
|
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
|
||||||
await setPluginEnabled(plugin.id, plugin.enabled);
|
await setPluginEnabled(plugin.id, plugin.enabled);
|
||||||
} else if (plugin.enabled && local.state !== "active") {
|
} else if (plugin.enabled && local.state !== "active") {
|
||||||
|
|||||||
@@ -256,4 +256,25 @@ describe("keys from a linked server", () => {
|
|||||||
await registries.setSyncedRegistryKeys([]);
|
await registries.setSyncedRegistryKeys([]);
|
||||||
expect(getCustomPluginKeys()).toEqual([]);
|
expect(getCustomPluginKeys()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks them as synced unless an admin added the same key here", async () => {
|
||||||
|
const synced = newKey();
|
||||||
|
const shared = newKey();
|
||||||
|
serve(URL_A, { registry: "acme", publicKey: shared });
|
||||||
|
await registries.addRegistry(URL_A, pluginKeyId(shared));
|
||||||
|
await registries.setSyncedRegistryKeys([synced, shared]);
|
||||||
|
|
||||||
|
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(shared))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(registries.isLocallyTrustedPluginKey("ec40e29bcc4986b2")).toBe(true);
|
||||||
|
await registries.setSyncedRegistryKeys([]);
|
||||||
|
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(registries.isLocallyTrustedPluginKey("unknown-key")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,8 @@ const state = vi.hoisted(() => ({
|
|||||||
fetch: vi.fn(),
|
fetch: vi.fn(),
|
||||||
loadBundled: vi.fn(),
|
loadBundled: vi.fn(),
|
||||||
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
|
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
|
||||||
|
syncedOnlyKeys: new Set<string>(),
|
||||||
|
grants: [] as string[],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../utils/logger.js", () => ({
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
@@ -28,10 +30,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
state: state.enabled.has(id) ? "enabled" : "disabled",
|
state: state.enabled.has(id) ? "enabled" : "disabled",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
createCurrentPluginPermissionGrantRepository: () => ({}),
|
createCurrentPluginPermissionGrantRepository: () => ({
|
||||||
|
listByPlugin: async () => [],
|
||||||
|
grant: async ({ capability }: { capability: string }) =>
|
||||||
|
void state.grants.push(capability),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../plugins/registries.js", () => ({
|
vi.mock("../../../plugins/registries.js", () => ({
|
||||||
setSyncedRegistryKeys: state.syncedKeys,
|
setSyncedRegistryKeys: state.syncedKeys,
|
||||||
|
isLocallyTrustedPluginKey: (keyId: string) =>
|
||||||
|
keyId === "local-key" && !state.syncedOnlyKeys.has(keyId),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../sync/client/http.js", () => ({
|
vi.mock("../../../sync/client/http.js", () => ({
|
||||||
remoteFetch: state.fetch,
|
remoteFetch: state.fetch,
|
||||||
@@ -58,6 +66,7 @@ vi.mock("../../../plugins/index.js", () => ({
|
|||||||
|
|
||||||
const { mirrorPlugins } = await import("../../../sync/client/plugins.js");
|
const { mirrorPlugins } = await import("../../../sync/client/plugins.js");
|
||||||
const { remoteJson } = await import("../../../sync/client/http.js");
|
const { remoteJson } = await import("../../../sync/client/http.js");
|
||||||
|
const { sendCoreAlert } = await import("../../../notify/core-notify.js");
|
||||||
|
|
||||||
const bundled = {
|
const bundled = {
|
||||||
id: "ssh-terminal",
|
id: "ssh-terminal",
|
||||||
@@ -92,6 +101,9 @@ beforeEach(() => {
|
|||||||
state.install.mockReset();
|
state.install.mockReset();
|
||||||
state.fetch.mockReset();
|
state.fetch.mockReset();
|
||||||
state.loadBundled.mockReset();
|
state.loadBundled.mockReset();
|
||||||
|
state.syncedOnlyKeys = new Set();
|
||||||
|
state.grants = [];
|
||||||
|
vi.mocked(sendCoreAlert).mockClear();
|
||||||
state.loadBundled.mockImplementation(async (id: string) => {
|
state.loadBundled.mockImplementation(async (id: string) => {
|
||||||
const plugin = { ...bundled, id };
|
const plugin = { ...bundled, id };
|
||||||
state.loaded.set(id, plugin);
|
state.loaded.set(id, plugin);
|
||||||
@@ -171,4 +183,87 @@ describe("mirrorPlugins", () => {
|
|||||||
expect(state.loaded.has("ssh-terminal")).toBe(true);
|
expect(state.loaded.has("ssh-terminal")).toBe(true);
|
||||||
expect(state.enabled.has("ssh-terminal")).toBe(true);
|
expect(state.enabled.has("ssh-terminal")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a user plugin the server has on", () => {
|
||||||
|
function serverRunsSignedBy(keyId: string) {
|
||||||
|
const version = keyId === "removed-key" ? "1.0.1" : "1.0.0";
|
||||||
|
state.loaded.set("extra", {
|
||||||
|
id: "extra",
|
||||||
|
source: "user",
|
||||||
|
state: "loaded",
|
||||||
|
signedBy: keyId,
|
||||||
|
manifest: { id: "extra", version, capabilities: ["net"] },
|
||||||
|
});
|
||||||
|
vi.mocked(remoteJson).mockResolvedValue({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "extra",
|
||||||
|
name: "Extra",
|
||||||
|
version,
|
||||||
|
source: "user",
|
||||||
|
artifact: true,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
desktop: "mirror",
|
||||||
|
syncEntities: [],
|
||||||
|
granted: ["net"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("turns it on when a key this desktop trusts signed it", async () => {
|
||||||
|
serverRunsSignedBy("local-key");
|
||||||
|
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
|
||||||
|
expect(state.enabled.has("extra")).toBe(true);
|
||||||
|
expect(state.grants).toEqual(["net"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["server-key", "removed-key"])(
|
||||||
|
"leaves it off for untrusted key %s",
|
||||||
|
async (keyId) => {
|
||||||
|
serverRunsSignedBy(keyId);
|
||||||
|
state.syncedOnlyKeys.add("server-key");
|
||||||
|
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
|
||||||
|
expect(state.enabled.has("extra")).toBe(false);
|
||||||
|
expect(state.grants).toEqual([]);
|
||||||
|
expect(sendCoreAlert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ category: "termix.sync.unsigned_plugin" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("server bundled label must not enable a synced-only user plugin", async () => {
|
||||||
|
state.syncedOnlyKeys.add("server-key");
|
||||||
|
state.loaded.set("extra", {
|
||||||
|
id: "extra",
|
||||||
|
source: "user",
|
||||||
|
state: "loaded",
|
||||||
|
signedBy: "server-key",
|
||||||
|
manifest: { id: "extra", version: "1.0.0", capabilities: [] },
|
||||||
|
});
|
||||||
|
vi.mocked(remoteJson).mockResolvedValue({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "extra",
|
||||||
|
name: "Extra",
|
||||||
|
version: "1.0.0",
|
||||||
|
source: "bundled",
|
||||||
|
artifact: true,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
desktop: "mirror",
|
||||||
|
syncEntities: [],
|
||||||
|
granted: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
expect(state.enabled.has("extra")).toBe(false);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user