mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
fix: harden the plugin runtime after the 2.9.0 security review
This commit is contained in:
1 parent
2961297989
commit
c6a44b7ff3
69 files changed
+4613
-312
No files matched your search
@@ -10,10 +10,14 @@
|
||||
*
|
||||
* The cache is invalidated explicitly on every grant change rather than by
|
||||
* TTL: a revoke that takes effect a minute later is not a revoke.
|
||||
*
|
||||
* Every refusal is audited here, so a guard added anywhere in the runtime
|
||||
* leaves a trail without each call site remembering to write one.
|
||||
*/
|
||||
|
||||
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
const cache = new Map<string, Set<string>>();
|
||||
|
||||
@@ -43,12 +47,81 @@ export async function hasCapability(
|
||||
return (await loadGrants(pluginId)).has(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* For hooks ssh2 calls synchronously. Answers from the cache only and fails
|
||||
* closed when the grants are not loaded yet, starting the load for next time.
|
||||
*/
|
||||
export function hasCachedCapability(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
): boolean {
|
||||
if (!declared.includes(capability)) return false;
|
||||
const cached = cache.get(pluginId);
|
||||
if (!cached) {
|
||||
void loadGrants(pluginId).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
return cached.has(capability);
|
||||
}
|
||||
|
||||
/** Loads a plugin's grants ahead of a synchronous hasCachedCapability. */
|
||||
export function warmPluginGrants(pluginId: string): void {
|
||||
void loadGrants(pluginId).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws PluginCapabilityError unless granted, after writing the refusal's
|
||||
* audit line. `action` names the call ("kv_set"), so the line says what was
|
||||
* refused and not only which capability was missing.
|
||||
*/
|
||||
export async function assertCapability(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
action = "capability_refused",
|
||||
): Promise<void> {
|
||||
if (!(await hasCapability(pluginId, capability, declared))) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
if (await hasCapability(pluginId, capability, declared)) return;
|
||||
const error = new PluginCapabilityError(pluginId, capability);
|
||||
await auditRefusal(pluginId, capability, action, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* The error for a capability a sync call never declared, with its audit
|
||||
* line written in the background.
|
||||
*/
|
||||
export function capabilityRefused(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
action = "capability_refused",
|
||||
): PluginCapabilityError {
|
||||
const error = new PluginCapabilityError(pluginId, capability);
|
||||
void auditRefusal(pluginId, capability, action, error);
|
||||
return error;
|
||||
}
|
||||
|
||||
async function auditRefusal(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
action: string,
|
||||
error: Error,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
// Attribution comes from the runtime, never from the plugin.
|
||||
userId: getActor() ?? "system",
|
||||
username: `plugin:${pluginId}`,
|
||||
action: `plugin_${action}`,
|
||||
resourceType: "plugin",
|
||||
resourceId: pluginId,
|
||||
resourceName: pluginId,
|
||||
details: `refused: ${capability}`,
|
||||
success: false,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never change what the caller sees.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user