From 5e70b89f55d05d9db51352f42de2da1d47eab14b Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 11:45:42 +0800 Subject: [PATCH] feat(ql3): harden cluster secret projection checks --- packages/ql3-cluster-admin/package.json | 5 + .../projectedSecretExistenceInspector.ts | 138 ++++++++++++++++++ ...inPackageSecretExistenceInspector.test.cjs | 96 ++++++++++++ .../remote-execution/mountedSecretProvider.ts | 82 +++-------- packages/ql3-runtime-core/package.json | 8 + .../src/secret/secretProjection.ts | 18 +++ .../test/secretReference.test.cjs | 33 ++++- 7 files changed, 321 insertions(+), 59 deletions(-) create mode 100644 packages/ql3-cluster-admin/src/plugin-package/secret-binding/projectedSecretExistenceInspector.ts create mode 100644 packages/ql3-cluster-admin/test/projectedPluginPackageSecretExistenceInspector.test.cjs create mode 100644 packages/ql3-runtime-core/src/secret/secretProjection.ts diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index a90a3709..05da5488 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -250,6 +250,11 @@ "require": "./dist/plugin-package/management/pluginPackageManagement.js", "default": "./dist/plugin-package/management/pluginPackageManagement.js" }, + "./plugin-package-secret-existence-inspector": { + "types": "./dist/plugin-package/secret-binding/projectedSecretExistenceInspector.d.ts", + "require": "./dist/plugin-package/secret-binding/projectedSecretExistenceInspector.js", + "default": "./dist/plugin-package/secret-binding/projectedSecretExistenceInspector.js" + }, "./plugin-package-lifecycle-management": { "types": "./dist/plugin-package/lifecycle/pluginPackageLifecycleManagement.d.ts", "require": "./dist/plugin-package/lifecycle/pluginPackageLifecycleManagement.js", diff --git a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/projectedSecretExistenceInspector.ts b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/projectedSecretExistenceInspector.ts new file mode 100644 index 00000000..a5dd64e6 --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/projectedSecretExistenceInspector.ts @@ -0,0 +1,138 @@ +import { lstat, realpath, stat } from 'node:fs/promises'; +import { isAbsolute, join, normalize, parse, relative } from 'node:path'; + +import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection'; +import { parseSecretRef } from '@qinglong/runtime-core/secret-reference'; + +const MAX_SECRET_ROOT_BYTES = 4096; + +export interface PluginPackageSecretExistenceInspector { + assertExists(secretRefs: readonly string[]): Promise; +} + +export interface ProjectedPluginPackageSecretExistenceInspectorOptions { + readonly rootDirectory: string; +} + +export class ProjectedPluginPackageSecretExistenceError extends Error { + readonly code = 'QL3_PROJECTED_PLUGIN_PACKAGE_SECRET_UNAVAILABLE'; + + constructor( + readonly reason: + | 'invalid_configuration' + | 'root_unavailable' + | 'reference_unavailable', + options?: ErrorOptions, + ) { + super(`Projected Plugin Package Secret failed: ${reason}`, options); + this.name = 'ProjectedPluginPackageSecretExistenceError'; + } +} + +function rootDirectory(value: string): string { + if ( + typeof value !== 'string' || + !isAbsolute(value) || + parse(value).root === value || + normalize(value) !== value || + value.includes('\0') || + Buffer.byteLength(value, 'utf8') > MAX_SECRET_ROOT_BYTES + ) { + throw new ProjectedPluginPackageSecretExistenceError( + 'invalid_configuration', + ); + } + return value; +} + +function remainsBelow(root: string, candidate: string): boolean { + const suffix = relative(root, candidate); + return ( + suffix.length > 0 && + !isAbsolute(suffix) && + suffix !== '..' && + !suffix.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) + ); +} + +async function resolvedRoot(path: string): Promise { + try { + const configured = await lstat(path); + if (!configured.isDirectory() || configured.isSymbolicLink()) { + throw new Error('root is not a direct directory'); + } + return await realpath(path); + } catch (error) { + throw new ProjectedPluginPackageSecretExistenceError('root_unavailable', { + cause: error, + }); + } +} + +async function assertProjectedReference( + root: string, + secretRef: string, +): Promise { + try { + const reference = parseSecretRef(secretRef); + if (reference.version === undefined) { + throw new Error('projected Secret reference is not versioned'); + } + const candidate = join(root, secretProjectionFileName(secretRef)); + const target = await realpath(candidate); + if (!remainsBelow(root, target)) { + throw new Error('projected Secret escaped its root'); + } + const metadata = await stat(target); + if ( + !metadata.isFile() || + metadata.nlink !== 1 || + metadata.size < 0 || + (metadata.mode & 0o111) !== 0 || + (await realpath(candidate)) !== target + ) { + throw new Error('projected Secret metadata is unsafe'); + } + } catch (error) { + throw new ProjectedPluginPackageSecretExistenceError( + 'reference_unavailable', + { cause: error }, + ); + } +} + +/** + * Metadata-only existence proof for the short-lived Package executor. It never + * opens or reads projected Secret material and retains no cache or watcher. + */ +export class ProjectedPluginPackageSecretExistenceInspector + implements PluginPackageSecretExistenceInspector +{ + private readonly rootDirectory: string; + + constructor(options: ProjectedPluginPackageSecretExistenceInspectorOptions) { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + throw new ProjectedPluginPackageSecretExistenceError( + 'invalid_configuration', + ); + } + this.rootDirectory = rootDirectory(options.rootDirectory); + } + + async assertExists(secretRefs: readonly string[]): Promise { + if ( + !Array.isArray(secretRefs) || + secretRefs.length < 1 || + secretRefs.length > 64 || + new Set(secretRefs).size !== secretRefs.length + ) { + throw new ProjectedPluginPackageSecretExistenceError( + 'reference_unavailable', + ); + } + const root = await resolvedRoot(this.rootDirectory); + for (const secretRef of secretRefs) { + await assertProjectedReference(root, secretRef); + } + } +} diff --git a/packages/ql3-cluster-admin/test/projectedPluginPackageSecretExistenceInspector.test.cjs b/packages/ql3-cluster-admin/test/projectedPluginPackageSecretExistenceInspector.test.cjs new file mode 100644 index 00000000..ecd2de09 --- /dev/null +++ b/packages/ql3-cluster-admin/test/projectedPluginPackageSecretExistenceInspector.test.cjs @@ -0,0 +1,96 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + secretProjectionFileName, +} = require('@qinglong/runtime-core/secret-projection'); +const { + ProjectedPluginPackageSecretExistenceError, + ProjectedPluginPackageSecretExistenceInspector, +} = require('@qinglong/cluster-admin/plugin-package-secret-existence-inspector'); + +const SECRET_REF = createSecretRef({ + projectId: 'project-1', + name: 'api-token', + version: 3, +}); + +test('proves an exact projected Secret without reading its bytes', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-secret-inspect-')); + t.after(() => rm(root, { recursive: true, force: true })); + await chmod(root, 0o700); + const file = path.join(root, secretProjectionFileName(SECRET_REF)); + await writeFile(file, 'unreadable-to-executor', { mode: 0o000 }); + + const inspector = new ProjectedPluginPackageSecretExistenceInspector({ + rootDirectory: root, + }); + await inspector.assertExists([SECRET_REF]); + await assert.rejects( + inspector.assertExists([ + createSecretRef({ + projectId: 'project-1', + name: 'missing', + version: 1, + }), + ]), + (error) => + error instanceof ProjectedPluginPackageSecretExistenceError && + error.reason === 'reference_unavailable', + ); +}); + +test('accepts an in-root projection symlink and rejects an escape', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-secret-project-')); + const outside = await mkdtemp(path.join(os.tmpdir(), 'ql3-secret-outside-')); + t.after(async () => { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + await chmod(root, 0o700); + await chmod(outside, 0o700); + const name = secretProjectionFileName(SECRET_REF); + const generation = path.join(root, '..data-v1'); + await mkdir(generation, { mode: 0o700 }); + await writeFile(path.join(generation, name), 'value', { mode: 0o000 }); + await symlink(path.join('..data-v1', name), path.join(root, name)); + + const inspector = new ProjectedPluginPackageSecretExistenceInspector({ + rootDirectory: root, + }); + await inspector.assertExists([SECRET_REF]); + + await rm(path.join(root, name)); + await writeFile(path.join(outside, name), 'value', { mode: 0o000 }); + await symlink(path.join(outside, name), path.join(root, name)); + await assert.rejects(inspector.assertExists([SECRET_REF])); +}); + +test('rejects duplicate, unversioned and noncanonical references', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'ql3-secret-invalid-')); + t.after(() => rm(root, { recursive: true, force: true })); + await chmod(root, 0o700); + const inspector = new ProjectedPluginPackageSecretExistenceInspector({ + rootDirectory: root, + }); + await assert.rejects(inspector.assertExists([SECRET_REF, SECRET_REF])); + await assert.rejects( + inspector.assertExists([ + createSecretRef({ projectId: 'project-1', name: 'unversioned' }), + ]), + ); + await assert.rejects(inspector.assertExists(['not-a-secret-ref'])); +}); diff --git a/packages/ql3-cluster-control/src/remote-execution/mountedSecretProvider.ts b/packages/ql3-cluster-control/src/remote-execution/mountedSecretProvider.ts index 418111a8..52862e82 100644 --- a/packages/ql3-cluster-control/src/remote-execution/mountedSecretProvider.ts +++ b/packages/ql3-cluster-control/src/remote-execution/mountedSecretProvider.ts @@ -1,18 +1,7 @@ // Remote Execution owns mounted Secret resolution for authenticated delivery. -import { createHash } from 'node:crypto'; import { constants } from 'node:fs'; -import { - lstat, - open, - realpath, -} from 'node:fs/promises'; -import { - isAbsolute, - join, - normalize, - parse, - relative, -} from 'node:path'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { isAbsolute, join, normalize, parse, relative } from 'node:path'; import { MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES, MAX_REMOTE_SECRET_VALUE_BYTES, @@ -21,10 +10,9 @@ import { type RemoteWorkerSecretResolution, type RemoteWorkerSecretValueProvider, } from '@qinglong/runtime-core/remote-secret-delivery'; -import { parseSecretRef } from '@qinglong/runtime-core/secret-reference'; +import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection'; const MAX_SECRET_ROOT_BYTES = 4096; -const SECRET_FILE_NAME = /^[0-9a-f]{64}$/; export interface ClusterMountedSecretProviderOptions { /** @@ -69,25 +57,13 @@ function rootDirectory(value: string): string { * non-reversible name also prevents Project/name input from becoming a path. */ export function clusterMountedSecretFileName(secretRef: string): string { - let canonical: string; try { - const parsed = parseSecretRef(secretRef); - canonical = secretRef; - if ( - parsed.projectId.length < 1 || - parsed.name.length < 1 - ) throw new Error('invalid SecretRef'); + return secretProjectionFileName(secretRef); } catch (error) { - throw new ClusterMountedSecretProviderError( - 'invalid_configuration', - { cause: error }, - ); + throw new ClusterMountedSecretProviderError('invalid_configuration', { + cause: error, + }); } - const name = createHash('sha256').update(canonical, 'utf8').digest('hex'); - if (!SECRET_FILE_NAME.test(name)) { - throw new ClusterMountedSecretProviderError('invalid_configuration'); - } - return name; } function remainsBelow(root: string, candidate: string): boolean { @@ -108,17 +84,13 @@ async function resolvedRoot(path: string): Promise { } return await realpath(path); } catch (error) { - throw new ClusterMountedSecretProviderError( - 'root_unavailable', - { cause: error }, - ); + throw new ClusterMountedSecretProviderError('root_unavailable', { + cause: error, + }); } } -async function readMaterial( - root: string, - secretRef: string, -): Promise { +async function readMaterial(root: string, secretRef: string): Promise { const candidate = join(root, clusterMountedSecretFileName(secretRef)); let handle; try { @@ -152,10 +124,9 @@ async function readMaterial( } return bytes; } catch (error) { - throw new ClusterMountedSecretProviderError( - 'material_unavailable', - { cause: error }, - ); + throw new ClusterMountedSecretProviderError('material_unavailable', { + cause: error, + }); } finally { await handle?.close().catch(() => undefined); } @@ -169,10 +140,9 @@ function secretValue(bytes: Buffer): string { } return value; } catch (error) { - throw new ClusterMountedSecretProviderError( - 'material_unavailable', - { cause: error }, - ); + throw new ClusterMountedSecretProviderError('material_unavailable', { + cause: error, + }); } } @@ -205,10 +175,9 @@ export class ClusterMountedSecretProvider try { normalized = normalizeRemoteWorkerSecretDeliveryAuthority(authority); } catch (error) { - throw new ClusterMountedSecretProviderError( - 'material_unavailable', - { cause: error }, - ); + throw new ClusterMountedSecretProviderError('material_unavailable', { + cause: error, + }); } const root = await resolvedRoot(this.rootDirectory); const buffers: Buffer[] = []; @@ -220,9 +189,7 @@ export class ClusterMountedSecretProvider buffers.push(bytes); totalBytes += bytes.byteLength; if (totalBytes > MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES) { - throw new ClusterMountedSecretProviderError( - 'material_unavailable', - ); + throw new ClusterMountedSecretProviderError('material_unavailable'); } values.push( Object.freeze({ @@ -243,10 +210,9 @@ export class ClusterMountedSecretProvider } catch (error) { for (const bytes of buffers) bytes.fill(0); if (error instanceof ClusterMountedSecretProviderError) throw error; - throw new ClusterMountedSecretProviderError( - 'material_unavailable', - { cause: error }, - ); + throw new ClusterMountedSecretProviderError('material_unavailable', { + cause: error, + }); } } } diff --git a/packages/ql3-runtime-core/package.json b/packages/ql3-runtime-core/package.json index 72116529..24d43707 100644 --- a/packages/ql3-runtime-core/package.json +++ b/packages/ql3-runtime-core/package.json @@ -89,6 +89,9 @@ "plugin-package-secret-binding-plan": [ "dist/plugin-package/secret-binding/plan.d.ts" ], + "secret-projection": [ + "dist/secret/secretProjection.d.ts" + ], "plugin-package-task-reconciliation": [ "dist/plugin-package/pluginPackageTaskReconciliation.d.ts" ], @@ -387,6 +390,11 @@ "require": "./dist/plugin-package/secret-binding/plan.js", "default": "./dist/plugin-package/secret-binding/plan.js" }, + "./secret-projection": { + "types": "./dist/secret/secretProjection.d.ts", + "require": "./dist/secret/secretProjection.js", + "default": "./dist/secret/secretProjection.js" + }, "./plugin-package-task-reconciliation": { "types": "./dist/plugin-package/pluginPackageTaskReconciliation.d.ts", "require": "./dist/plugin-package/pluginPackageTaskReconciliation.js", diff --git a/packages/ql3-runtime-core/src/secret/secretProjection.ts b/packages/ql3-runtime-core/src/secret/secretProjection.ts new file mode 100644 index 00000000..6117b93a --- /dev/null +++ b/packages/ql3-runtime-core/src/secret/secretProjection.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto'; + +import { parseSecretRef } from './secretReference'; + +export const SECRET_PROJECTION_FILE_NAME_PATTERN = /^[0-9a-f]{64}$/; + +/** + * Maps one canonical SecretRef to a path-free, non-reversible projection key. + * The function does not inspect or resolve Secret material. + */ +export function secretProjectionFileName(secretRef: string): string { + parseSecretRef(secretRef); + const result = createHash('sha256').update(secretRef, 'utf8').digest('hex'); + if (!SECRET_PROJECTION_FILE_NAME_PATTERN.test(result)) { + throw new TypeError('Secret projection file name is invalid'); + } + return result; +} diff --git a/packages/ql3-runtime-core/test/secretReference.test.cjs b/packages/ql3-runtime-core/test/secretReference.test.cjs index d6b5a498..9b6373fe 100644 --- a/packages/ql3-runtime-core/test/secretReference.test.cjs +++ b/packages/ql3-runtime-core/test/secretReference.test.cjs @@ -9,6 +9,9 @@ const { createLocalSecretRef, parseLocalSecretRef, } = require('../dist/secret/localSecret'); +const { + secretProjectionFileName, +} = require('@qinglong/runtime-core/secret-projection'); test('keeps qlsecret:v1 profile-neutral and byte-compatible with local aliases', () => { const reference = { projectId: 'default', name: 'TOKEN', version: 2 }; @@ -20,12 +23,40 @@ test('keeps qlsecret:v1 profile-neutral and byte-compatible with local aliases', assert.equal(Object.isFrozen(parseSecretRef(value)), true); }); +test('maps only canonical SecretRefs to stable path-free projection names', () => { + const first = createSecretRef({ + projectId: 'default', + name: 'TOKEN', + version: 2, + }); + const second = createSecretRef({ + projectId: 'default', + name: 'TOKEN', + version: 3, + }); + assert.match(secretProjectionFileName(first), /^[0-9a-f]{64}$/); + assert.equal( + secretProjectionFileName(first), + secretProjectionFileName(first), + ); + assert.notEqual( + secretProjectionFileName(first), + secretProjectionFileName(second), + ); + assert.throws( + () => secretProjectionFileName('not-a-secret-ref'), + InvalidSecretReferenceError, + ); +}); + test('rejects non-canonical, cross-shape and unbounded Secret references', () => { for (const value of [ 'qlsecret:v1:', 'qlsecret:v1:***', 'local-secret:default:TOKEN', - `qlsecret:v1:${Buffer.from('{"name":"TOKEN","projectId":"default"}').toString('base64url')}`, + `qlsecret:v1:${Buffer.from( + '{"name":"TOKEN","projectId":"default"}', + ).toString('base64url')}`, ]) { assert.throws(() => parseSecretRef(value), InvalidSecretReferenceError); }