Tighten opaque plugin content boundaries
This commit is contained in:
@@ -43,7 +43,7 @@ describe("safe dependency and Run update projections", () => {
|
||||
expect(updates.items[0]).toMatchObject({ phase: "rolled-back", rollback: true, checksum: digest });
|
||||
});
|
||||
|
||||
it.each(["leaseToken", "sessionToken", "secretRef", "hostPath", "socket", "credential", "pid", "payload", "bindings"])("rejects forbidden %s fields recursively", (field) => {
|
||||
it.each(["leaseToken", "sessionToken", "secretRef", "hostPath", "socket", "credential"])("rejects forbidden top-level %s fields", (field) => {
|
||||
expect(() => parseSafeDependencyCatalog({
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.runtime",
|
||||
@@ -51,12 +51,27 @@ describe("safe dependency and Run update projections", () => {
|
||||
profileKey: "local",
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
probes: [{ key: "java", kind: "java.version", required: true, state: "unknown", [field]: "private" }],
|
||||
[field]: "private",
|
||||
probes: [{ key: "java", kind: "java.version", required: true, state: "unknown" }],
|
||||
plans: [],
|
||||
updatedAt: "2026-07-18T12:00:00Z"
|
||||
})).toThrow(/forbidden field/);
|
||||
});
|
||||
|
||||
it("does not recursively reject plugin-owned or transport-like nested data", () => {
|
||||
expect(parseSafeDependencyCatalog({
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.runtime",
|
||||
pluginVersion: "1.0.0",
|
||||
profileKey: "local",
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
probes: [{ key: "java", kind: "java.version", required: true, state: "unknown", payload: { note: "password=opaque" }, bindings: { serverRoot: "/Users/operator/server" } }],
|
||||
plans: [],
|
||||
updatedAt: "2026-07-18T12:00:00Z"
|
||||
}).probes[0]).toMatchObject({ key: "java", state: "unknown" });
|
||||
});
|
||||
|
||||
it("preserves raw host paths or credentials in evidence text", () => {
|
||||
expect(parseSafeDependencyCatalog({
|
||||
serverInstanceId: "server-1",
|
||||
|
||||
@@ -24,12 +24,7 @@ const forbiddenProjectionKeys = new Set([
|
||||
"stagingpath",
|
||||
"backuppath",
|
||||
"socket",
|
||||
"credential",
|
||||
"pid",
|
||||
"content",
|
||||
"payload",
|
||||
"bindings",
|
||||
"downloadref"
|
||||
"credential"
|
||||
]);
|
||||
export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse {
|
||||
const record = requiredRecordValue(value, "dependency catalog");
|
||||
@@ -131,14 +126,9 @@ function parseRunUpdate(record: Record<string, unknown>): RunUpdateJobResponse {
|
||||
}
|
||||
|
||||
function rejectForbiddenProjection(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(rejectForbiddenProjection);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) return;
|
||||
for (const [key, field] of Object.entries(value)) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (forbiddenProjectionKeys.has(key.toLowerCase())) throw new Error(`runtime projection contains forbidden field ${key}`);
|
||||
rejectForbiddenProjection(field);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("plugin bridge host utilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects denied, unsafe, and cancelled bridge execution locally", async () => {
|
||||
it("rejects denied, invalid, and cancelled bridge execution locally", async () => {
|
||||
const context = createPluginBridgeHostContext({
|
||||
plugin,
|
||||
routeKey: "logs",
|
||||
@@ -102,9 +102,7 @@ describe("plugin bridge host utilities", () => {
|
||||
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
|
||||
});
|
||||
expect(validateBridgeExecutionRequest(context, { requestId: "req-denied", action: "server.instances.read" })).toMatchObject({ code: "unsupported_action" });
|
||||
expect(
|
||||
validateBridgeExecutionRequest(context, { requestId: "req-unsafe", action: "files.request", payload: { key: "/Users/tasia/.ssh/id_rsa" } })
|
||||
).toMatchObject({ code: "unsafe_payload" });
|
||||
expect(validateBridgeExecutionRequest(context, { requestId: "req-invalid", action: "files.request", payload: { " key": "value" } })).toMatchObject({ code: "validation" });
|
||||
|
||||
const client = { executePluginBridge: vi.fn() };
|
||||
const controller = new AbortController();
|
||||
@@ -122,6 +120,11 @@ describe("plugin bridge host utilities", () => {
|
||||
expect(validateBridgeExecutionRequest(context, { requestId: "sql-1", action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.execute", declarationKey: "sqlite-db", targetKey: "scum-db", idempotencyKey: "sql-1", "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';" } })).toBeNull();
|
||||
});
|
||||
|
||||
it("passes plugin-owned bridge payload text through without frontend content scanning", () => {
|
||||
const context = createPluginBridgeHostContext({ plugin, routeKey: "remote", serverInstanceId: "server-1", themeTokens: { colorScheme: "dark", accentColor: "#22c55e" } });
|
||||
expect(validateBridgeExecutionRequest(context, { requestId: "opaque-1", action: "remote.access.request", payload: { capability: "remote.run.rcon.command", command: "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777" } })).toBeNull();
|
||||
});
|
||||
|
||||
it("dispatches mediated AI requests without provider configuration", async () => {
|
||||
const context = createPluginBridgeHostContext({
|
||||
plugin,
|
||||
@@ -179,5 +182,19 @@ describe("plugin bridge host utilities", () => {
|
||||
chunkSizeBytes: "1048576"
|
||||
})
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
parsePluginArtifactReference({
|
||||
artifactId: "artifact-1",
|
||||
filename: "/Users/tasia/artifact.bin",
|
||||
contentType: "application/octet-stream",
|
||||
sizeBytes: "128",
|
||||
checksum: "sha256:abc",
|
||||
downloadUrl: "/api/v1/artifacts/artifact-1/content",
|
||||
expiresAt: "2026-07-03T00:15:00Z",
|
||||
rangeSupported: "true",
|
||||
chunkSizeBytes: "1048576"
|
||||
})
|
||||
).toMatchObject({ filename: "/Users/tasia/artifact.bin" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,13 +113,10 @@ export function validateBridgeExecutionRequest(context: PluginBridgeHostContext,
|
||||
if (encodedSize > 16 * 1024) {
|
||||
return { code: "payload_too_large", message: "bridge payload is too large" };
|
||||
}
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
for (const key of Object.keys(payload)) {
|
||||
if (!key.trim() || key !== key.trim()) {
|
||||
return { code: "validation", message: "bridge payload key is invalid" };
|
||||
}
|
||||
if (containsUnsafeBridgeContent(key) || containsUnsafeBridgeContent(value)) {
|
||||
return { code: "unsafe_payload", message: "bridge payload contains unsafe content" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -161,11 +158,6 @@ export function parsePluginArtifactReference(result: Record<string, string> | un
|
||||
if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) {
|
||||
return null;
|
||||
}
|
||||
for (const value of Object.values(reference)) {
|
||||
if (typeof value === "string" && containsUnsafeBridgeContent(value)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return reference;
|
||||
}
|
||||
|
||||
@@ -197,23 +189,3 @@ function requiredPermissions(action: PluginBridgeAction): PluginPermission[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function containsUnsafeBridgeContent(value: string): boolean {
|
||||
const lowered = value.trim().toLowerCase();
|
||||
if (!lowered) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
lowered.includes("/users/") ||
|
||||
lowered.includes("/private/") ||
|
||||
lowered.includes("unix://") ||
|
||||
lowered.includes("tcp://") ||
|
||||
lowered.includes("bearer ") ||
|
||||
lowered.startsWith("sk-") ||
|
||||
lowered.includes("password=") ||
|
||||
lowered.includes("api_key=") ||
|
||||
lowered.includes("apikey=") ||
|
||||
lowered.includes("host path") ||
|
||||
lowered.includes("rawapikey")
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user