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>; 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 { 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 { 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 { 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 { const plugin = await resolveTenantPlugin(args); return loadPluginUserSettingsForPlugin({ plugin, userId: args.userId }); } async function loadPluginUserSettingsForPlugin({ plugin, userId }: { plugin: PluginListItem; userId: string }): Promise { 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 { 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 { 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 = {}; 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.`); } }