343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import path from "node:path";
|
|
import type { PluginManifest } from "@toolctrl/plugin-runtime";
|
|
import { publishAuditEventViaOutbox } from "@toolctrl/db/audit-events.server";
|
|
import { db } from "~/utils/db.server";
|
|
import {
|
|
PLUGIN_SETTING_VALUE_MAX_BYTES,
|
|
isPluginSettingKeyFormatSafe,
|
|
isPluginSettingKeyReserved,
|
|
isPluginSettingKeySecretLike,
|
|
isPluginSettingValue,
|
|
isPluginSettingValueShapeSafe,
|
|
isPluginSettingValueWithinSize,
|
|
isSafePluginSettingKey,
|
|
type PluginSettingValue,
|
|
} from "../../utils/pluginSettingsValidation";
|
|
import { getPluginForTenant, type PluginLifecycleActor, type PluginListItem } from "./PluginLifecycleService";
|
|
export type { PluginSettingValue } from "../../utils/pluginSettingsValidation";
|
|
|
|
const DEFAULT_PLUGIN_BINARY_BASE_PATH = "/app/var/plugins";
|
|
const RELATIVE_HTML_ENTRY_PATTERN = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?![a-z][a-z0-9+.-]*:)[a-z0-9][a-z0-9._/-]*\.html$/iu;
|
|
const MAX_PLUGIN_USER_CONFIG_BYTES = 64 * 1024;
|
|
const MAX_PLUGIN_SETTINGS_HTML_BYTES = 256 * 1024;
|
|
const MAX_PLUGIN_SETTING_VALUE_BYTES = PLUGIN_SETTING_VALUE_MAX_BYTES;
|
|
|
|
export type PluginSettingsServiceErrorCode =
|
|
| "plugin_not_found"
|
|
| "plugin_disabled"
|
|
| "settings_ui_unavailable"
|
|
| "settings_ui_entry_unsafe"
|
|
| "settings_ui_file_not_found"
|
|
| "settings_ui_file_too_large"
|
|
| "settings_ui_integrity_missing"
|
|
| "settings_ui_integrity_mismatch"
|
|
| "settings_key_invalid"
|
|
| "settings_key_secret_like"
|
|
| "settings_value_invalid"
|
|
| "settings_value_too_large";
|
|
|
|
export class PluginSettingsServiceError extends Error {
|
|
public override readonly name = "PluginSettingsServiceError";
|
|
|
|
constructor(
|
|
public readonly code: PluginSettingsServiceErrorCode,
|
|
message: string
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export type PluginSettingsHtml = {
|
|
plugin: PluginListItem;
|
|
entry: string;
|
|
trustedHtml: string;
|
|
};
|
|
|
|
export type PluginUserSettings = Readonly<Record<string, PluginSettingValue>>;
|
|
export type PluginSettingsPageData = PluginSettingsHtml & {
|
|
settings: PluginUserSettings;
|
|
};
|
|
export type PluginSettingsFrameViolationReason = "wrong_origin" | "wrong_source" | "invalid_payload" | "unexpected_navigation";
|
|
|
|
export async function loadPluginSettingsHtml(args: { tenantId: string; pluginRowId: string }): Promise<PluginSettingsHtml> {
|
|
const plugin = await getPluginForTenant({ tenantId: args.tenantId, pluginRowId: args.pluginRowId });
|
|
if (!plugin) {
|
|
throw new PluginSettingsServiceError("plugin_not_found", `Plugin row ${args.pluginRowId} was not found for this tenant.`);
|
|
}
|
|
return loadPluginSettingsHtmlForPlugin(plugin);
|
|
}
|
|
|
|
export async function loadPluginSettingsPageData(args: { tenantId: string; pluginRowId: string; userId: string }): Promise<PluginSettingsPageData> {
|
|
const plugin = await resolveTenantPlugin(args);
|
|
const [settingsHtml, settings] = await Promise.all([
|
|
loadPluginSettingsHtmlForPlugin(plugin),
|
|
loadPluginUserSettingsForPlugin({ plugin, userId: args.userId }),
|
|
]);
|
|
return { ...settingsHtml, settings };
|
|
}
|
|
|
|
async function loadPluginSettingsHtmlForPlugin(plugin: PluginListItem): Promise<PluginSettingsHtml> {
|
|
if (!plugin.enabled) {
|
|
throw new PluginSettingsServiceError("settings_ui_unavailable", `Plugin ${plugin.pluginId} is disabled.`);
|
|
}
|
|
const entry = assertPluginDeclaresSafeSettingsUi(plugin);
|
|
|
|
const bundleRoot = path.join(resolvePluginBundleBasePath(), plugin.pluginId, plugin.version);
|
|
let rootReal: string;
|
|
let fileReal: string;
|
|
try {
|
|
rootReal = await fs.realpath(bundleRoot);
|
|
fileReal = await fs.realpath(path.resolve(rootReal, entry));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw new PluginSettingsServiceError("settings_ui_file_not_found", `Plugin settings UI file ${entry} was not found.`);
|
|
}
|
|
throw error;
|
|
}
|
|
const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`;
|
|
if (!fileReal.startsWith(rootPrefix)) {
|
|
throw new PluginSettingsServiceError("settings_ui_entry_unsafe", `Plugin settings UI entry ${entry} escapes the plugin bundle.`);
|
|
}
|
|
const fileHandle = await fs.open(fileReal, "r");
|
|
try {
|
|
const fileStats = await fileHandle.stat();
|
|
if (fileStats.size > MAX_PLUGIN_SETTINGS_HTML_BYTES) {
|
|
throw new PluginSettingsServiceError("settings_ui_file_too_large", `Plugin settings UI file ${entry} exceeds ${MAX_PLUGIN_SETTINGS_HTML_BYTES} bytes.`);
|
|
}
|
|
const htmlBytes = await fileHandle.readFile();
|
|
if (htmlBytes.byteLength > MAX_PLUGIN_SETTINGS_HTML_BYTES) {
|
|
throw new PluginSettingsServiceError("settings_ui_file_too_large", `Plugin settings UI file ${entry} exceeds ${MAX_PLUGIN_SETTINGS_HTML_BYTES} bytes.`);
|
|
}
|
|
assertSettingsHtmlIntegrity({ plugin, entry, htmlBytes });
|
|
|
|
return {
|
|
plugin,
|
|
entry,
|
|
trustedHtml: htmlBytes.toString("utf8"),
|
|
};
|
|
} finally {
|
|
await fileHandle.close();
|
|
}
|
|
}
|
|
|
|
export async function loadPluginUserSettings(args: { tenantId: string; pluginRowId: string; userId: string }): Promise<PluginUserSettings> {
|
|
const plugin = await resolveTenantPlugin(args);
|
|
return loadPluginUserSettingsForPlugin({ plugin, userId: args.userId });
|
|
}
|
|
|
|
async function loadPluginUserSettingsForPlugin({ plugin, userId }: { plugin: PluginListItem; userId: string }): Promise<PluginUserSettings> {
|
|
if (!plugin.enabled) {
|
|
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
|
|
}
|
|
assertPluginDeclaresSafeSettingsUi(plugin);
|
|
|
|
const config = await db.pluginUserConfig.findUnique({
|
|
where: { pluginId_userId: { pluginId: plugin.id, userId } },
|
|
select: { configJson: true },
|
|
});
|
|
return normalizeSettingsConfig(config?.configJson);
|
|
}
|
|
|
|
export async function setPluginUserSetting(args: {
|
|
tenantId: string;
|
|
pluginRowId: string;
|
|
userId: string;
|
|
key: string;
|
|
value: PluginSettingValue;
|
|
actor: PluginLifecycleActor;
|
|
}): Promise<{ plugin: PluginListItem; settings: PluginUserSettings; key: string; value: PluginSettingValue }> {
|
|
const plugin = await resolveTenantPlugin(args);
|
|
if (!plugin.enabled) {
|
|
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
|
|
}
|
|
assertPluginDeclaresSafeSettingsUi(plugin);
|
|
assertSafePluginSettingKeyForWrite(args.key);
|
|
assertPluginSettingValue(args.value);
|
|
|
|
const settings = await db.$transaction(async (tx) => {
|
|
const current = await tx.pluginUserConfig.findUnique({
|
|
where: { pluginId_userId: { pluginId: plugin.id, userId: args.userId } },
|
|
select: { configJson: true },
|
|
});
|
|
const nextSettings = {
|
|
...normalizeSettingsConfig(current?.configJson),
|
|
[args.key]: args.value,
|
|
} satisfies PluginUserSettings;
|
|
assertPluginSettingsConfigSize(nextSettings);
|
|
|
|
await tx.pluginUserConfig.upsert({
|
|
where: { pluginId_userId: { pluginId: plugin.id, userId: args.userId } },
|
|
create: {
|
|
pluginId: plugin.id,
|
|
userId: args.userId,
|
|
configJson: nextSettings as never,
|
|
},
|
|
update: {
|
|
configJson: nextSettings as never,
|
|
},
|
|
});
|
|
|
|
await publishAuditEventViaOutbox(tx, {
|
|
tenantId: args.tenantId,
|
|
actorId: args.actor.userId,
|
|
actorType: args.actor.userId ? "user" : "system",
|
|
actorEmail: args.actor.email,
|
|
actorIp: args.actor.ip,
|
|
action: "plugin.configuration.updated",
|
|
targetType: "plugin",
|
|
targetId: plugin.id,
|
|
sourceModule: "plugin-settings",
|
|
outcome: "success",
|
|
dataSensitivity: "internal",
|
|
metadata: { pluginId: plugin.pluginId, key: args.key },
|
|
});
|
|
|
|
return nextSettings;
|
|
});
|
|
|
|
return { plugin, settings, key: args.key, value: args.value };
|
|
}
|
|
|
|
export async function reportPluginSettingsFrameViolation(args: {
|
|
tenantId: string;
|
|
pluginRowId: string;
|
|
reason: PluginSettingsFrameViolationReason;
|
|
actor: PluginLifecycleActor;
|
|
}): Promise<void> {
|
|
const plugin = await resolveTenantPlugin(args);
|
|
if (!plugin.enabled) {
|
|
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
|
|
}
|
|
assertPluginDeclaresSafeSettingsUi(plugin);
|
|
await publishAuditEventViaOutbox(db, {
|
|
tenantId: args.tenantId,
|
|
actorId: args.actor.userId,
|
|
actorType: args.actor.userId ? "user" : "system",
|
|
actorEmail: args.actor.email,
|
|
actorIp: args.actor.ip,
|
|
action: "plugin.settings_frame.violation",
|
|
targetType: "plugin",
|
|
targetId: plugin.id,
|
|
sourceModule: "plugin-settings",
|
|
outcome: "blocked",
|
|
dataSensitivity: "internal",
|
|
metadata: { pluginId: plugin.pluginId, pluginRowId: plugin.id, reason: args.reason, reportedByClient: true },
|
|
});
|
|
}
|
|
|
|
export function getPluginSettingsEntry(manifest: PluginManifest): string | null {
|
|
const entry = manifest.ui?.settings?.entry;
|
|
return typeof entry === "string" && entry.length > 0 ? entry : null;
|
|
}
|
|
|
|
export function assertSafePluginSettingsEntry(entry: string): void {
|
|
if (!RELATIVE_HTML_ENTRY_PATTERN.test(entry)) {
|
|
throw new PluginSettingsServiceError("settings_ui_entry_unsafe", `Plugin settings UI entry ${entry} is not a safe relative HTML path.`);
|
|
}
|
|
}
|
|
|
|
function assertPluginDeclaresSafeSettingsUi(plugin: PluginListItem): string {
|
|
const entry = getPluginSettingsEntry(plugin.manifest);
|
|
if (!entry) {
|
|
throw new PluginSettingsServiceError("settings_ui_unavailable", `Plugin ${plugin.pluginId} does not declare a settings UI.`);
|
|
}
|
|
assertSafePluginSettingsEntry(entry);
|
|
if (!getPluginSettingsExpectedSha256(plugin.manifest)) {
|
|
throw new PluginSettingsServiceError(
|
|
"settings_ui_integrity_missing",
|
|
`Plugin ${plugin.pluginId} settings UI ${entry} does not declare sha256 or integrity.`
|
|
);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function getPluginSettingsExpectedSha256(manifest: PluginManifest): string | null {
|
|
const settings = manifest.ui?.settings;
|
|
if (!settings) return null;
|
|
if (typeof settings.sha256 === "string" && settings.sha256.length > 0) {
|
|
return settings.sha256;
|
|
}
|
|
if (typeof settings.integrity === "string" && settings.integrity.startsWith("sha256-")) {
|
|
return Buffer.from(settings.integrity.slice("sha256-".length), "base64").toString("hex");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function assertSettingsHtmlIntegrity({ plugin, entry, htmlBytes }: { plugin: PluginListItem; entry: string; htmlBytes: Buffer }): void {
|
|
const expectedSha256 = getPluginSettingsExpectedSha256(plugin.manifest);
|
|
if (!expectedSha256) {
|
|
throw new PluginSettingsServiceError(
|
|
"settings_ui_integrity_missing",
|
|
`Plugin ${plugin.pluginId} settings UI ${entry} does not declare sha256 or integrity.`
|
|
);
|
|
}
|
|
const actualSha256 = createHash("sha256").update(htmlBytes).digest("hex");
|
|
if (actualSha256 !== expectedSha256) {
|
|
throw new PluginSettingsServiceError("settings_ui_integrity_mismatch", `Plugin settings UI file ${entry} does not match the manifest sha256.`);
|
|
}
|
|
}
|
|
|
|
function resolvePluginBundleBasePath(): string {
|
|
return process.env.PLUGIN_BINARY_BASE_PATH?.trim() || DEFAULT_PLUGIN_BINARY_BASE_PATH;
|
|
}
|
|
|
|
async function resolveTenantPlugin(args: { tenantId: string; pluginRowId: string }): Promise<PluginListItem> {
|
|
const plugin = await getPluginForTenant({ tenantId: args.tenantId, pluginRowId: args.pluginRowId });
|
|
if (!plugin) {
|
|
throw new PluginSettingsServiceError("plugin_not_found", `Plugin row ${args.pluginRowId} was not found for this tenant.`);
|
|
}
|
|
return plugin;
|
|
}
|
|
|
|
function normalizeSettingsConfig(config: unknown): PluginUserSettings {
|
|
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
return {};
|
|
}
|
|
|
|
const settings: Record<string, PluginSettingValue> = {};
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (isSafePluginSettingKey(key) && isPluginSettingValue(value) && isPluginSettingValueWithinSize(value)) {
|
|
settings[key] = value;
|
|
}
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
function assertPluginSettingKeyNotSecretLike(key: string): void {
|
|
if (isPluginSettingKeySecretLike(key)) {
|
|
throw new PluginSettingsServiceError("settings_key_secret_like", `Plugin setting key ${key} looks like a secret.`);
|
|
}
|
|
}
|
|
|
|
function assertSafePluginSettingKeyForWrite(key: string): void {
|
|
if (!isPluginSettingKeyFormatSafe(key)) {
|
|
throw new PluginSettingsServiceError("settings_key_invalid", `Plugin setting key ${key} is not a safe key.`);
|
|
}
|
|
if (isPluginSettingKeyReserved(key)) {
|
|
throw new PluginSettingsServiceError("settings_key_invalid", `Plugin setting key ${key} is reserved.`);
|
|
}
|
|
assertPluginSettingKeyNotSecretLike(key);
|
|
}
|
|
|
|
function assertPluginSettingValue(value: unknown): asserts value is PluginSettingValue {
|
|
if (!isPluginSettingValueShapeSafe(value)) {
|
|
throw new PluginSettingsServiceError("settings_value_invalid", "Plugin setting value must be JSON-serializable.");
|
|
}
|
|
assertPluginSettingValueSize(value);
|
|
}
|
|
|
|
function assertPluginSettingValueSize(value: PluginSettingValue): void {
|
|
if (!isPluginSettingValueWithinSize(value)) {
|
|
throw new PluginSettingsServiceError("settings_value_too_large", `Plugin setting value exceeds ${MAX_PLUGIN_SETTING_VALUE_BYTES} bytes.`);
|
|
}
|
|
}
|
|
|
|
function assertPluginSettingsConfigSize(settings: PluginUserSettings): void {
|
|
const bytes = Buffer.byteLength(JSON.stringify(settings), "utf8");
|
|
if (bytes > MAX_PLUGIN_USER_CONFIG_BYTES) {
|
|
throw new PluginSettingsServiceError("settings_value_too_large", `Plugin settings exceed ${MAX_PLUGIN_USER_CONFIG_BYTES} bytes.`);
|
|
}
|
|
}
|