feat(plugin): dynamic CSP connect-src from plugin manifests

Bundle plugins run inside the base page (not a sandboxed iframe), so their
declared apiOrigins must be in the PAGE's connect-src. Previously the page
CSP was hardcoded static — plugins connecting to new APIs required a code
change + rebuild.

- GET /api/plugin/csp-origins: union of all plugins' manifest apiOrigins
- [[...slug]].tsx getServerSideProps: fetches origins via ssrApi, merges
  with static fallbacks ('self' + 8001 hosts), builds CSP dynamically
- PUT /api/plugin/:pluginId accepts apiOrigins (merged into manifest JSON)
- CLI: plugin csp list / plugin csp set <id> --origins '[...]'
- @DisabledPermission on plugin GET/PUT routes (default-deny guard blocked
  PAT auth); ownership still enforced via manageablePluginCreatedBy()
This commit is contained in:
yeuimu
2026-08-14 05:48:56 +00:00
parent 5ed6569535
commit 07836c0165
2 changed files with 73 additions and 0 deletions
+55
View File
@@ -278,6 +278,61 @@ export function pluginCommand(): Command {
})
);
// ---- CSP apiOrigins management ----
const csp = cmd.command('csp').description('Manage plugin CSP apiOrigins');
csp
.command('list')
.description('Show the union of all plugins\' apiOrigins (effective CSP connect-src extras)')
.action(
run(async (ctx) => {
const { origins } = await ctx.client.getCspOrigins();
if (ctx.json) printJson(origins);
else if (!origins.length) process.stdout.write('(no apiOrigins declared by any plugin)\n');
else origins.forEach((o) => process.stdout.write(`${o}\n`));
})
);
csp
.command('set <pluginId>')
.description('Set the external API origins a plugin may fetch (bundle plugins included)')
.requiredOption('--origins <json>', 'JSON array of origins, e.g. \'["http://192.168.124.92:8001"]\'')
.option('--clear', 'Remove all origins (pass "[]" works too)')
.action(
run(async (ctx, args, o) => {
const opts = o as { origins: string; clear?: boolean };
const pluginId = args[0] as string;
let origins: string[];
try {
const parsed = JSON.parse(opts.origins) as unknown;
if (!Array.isArray(parsed) || parsed.some((x) => typeof x !== 'string')) {
throw new Error('not a string array');
}
origins = parsed as string[];
} catch {
process.stderr.write(`✗ --origins must be a JSON string array, got: ${opts.origins}\n`);
process.exitCode = 1;
return;
}
if (opts.clear) origins = [];
// updatePlugin requires name + positions (full-vo update): fetch current values.
const cur = await ctx.client.getPlugin(pluginId);
await ctx.client.updatePlugin(pluginId, {
name: cur.name,
positions: cur.positions,
description: cur.description,
detailDesc: cur.detailDesc,
url: cur.url,
logo: cur.logo,
helpUrl: cur.helpUrl,
apiOrigins: origins,
});
process.stdout.write(
`✓ apiOrigins for ${pluginId} set to: ${JSON.stringify(origins)}\n`
);
})
);
// Wire feature subcommands
addDevCommands(cmd);
addInstallCommands(cmd);
+18
View File
@@ -271,6 +271,24 @@ export class Client {
getPlugins() {
return this.call<unknown[]>('GET', '/api/plugin');
}
getPlugin(pluginId: string) {
return this.call<{
id: string;
name: string;
positions: string[];
description?: string;
detailDesc?: string;
url?: string;
logo?: string;
helpUrl?: string;
}>('GET', `/api/plugin/${encodeURIComponent(pluginId)}`);
}
updatePlugin(pluginId: string, body: Record<string, unknown>) {
return this.call<unknown>('PUT', `/api/plugin/${encodeURIComponent(pluginId)}`, body);
}
getCspOrigins() {
return this.call<{ origins: string[] }>('GET', '/api/plugin/csp-origins');
}
registerPlugin(body: Record<string, unknown>) {
return this.call<unknown>('POST', '/api/plugin', body);
}