Tighten opaque plugin content boundaries

This commit is contained in:
npc0-hue
2026-09-03 18:24:39 +08:00
parent 80cddbf19d
commit 14cbc63e61
31 changed files with 452 additions and 558 deletions
+2
View File
@@ -22,6 +22,8 @@ Manifests, schemas, SDK contracts, example plugins, and test fixtures must live
Plugins must never receive raw AI provider keys, run credentials, raw host paths, or direct storage endpoints. Use platform-mediated bridge calls for jobs, logs, files, artifacts, and AI.
Plugin-owned request text, result payloads, player/user records, stdout/stderr, and declared file-tail bodies are opaque to Platform and Run. Do not add plugin SDK or manifest compatibility logic that scans, redacts, filters, normalizes, or rejects those values because they look like credentials, host paths, login lines, SQL text, RCON text, or game-specific user data. Keep safety checks on declared actions, permissions, scoped references, schemas, sizes, checksums, and transport framing.
## Local Development
Local plugin development should exercise real platform-run flows through dev registration instead of bypassing platform authorization.
@@ -29,7 +29,7 @@ function renderConfigPage(e: ReactLike["createElement"], input: any) {
function renderLogsPage(e: ReactLike["createElement"], input: any) {
return renderPanel(e, "日志视图", "日志入口由插件声明并通过平台日志通道读取。", input, [
["日志权限", (input.context?.permissions ?? []).includes("server.logs.read") ? "可读" : "未声明"],
["Companion", input.availability?.available ? "可用" : input.availability?.reason ?? "等待运行端"],
["桥接状态", input.availability?.available ? "可用" : input.availability?.reason ?? "等待运行端"],
["桥接", (input.context?.bridgeActions ?? []).includes("logs.query") ? "logs.query" : "未声明"]
]);
}
-71
View File
@@ -171,76 +171,6 @@ function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefi
return undefined;
}
function unsafeGameClientBridgePayloadKey(value: string): boolean {
const tokens = identifierTokens(value);
const compact = tokens.join("");
return ["sql", "rawsql", "sqltext", "sqlstatement", "dsn", "hostpath", "socket", "credential", "accesstoken"].includes(compact);
}
function unsafeBridgeSchemaFieldReason(fieldName: string): string | undefined {
const tokens = identifierTokens(fieldName);
const compact = tokens.join("");
const generalReason = unsafeFieldReason(fieldName);
if (generalReason) {
return generalReason;
}
if ((tokens.includes("sql") || tokens.includes("query")) && tokens.includes("template") && (tokens.includes("key") || tokens.includes("ref"))) {
return undefined;
}
if (["sql", "rawsql", "sqltext", "sqlquery", "sqlstatement", "rawquery", "statement"].includes(compact)) {
return "arbitrary SQL field is not allowed";
}
if (["shell", "shellcommand", "shellscript", "script", "scriptbody", "terminalcommand", "commandline", "powershell"].includes(compact)) {
return "arbitrary shell or script field is not allowed";
}
if (["hostpath", "rawpath", "absolutepath", "filesystempath"].includes(compact)) {
return "raw host path field is not allowed";
}
if (["runcapability", "executorcapability", "runendpoint", "runsocket", "directrun"].includes(compact)) {
return "unsafe executor capability or direct Run field is not allowed";
}
if (tokens.some((token) => ["socket", "password", "credential", "secret", "token", "dsn"].includes(token))) {
return "direct socket or raw credential field is not allowed";
}
return undefined;
}
function unsafeBridgeSchemaStringReasons(value: string): string[] {
const reasons = [...unsafeStringReasons(value)];
const trimmed = value.trim();
const fieldReason = unsafeBridgeSchemaFieldReason(trimmed);
if (fieldReason) {
reasons.push(fieldReason);
}
if (/\bselect\b[\s\S]{0,240}\bfrom\b/i.test(trimmed) || /\b(?:insert\s+into|update\s+[a-z0-9_.]+\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|attach\s+database|pragma\s+[a-z0-9_]+)/i.test(trimmed)) {
reasons.push("arbitrary SQL content is not allowed");
}
if (/^\s*(?:sh|bash|zsh|powershell|pwsh)\s+-[a-z]*c\b/i.test(trimmed) || /^\s*cmd(?:\.exe)?\s+\/c\b/i.test(trimmed)) {
reasons.push("arbitrary shell content is not allowed");
}
if (/^(?:run|executor|shell|script|terminal)\.(?:socket|endpoint|exec|execute|command)$/i.test(trimmed)) {
reasons.push("unsafe executor capability is not allowed");
}
return [...new Set(reasons)];
}
function scanUnsafeBridgeSchema(value: unknown, location: string): string[] {
if (typeof value === "string") {
return unsafeBridgeSchemaStringReasons(value).map((reason) => `${location}: ${reason}`);
}
if (Array.isArray(value)) {
return value.flatMap((item, index) => scanUnsafeBridgeSchema(item, `${location}[${index}]`));
}
if (typeof value === "object" && value !== null) {
return Object.entries(value).flatMap(([key, child]) => {
const keyReason = unsafeBridgeSchemaFieldReason(key);
const keyErrors = keyReason ? [`${location}.${key}: ${keyReason}`] : [];
return [...keyErrors, ...scanUnsafeBridgeSchema(child, `${location}.${key}`)];
});
}
return [];
}
function validateBoundedBridgeSchema(value: unknown, location: string): string[] {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return [`${location}: bridge schema root must be an object schema`];
@@ -790,7 +720,6 @@ function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: str
const message = error instanceof Error ? error.message : "invalid JSON Schema";
errors.push(`${declaration.location}: bridge schema is invalid: ${message}`);
}
errors.push(...scanUnsafeBridgeSchema(schema, `${declaration.location}.schema`));
errors.push(...validateBoundedBridgeSchema(schema, `${declaration.location}.schema`));
}
return errors;
+1 -1
View File
@@ -34,7 +34,7 @@ SQLite reads use manifest-declared `gameClientBridge.queryTemplates`. Plugin pag
Run distribution, dependency, and log backfill requests use `createRunDistributionRequest`, `createDependencyActionRequest`, and `createLogBackfillRequest`. These envelopes carry only operation names, logical profile keys, target platforms, artifact IDs, approved dependency plan digests, cursors, and idempotency keys. Raw run keys, component sessions, secret refs, host paths, PIDs, sockets, credentials, and direct Run endpoint details are never plugin bridge fields.
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, redacts, queues, and forwards it to Run. A management program is not host OS shell access. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields.
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, queues, and forwards it to Run without inspecting or redacting plugin-owned request/result text. A management program is not host OS shell access. Transport envelope fields intentionally have no component session, component key, installation fence, Run endpoint, direct socket, or storage credential access; plugin-owned payload/result fields pass through unchanged inside those envelopes.
Production plugin lifecycle requests use `createProductionPluginLifecycleRequest`. Envelopes contain only plugin/server scope, enumerated operation, optional target version, confirmation, and idempotency key. Platform rechecks the manifest `productionLifecycle` declaration, dependency policy, disruptive approval, endpoint capacity, compatibility, and prior idempotency inputs before dispatch.
-26
View File
@@ -822,11 +822,6 @@ export function parseArtifactReference(result: Record<string, string> | undefine
if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) {
return undefined;
}
for (const value of Object.values(reference)) {
if (typeof value === "string" && containsUnsafeReferenceContent(value)) {
return undefined;
}
}
return reference;
}
@@ -883,24 +878,3 @@ export function bridgeError(
): PluginBridgeError {
return { code, message, details };
}
function containsUnsafeReferenceContent(value: string): boolean {
const lowered = value.trim().toLowerCase();
return (
lowered.includes("/users/") ||
lowered.includes("/private/") ||
lowered.includes("unix://") ||
lowered.includes("tcp://") ||
lowered.includes("bearer ") ||
lowered.includes("password=") ||
lowered.includes("api_key=") ||
lowered.includes("apikey=") ||
lowered.includes("storage://") ||
lowered.includes("file://") ||
lowered.includes("sessiontoken") ||
lowered.includes("secret://") ||
lowered.includes("hostpath") ||
lowered.includes("processid") ||
lowered.startsWith("sk-")
);
}
+21 -12
View File
@@ -638,18 +638,18 @@ describe("plugin manifest validation", () => {
expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true);
});
it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => {
it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("keeps opaque query parameter schema field %s", (fieldName) => {
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName]));
});
expect(errors.some((error) => error.includes("queryTemplates[0].parameterSchemaRef") && error.includes("not allowed"))).toBe(true);
expect(errors.some((error) => error.includes("queryTemplates[0].parameterSchemaRef"))).toBe(false);
});
it("rejects SQL text embedded in a query result schema", () => {
it("keeps opaque SQL-looking text embedded in a query result schema", () => {
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ summary: { type: "string", minLength: 1, maxLength: 200, const: "SELECT id FROM players" } }, ["summary"]));
});
expect(errors.some((error) => error.includes("queryTemplates[0].resultSchemaRef") && error.includes("arbitrary SQL content"))).toBe(true);
expect(errors.some((error) => error.includes("queryTemplates[0].resultSchemaRef"))).toBe(false);
});
it("rejects missing bridge schema files end to end", () => {
@@ -673,16 +673,13 @@ describe("plugin manifest validation", () => {
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("not valid JSON"))).toBe(true);
});
it("rejects dangerous fields and values in payload, result, and snapshot schemas", () => {
it("keeps opaque fields and values in payload, result, and snapshot schemas", () => {
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping-result.schema.json", bridgeObjectSchema({ shellCommand: { type: "string", const: "bash -c whoami" } }, ["shellCommand"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, mode: { type: "string", const: "run.socket" }, runCapability: { type: "string" } }, ["hostPath", "mode", "runCapability"]));
});
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("arbitrary SQL field"))).toBe(true);
expect(errors.some((error) => error.includes("resultSchemaRef") && error.includes("arbitrary shell"))).toBe(true);
expect(errors.some((error) => error.includes("snapshots[0].schemaRef") && error.includes("raw host path"))).toBe(true);
expect(errors.some((error) => error.includes("snapshots[0].schemaRef") && error.includes("unsafe executor capability"))).toBe(true);
expect(errors.some((error) => error.includes("payloadSchemaRef") || error.includes("resultSchemaRef") || error.includes("snapshots[0].schemaRef"))).toBe(false);
});
it("requires bounded object schemas for every bridge reference", () => {
@@ -938,9 +935,21 @@ describe("plugin SDK", () => {
storageBehavior: "platform-memory-transfer-session"
});
expect(reference).toMatchObject({ artifactId: "artifact-1", rangeSupported: true });
expect(JSON.stringify(reference)).not.toContain("/Users/");
expect(JSON.stringify(reference)).not.toContain("storage://");
expect(JSON.stringify(reference)).not.toContain("Bearer ");
expect(
parseArtifactReference({
artifactId: "artifact-1",
filename: "/Users/operator/password=opaque.bin",
contentType: "application/octet-stream",
sizeBytes: "64",
checksum: "sha256:abc",
downloadUrl: "/api/v1/artifacts/artifact-1/content",
expiresAt: "2026-07-03T00:15:00Z",
rangeSupported: "true",
chunkSizeBytes: "1048576",
storageBehavior: "plugin-owned opaque storage:// label"
})
).toMatchObject({ filename: "/Users/operator/password=opaque.bin", storageBehavior: "plugin-owned opaque storage:// label" });
expect(
parseArtifactReference({