diff --git a/packages/plugin-protocol/src/__tests__/delivery.test.ts b/packages/plugin-protocol/src/__tests__/delivery.test.ts index 879448a..29c8f65 100644 --- a/packages/plugin-protocol/src/__tests__/delivery.test.ts +++ b/packages/plugin-protocol/src/__tests__/delivery.test.ts @@ -6,7 +6,10 @@ import { parsePluginDownloadResponse, PluginProtocolError, } from '../delivery.js'; -import { GHOST_MANIFEST_SCHEMA_VERSION } from '../manifest.js'; +import { + GHOST_MANIFEST_SCHEMA_VERSION, + GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, +} from '../manifest.js'; const validManifest = { schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION, @@ -205,6 +208,60 @@ describe('plugin delivery contract', () => { } }); + it('preserves secret endpoint scope through release manifest normalization', () => { + // delivery 层会重新校验并输出规范化 manifest;endpoint 收窄一旦在这里被丢弃, + // 市场安装路径就会退回"整域可注入",属于凭证边界的 fail open。 + const scopedManifest = { + ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [ + { + key: 'acme_api_key', + label: 'Acme API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['api.example.com'], + paths: ['/v1/convert'], + methods: ['POST'], + }, + }, + ], + }, + }; + + const response = parseGetPluginResponse({ + schemaVersion: PLUGIN_API_SCHEMA_VERSION, + plugin: { + id: pluginId, + ghostId: scopedManifest.id, + name: scopedManifest.name, + description: null, + author: null, + scope: 'public', + organizationId: null, + defaultInstall: false, + currentRelease: { + id: 'release-scoped', + version: scopedManifest.version, + sha256: 'a'.repeat(64), + sizeBytes: 1024, + publishedAt: '2026-07-19T00:00:00.000Z', + manifest: scopedManifest, + }, + }, + }); + + const inject = response.plugin.currentRelease.manifest?.network?.secrets?.[0]?.inject; + expect(inject?.paths).toEqual(['/v1/convert']); + expect(inject?.methods).toEqual(['POST']); + }); + it('keeps availability separate from default installation', () => { const response = parseListPluginsResponse({ schemaVersion: PLUGIN_API_SCHEMA_VERSION, diff --git a/packages/plugin-protocol/src/__tests__/manifest.test.ts b/packages/plugin-protocol/src/__tests__/manifest.test.ts index bc9141c..20f97af 100644 --- a/packages/plugin-protocol/src/__tests__/manifest.test.ts +++ b/packages/plugin-protocol/src/__tests__/manifest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { GHOST_MANIFEST_SUMMARY_MAX_CHARS, GHOST_MANIFEST_SCHEMA_VERSION, + GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, GHOST_OAUTH_SCOPES_MAX, compareCindyVersions, ghostManifestUsesOidcToken, @@ -133,6 +134,187 @@ describe('Ghost manifest contract', () => { expect(baseline.ok && ghostManifestUsesOidcToken(baseline.manifest)).toBe(false); }); + it('accepts and normalizes secret endpoint path/method allowlists (schema v3)', () => { + const result = validateGhostManifest({ + ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/z', '/v1/convert'], + methods: ['POST', 'GET'], + }, + }, + ], + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.schemaVersion).toBe(GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE); + expect(result.manifest.network?.secrets?.[0]?.inject).toEqual({ + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/convert', '/v1/z'], + methods: ['GET', 'POST'], + }); + }); + + it('rejects endpoint-scoped inject on schema v2 (mixed-version fail-closed)', () => { + // 旧客户端不识别 paths/methods,会静默退化为整域注入(fail-open)。因此声明 + // 新字段的清单必须升级到 v3——v2 上声明直接拒装,老客户端遇到 v3 也整包拒装。 + for (const extra of [ + { paths: ['/v1/convert'] }, + { methods: ['POST'] }, + { paths: ['/v1/convert'], methods: ['POST'] }, + ]) { + const result = validateGhostManifest({ + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + ...extra, + }, + }, + ], + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain('schemaVersion'); + } + }); + + it('accepts host-only inject on schema v3 (backward compatible)', () => { + const result = validateGhostManifest({ + ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['api.example.com'], + }, + }, + ], + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.network?.secrets?.[0]?.inject.hosts).toEqual(['api.example.com']); + }); + + it('keeps legacy host-only inject.hosts order unchanged (manifestDigest compatibility)', () => { + // 旧清单的归一化输出必须与升级前逐字节一致:manifestDigest 按数组原始顺序 + // 计算,排序会让已装插件的账本摘要永久失配。 + const result = validateGhostManifest({ + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com', 'cdn.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['cdn.example.com', 'api.example.com'], + }, + }, + ], + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.network?.secrets?.[0]?.inject.hosts).toEqual([ + 'cdn.example.com', + 'api.example.com', + ]); + }); + + it('sorts inject.hosts once endpoint fields are declared (stable permission detail/diff)', () => { + const result = validateGhostManifest({ + ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com', 'cdn.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['cdn.example.com', 'api.example.com'], + paths: ['/v1/convert'], + }, + }, + ], + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.network?.secrets?.[0]?.inject.hosts).toEqual([ + 'api.example.com', + 'cdn.example.com', + ]); + }); + + it('rejects ambiguous endpoint paths and unsupported methods', () => { + const validateInject = (inject: Record) => + validateGhostManifest({ + ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [{ key: 'api_key', label: 'API Key', inject }], + }, + }); + expect( + validateInject({ header: 'Authorization', format: 'Bearer {value}', paths: ['/a/../b'] }).ok, + ).toBe(false); + expect( + validateInject({ header: 'Authorization', format: 'Bearer {value}', paths: ['/%2fsecret'] }) + .ok, + ).toBe(false); + expect( + validateInject({ header: 'Authorization', format: 'Bearer {value}', methods: ['HEAD'] }).ok, + ).toBe(false); + }); + it('rejects unsafe oidc-token declarations', () => { const base = { ...validManifest, diff --git a/packages/plugin-protocol/src/manifest.ts b/packages/plugin-protocol/src/manifest.ts index 0c7511c..94ddad0 100644 --- a/packages/plugin-protocol/src/manifest.ts +++ b/packages/plugin-protocol/src/manifest.ts @@ -7,6 +7,18 @@ export const CINDY_FILE_EXT = '.cindy'; /** ghost.json 格式版本;与 Plugin HTTP API envelope 版本独立演进。 */ export const GHOST_MANIFEST_SCHEMA_VERSION = 2 as const; +/** + * 声明 endpoint-scoped secret 注入(inject.paths / inject.methods)所需的 schema 版本。 + * 旧客户端只认 GHOST_MANIFEST_SCHEMA_VERSION,对更高的版本整包拒装——这是 + * mixed-version fail-closed 边界:使用新安全字段的插件必须声明该版本,在旧客户端 + * 上被拒绝,而不是被静默降级为整域注入(权限收窄退化为放开)。 + */ +export const GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE = 3 as const; + +/** ghost.json 当前受理的全部 schema 版本。 */ +export type GhostManifestSchemaVersion = + typeof GHOST_MANIFEST_SCHEMA_VERSION | typeof GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE; + /** ghost.json 的 description / whenToUse 字符上限。 */ export const GHOST_MANIFEST_SUMMARY_MAX_CHARS = 300; @@ -387,6 +399,34 @@ function ghostNetworkHostMatches(pattern: string, hostname: string): boolean { return hostname === pattern; } +export const GHOST_SECRET_INJECT_MAX_PATHS = 16; +export const GHOST_SECRET_INJECT_PATH_MAX_CHARS = 1024; +export const GHOST_FETCH_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +export type GhostFetchMethod = (typeof GHOST_FETCH_METHODS)[number]; + +export function isValidGhostSecretInjectPath(pathname: unknown): pathname is string { + if ( + typeof pathname !== 'string' || + pathname.length === 0 || + pathname.length > GHOST_SECRET_INJECT_PATH_MAX_CHARS || + !pathname.startsWith('/') || + pathname.includes('?') || + pathname.includes('#') || + pathname.includes('\\') || + // eslint-disable-next-line no-control-regex -- 控制字符是显式清洗目标 + /[\x00-\x1f\x7f]/.test(pathname) || + /%(?:2f|5c)/i.test(pathname) || + /%(?![0-9a-f]{2})/i.test(pathname) + ) { + return false; + } + try { + return new URL(pathname, 'https://ghost.invalid').pathname === pathname; + } catch { + return false; + } +} + /** * 凭证注入声明:该凭证以什么形态、进哪些域名的请求头。绑定在 secret 上 * (而非独立 auth 模板)是刻意的——结构上保证"key 只流向它声明的域名", @@ -398,6 +438,10 @@ export interface GhostSecretInjectDecl { * 缺省 = 详单里的全部域名。 */ hosts?: string[]; + /** 精确 URL.pathname 白名单;缺省 = 该 host 下全部路径。 */ + paths?: string[]; + /** HTTP method 白名单;缺省 = 代理 fetch 支持的全部方法。 */ + methods?: GhostFetchMethod[]; /** 注入的请求头名(如 Authorization / X-Subscription-Token)。 */ header: string; /** 头值模板:恰含一个 `{value}` 占位,其余为静态文本(如 `Bearer {value}`)。 */ @@ -754,8 +798,8 @@ export interface GhostSkillNeeds { /** ghost.json 清单(不变量由 validateGhostManifest 保证)。 */ export interface GhostManifest { - /** 清单格式版本,恒 2(v1 声明型已于 2026-07-12 移除,无存量不留兼容)。 */ - schemaVersion: typeof GHOST_MANIFEST_SCHEMA_VERSION; + /** 清单格式版本:2 = 基线(v1 声明型已于 2026-07-12 移除);3 = endpoint-scoped 凭证注入。 */ + schemaVersion: GhostManifestSchemaVersion; /** 唯一标识,同时是安装目录名与 panelKind 后缀。 */ id: string; /** 展示名。 */ @@ -1020,10 +1064,13 @@ function isPlainObject(v: unknown): v is Record { export function validateGhostManifest(raw: unknown): ManifestValidation { if (!isPlainObject(raw)) return { ok: false, reason: '清单不是对象' }; - if (raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION) { + if ( + raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION && + raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE + ) { return { ok: false, - reason: `schemaVersion 必须是 ${GHOST_MANIFEST_SCHEMA_VERSION},得到 ${JSON.stringify(raw.schemaVersion)}(v1 声明型已于 2026-07-12 移除)`, + reason: `schemaVersion 必须是 ${GHOST_MANIFEST_SCHEMA_VERSION} 或 ${GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE},得到 ${JSON.stringify(raw.schemaVersion)}(v1 声明型已于 2026-07-12 移除)`, }; } if (!isValidGhostId(raw.id)) { @@ -1764,6 +1811,89 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { injectHosts.push(ihNorm); } } + // endpoint-scoped 注入(paths / methods)必须声明 schemaVersion 3: + // 旧客户端不识别这两个字段,若放行会让收窄静默退化为整域注入(fail-open)。 + // 声明新字段即升级版本,旧客户端对 v3 整包拒装——mixed-version fail-closed。 + if ( + (inj.paths !== undefined || inj.methods !== undefined) && + raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE + ) { + return { + ok: false, + reason: `network.secrets[].inject 声明了 paths/methods,必须使用 schemaVersion ${GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE}(旧版本客户端会忽略这些字段并退化为整域注入,故强制升级清单版本)`, + }; + } + let injectPaths: string[] | undefined; + if (inj.paths !== undefined) { + if ( + !Array.isArray(inj.paths) || + inj.paths.length === 0 || + inj.paths.length > GHOST_SECRET_INJECT_MAX_PATHS + ) { + return { + ok: false, + reason: `network.secrets[].inject.paths 必须是 1–${GHOST_SECRET_INJECT_MAX_PATHS} 条精确 pathname 的数组(或省略 = 全部路径)`, + }; + } + injectPaths = []; + for (const path of inj.paths) { + if (!isValidGhostSecretInjectPath(path)) { + return { + ok: false, + reason: `network.secrets[].inject.paths 含非法条目 ${JSON.stringify(path)}`, + }; + } + if (injectPaths.includes(path)) { + return { + ok: false, + reason: `network.secrets[].inject.paths 含重复条目 ${JSON.stringify(path)}`, + }; + } + injectPaths.push(path); + } + injectPaths.sort(); + } + let injectMethods: GhostFetchMethod[] | undefined; + if (inj.methods !== undefined) { + if (!Array.isArray(inj.methods) || inj.methods.length === 0) { + return { + ok: false, + reason: 'network.secrets[].inject.methods 必须是非空数组(或省略 = 全部支持的方法)', + }; + } + injectMethods = []; + for (const method of inj.methods) { + if ( + typeof method !== 'string' || + !(GHOST_FETCH_METHODS as readonly string[]).includes(method) + ) { + return { + ok: false, + reason: `network.secrets[].inject.methods 含未知项 ${JSON.stringify(method)}(可用:${GHOST_FETCH_METHODS.join(' / ')})`, + }; + } + const typed = method as GhostFetchMethod; + if (injectMethods.includes(typed)) { + return { + ok: false, + reason: `network.secrets[].inject.methods 含重复条目 ${JSON.stringify(method)}`, + }; + } + injectMethods.push(typed); + } + injectMethods.sort( + (a, b) => GHOST_FETCH_METHODS.indexOf(a) - GHOST_FETCH_METHODS.indexOf(b), + ); + } + // 排序归一化只对声明了新字段(inject.paths / inject.methods)的凭证生效: + // 旧 host-only 清单的归一化输出必须与升级前逐字节一致(manifestDigest 按 + // 数组原始顺序计算,排序会让已装插件的账本摘要永久失配)。 + if ( + injectHosts !== undefined && + (injectPaths !== undefined || injectMethods !== undefined) + ) { + injectHosts.sort(); + } if (oidcManaged) { if (inj.header !== 'Authorization' || inj.format !== 'Bearer {value}') { return { @@ -2234,6 +2364,8 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { header: inj.header, format: inj.format, ...(injectHosts !== undefined ? { hosts: injectHosts } : {}), + ...(injectPaths !== undefined ? { paths: injectPaths } : {}), + ...(injectMethods !== undefined ? { methods: injectMethods } : {}), }, ...(exchange !== undefined ? { exchange } : {}), ...(oauth !== undefined ? { oauth } : {}), @@ -2852,7 +2984,8 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { return { ok: true, manifest: { - schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION, + // 保留输入版本(v3 清单不得被降级回 v2,否则 manifestDigest 与打包时失配)。 + schemaVersion: raw.schemaVersion as GhostManifestSchemaVersion, id: raw.id, name: raw.name, version: raw.version,