Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/plugins/services/.server/PluginSettingsService.ts
T

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.`);
}
}