mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
198 lines
6.1 KiB
TypeScript
198 lines
6.1 KiB
TypeScript
/**
|
|
* ctx.notify and ctx.fetch: each is gated on its capability and audited,
|
|
* notify only ever reaches the acting user's own channels, and fetch goes
|
|
* through the SSRF guard with only the hosts the caller listed.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const grants = new Map<string, string[]>();
|
|
const auditEntries: Array<Record<string, unknown>> = [];
|
|
|
|
const state = vi.hoisted(() => ({
|
|
channels: new Map<
|
|
string,
|
|
Array<{
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
config: string;
|
|
enabled: number;
|
|
}>
|
|
>(),
|
|
delivered: [] as Array<{ channelId: number; title: string }>,
|
|
failChannel: null as number | null,
|
|
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
|
lastSignal: null as AbortSignal | null,
|
|
}));
|
|
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentPluginPermissionGrantRepository: () => ({
|
|
listByPlugin: async (pluginId: string) =>
|
|
(grants.get(pluginId) ?? []).map((capability) => ({
|
|
pluginId,
|
|
capability,
|
|
})),
|
|
}),
|
|
createCurrentNotificationChannelRepository: () => ({
|
|
listNotificationChannels: async (userId: string) =>
|
|
state.channels.get(userId) ?? [],
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../utils/audit-logger.js", () => ({
|
|
logAudit: async (entry: Record<string, unknown>) => {
|
|
auditEntries.push(entry);
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../utils/notification-sender.js", () => ({
|
|
deliverNotification: async (
|
|
channel: { id: number },
|
|
notification: { title: string },
|
|
) => {
|
|
if (channel.id === state.failChannel) throw new Error("HTTP 500");
|
|
state.delivered.push({ channelId: channel.id, title: notification.title });
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
|
safeOutboundFetch: async (
|
|
url: string,
|
|
init: RequestInit,
|
|
allowlist: readonly string[],
|
|
) => {
|
|
state.lastSignal = init.signal ?? null;
|
|
state.fetchCalls.push({ url, allowlist });
|
|
return new Response("ok", { status: 200 });
|
|
},
|
|
}));
|
|
|
|
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
|
import { runAsActor } from "../../plugins/actor.js";
|
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
|
|
|
function contextFor(capabilities: string[]) {
|
|
grants.set("demo", capabilities);
|
|
const manifest = {
|
|
id: "demo",
|
|
name: "demo",
|
|
version: "1.0.0",
|
|
description: "",
|
|
author: { name: "test" },
|
|
license: "MIT",
|
|
category: "Productivity",
|
|
engine: { termix: ">=2.9.0", api: "1" },
|
|
capabilities,
|
|
} as PluginManifest;
|
|
const handle = createPluginHandle("demo", { activate: () => {} });
|
|
return createPluginContext(manifest, handle);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
grants.clear();
|
|
auditEntries.length = 0;
|
|
invalidatePluginPermissionCache();
|
|
state.channels.clear();
|
|
state.delivered.length = 0;
|
|
state.failChannel = null;
|
|
state.fetchCalls.length = 0;
|
|
state.channels.set("alice", [
|
|
{ id: 1, name: "ops", type: "webhook", config: "{}", enabled: 1 },
|
|
{ id: 2, name: "muted", type: "ntfy", config: "{}", enabled: 0 },
|
|
{ id: 3, name: "pager", type: "discord", config: "{}", enabled: 1 },
|
|
]);
|
|
state.channels.set("bob", [
|
|
{ id: 9, name: "bob's", type: "webhook", config: "{}", enabled: 1 },
|
|
]);
|
|
});
|
|
|
|
describe("ctx.notify", () => {
|
|
it("refuses without notify:send and audits the refusal", async () => {
|
|
const ctx = contextFor([]);
|
|
await expect(
|
|
runAsActor("alice", "request", () => ctx.notify.channels()),
|
|
).rejects.toThrow(/notify:send/);
|
|
expect(auditEntries.at(-1)).toMatchObject({
|
|
action: "plugin_notify_channels",
|
|
success: false,
|
|
});
|
|
});
|
|
|
|
it("refuses a call with no acting user", async () => {
|
|
const ctx = contextFor(["notify:send"]);
|
|
await expect(
|
|
ctx.notify.send([1], { title: "t", body: "b" }),
|
|
).rejects.toThrow(/acting user/);
|
|
});
|
|
|
|
it("lists the actor's channels without their config", async () => {
|
|
const ctx = contextFor(["notify:send"]);
|
|
const channels = await runAsActor("alice", "request", () =>
|
|
ctx.notify.channels(),
|
|
);
|
|
expect(channels).toEqual([
|
|
{ id: 1, name: "ops", type: "webhook", enabled: true },
|
|
{ id: 2, name: "muted", type: "ntfy", enabled: false },
|
|
{ id: 3, name: "pager", type: "discord", enabled: true },
|
|
]);
|
|
});
|
|
|
|
it("sends only to the actor's enabled channels and reports failures", async () => {
|
|
const ctx = contextFor(["notify:send"]);
|
|
state.failChannel = 3;
|
|
|
|
const result = await runAsActor("alice", "request", () =>
|
|
ctx.notify.send([1, 2, 3, 9], { title: "Disk", body: "full" }),
|
|
);
|
|
|
|
expect(state.delivered).toEqual([{ channelId: 1, title: "Disk" }]);
|
|
expect(result).toEqual({
|
|
delivered: 1,
|
|
failures: [{ channelId: 3, name: "pager", error: "HTTP 500" }],
|
|
});
|
|
expect(auditEntries.at(-1)).toMatchObject({
|
|
action: "plugin_notify_send",
|
|
success: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ctx.fetch", () => {
|
|
it("refuses without network:outbound", async () => {
|
|
const ctx = contextFor([]);
|
|
await expect(ctx.fetch("https://example.com")).rejects.toThrow(
|
|
/network:outbound/,
|
|
);
|
|
expect(auditEntries.at(-1)).toMatchObject({
|
|
action: "plugin_fetch",
|
|
success: false,
|
|
});
|
|
});
|
|
|
|
it("goes through the SSRF guard with the listed private hosts only", async () => {
|
|
const ctx = contextFor(["network:outbound"]);
|
|
const response = await ctx.fetch("https://ntfy.lan/topic", {
|
|
method: "POST",
|
|
body: "hi",
|
|
allowPrivateHosts: ["NTFY.LAN"],
|
|
});
|
|
expect(await response.text()).toBe("ok");
|
|
expect(state.fetchCalls).toEqual([
|
|
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
|
|
]);
|
|
});
|
|
|
|
it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
|
|
const ctx = contextFor(["network:outbound"]);
|
|
const controller = new AbortController();
|
|
await ctx.fetch("https://example.com/stream", {
|
|
signal: controller.signal,
|
|
});
|
|
expect(state.lastSignal?.aborted).toBe(false);
|
|
controller.abort();
|
|
expect(state.lastSignal?.aborted).toBe(true);
|
|
});
|
|
});
|