Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/global/core/app/tool/systemTool/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SystemPluginToolCollectionType } from '../../../plugin/tool/type';
import { PluginStatusEnum } from '../../../plugin/type';
import { SystemToolSystemSecretStatusEnum } from './constants';
import type { SystemToolListItemType } from './type';
import { isDebugToolSource } from '../utils';

type SystemToolConfigLike = SystemPluginToolCollectionType & {
toObject?: () => SystemPluginToolCollectionType;
Expand Down Expand Up @@ -87,13 +88,16 @@ export const SystemToolCodec = {
attachToolConfig({
tool,
config,
lang
lang,
source
}: {
tool: ToolListItemType;
config?: SystemPluginToolCollectionType;
lang?: `${LangEnum}`;
source?: string;
}): SystemToolListItemType {
const configuredSecretsVal = this.getConfiguredSecretsVal(config);
const isDebugSource = isDebugToolSource(source) || isDebugToolSource(tool.source);
const configuredSecretsVal = isDebugSource ? undefined : this.getConfiguredSecretsVal(config);
const hasSystemSecret = !!configuredSecretsVal;

return {
Expand Down
3 changes: 3 additions & 0 deletions packages/global/core/app/tool/systemTool/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ export enum SystemToolSystemSecretStatusEnum {
configured = 'configured',
unconfigured = 'unconfigured'
}

/** 管理员配置页使用的 write-only 标记,表示该字段已有系统密钥但不回显密文。 */
export const SystemToolSecretMaskedValue = '__FASTGPT_SYSTEM_SECRET_MASKED__';
32 changes: 20 additions & 12 deletions packages/service/core/app/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }

// Format header secret
node.inputs.forEach((input) => {
if (
input.key === NodeInputKeyEnum.systemInputConfig &&
typeof input.value === 'object' &&
input.value !== null
) {
if (input.value?.type !== SystemToolSecretInputTypeEnum.manual) {
// system/team 只保存来源类型,禁止把临时密钥值带入应用节点。
delete input.value.value;
} else {
const secretValues = input.value.value;
if (secretValues && typeof secretValues === 'object' && !Array.isArray(secretValues)) {
input.inputList?.forEach((inputItem) => {
if (inputItem.inputType === 'secret') {
secretValues[inputItem.key] = encryptSecretValue(secretValues[inputItem.key]);
}
});
}
}
}

if (nodeInputIsReference(input)) return;

// 敏感信息
Expand All @@ -88,18 +108,6 @@ export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }
if (input.renderTypeList?.includes(FlowNodeInputTypeEnum.password)) {
input.value = encryptSecretValue(input.value);
}
if (input.key === NodeInputKeyEnum.systemInputConfig && typeof input.value === 'object') {
input.inputList?.forEach((inputItem) => {
if (
inputItem.inputType === 'secret' &&
input.value?.type === SystemToolSecretInputTypeEnum.manual &&
input.value?.value
) {
input.value.value[inputItem.key] = encryptSecretValue(input.value.value[inputItem.key]);
}
});
}

// 知识库
if (isDatasetNode) {
// Agent
Expand Down
138 changes: 138 additions & 0 deletions packages/service/core/app/tool/systemTool/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { SystemToolSecretMaskedValue } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { type InputConfigType } from '@fastgpt/global/core/workflow/type/io';
import { decryptSecret, encryptSecret } from '../../../../common/secret/aes256gcm';

type SecretsVal = Record<string, unknown>;

type SecretValueLike = {
secret?: unknown;
value?: unknown;
};

const isMaskedValue = (value: unknown) => value === SystemToolSecretMaskedValue;

const isSecretValueLike = (value: unknown): value is SecretValueLike => {
return typeof value === 'object' && value !== null && ('secret' in value || 'value' in value);
};

const isConfiguredValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return false;
if (isMaskedValue(value)) return true;

if (isSecretValueLike(value)) {
return (value.value !== undefined && value.value !== '') || !!value.secret;
}

return true;
};

const isEncryptedValue = (value: unknown): value is { secret: string; value?: string } => {
return isSecretValueLike(value) && typeof value.secret === 'string' && value.secret.length > 0;
};

const encryptValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return value;
if (isEncryptedValue(value)) return value;

if (isSecretValueLike(value)) {
if (typeof value.value !== 'string' || value.value === '') return '';
return {
secret: encryptSecret(value.value),
value: ''
};
}

return {
secret: encryptSecret(String(value)),
value: ''
};
};

const decryptValue = (value: unknown) => {
if (!isSecretValueLike(value)) return value;

if (typeof value.value === 'string' && value.value !== '') return value.value;
if (!isEncryptedValue(value)) return value.value ?? '';

try {
return decryptSecret(value.secret);
} catch {
// 兼容历史非 AES 密钥结构,避免一次坏字段导致整个工具无法运行。
return value.value ?? '';
}
};

/** 从插件 secret schema 生成需要加密的字段集合。 */
export const getSystemToolSecretKeys = (inputList?: InputConfigType[]) =>
new Set((inputList ?? []).filter((item) => item.inputType === 'secret').map((item) => item.key));

/** 将管理员提交的系统密钥字段加密,并保留未编辑的已有字段。 */
export const encryptSystemToolSecrets = ({
secretsVal,
existingSecretsVal,
secretKeys
}: {
secretsVal?: SecretsVal | null;
existingSecretsVal?: SecretsVal;
secretKeys: Set<string>;
}) => {
if (secretsVal === null || secretsVal === undefined) return secretsVal;

const result: SecretsVal = { ...(existingSecretsVal ?? {}) };

Object.entries(secretsVal).forEach(([key, value]) => {
if (!secretKeys.has(key)) {
result[key] = value;
return;
}

if (isMaskedValue(value)) {
const existingValue = existingSecretsVal?.[key];
if (isConfiguredValue(existingValue)) {
result[key] = encryptValue(existingValue);
} else {
delete result[key];
}
return;
}

if (value === undefined || value === null || value === '') {
delete result[key];
return;
}

result[key] = encryptValue(value);
});

return result;
};

/** 管理员详情只返回已配置标记,禁止把系统密钥明文或密文回传给前端。 */
export const maskSystemToolSecrets = ({
secretsVal,
secretKeys
}: {
secretsVal?: SecretsVal;
secretKeys: Set<string>;
}) => {
if (!secretsVal) return secretsVal;

return Object.fromEntries(
Object.entries(secretsVal).map(([key, value]) => [
key,
secretKeys.has(key) && isConfiguredValue(value) ? SystemToolSecretMaskedValue : value
])
);
};

/** runtime 使用的系统密钥值,兼容旧明文并解密新格式。 */
export const decryptSystemToolSecrets = (secretsVal?: SecretsVal) => {
if (!secretsVal) return secretsVal;

return Object.fromEntries(
Object.entries(secretsVal).flatMap(([key, value]) => {
if (isMaskedValue(value)) return [];
return [[key, decryptValue(value)]];
})
);
};
44 changes: 37 additions & 7 deletions packages/service/core/app/tool/systemTool/systemTool.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ import type { AppToolRuntimeType } from '@fastgpt/global/core/app/tool/type';
import type { PluginPermissionEnumType } from '@fastgpt/global/sdk/fastgpt-plugin';
import { Types } from '../../../../common/mongo';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import {
decryptSystemToolSecrets,
getSystemToolSecretKeys,
maskSystemToolSecrets
} from './secrets';

type SystemToolRuntimeType = {
id: string;
Expand Down Expand Up @@ -314,7 +319,8 @@ export class SystemToolRepo {
const item = SystemToolCodec.attachToolConfig({
tool,
config: getFirstSystemToolConfig(DBPluginsMap, tool.pluginId),
lang
lang,
source: tool.source
});

return {
Expand Down Expand Up @@ -345,13 +351,15 @@ export class SystemToolRepo {
version,
source: toolSource = 'system',
lang,
fallbackLatestVersion = false
fallbackLatestVersion = false,
maskSecrets = false
}: {
pluginId: string;
version?: string;
source?: string;
lang?: `${LangEnum}`;
fallbackLatestVersion?: boolean;
maskSecrets?: boolean;
}): Promise<SystemToolDetailType> => {
const isDebugSource = isDebugToolSource(toolSource);
const { pluginId: rawPluginId, source: idSource } = parseSystemToolId({
Expand Down Expand Up @@ -479,8 +487,24 @@ export class SystemToolRepo {
childPluginId ? child!.outputSchema : tool.outputSchema
);
const secrets = jsonSchema2SecretInput({ jsonSchema: secretSchema });
const configuredSecretsVal = SystemToolCodec.getConfiguredSecretsVal(dbTool);
const hasSystemSecret = !!configuredSecretsVal;
const secretKeys = getSystemToolSecretKeys(secrets);
const parentDbTool = await getParentSystemToolConfig({
pluginId,
idSource,
parentPluginId
});
const secretConfig = parentDbTool ?? dbTool;
const configuredSecretsVal = isDebugSource
? undefined
: SystemToolCodec.getConfiguredSecretsVal(secretConfig);
const hasSystemSecret = !isDebugSource && !!configuredSecretsVal;
const visibleSecretsVal =
maskSecrets && !isDebugSource
? maskSystemToolSecrets({
secretsVal: configuredSecretsVal,
secretKeys
})
: configuredSecretsVal;

const toolDetail: SystemToolDetailType = {
id: pluginId,
Expand All @@ -492,7 +516,7 @@ export class SystemToolRepo {
hasSecret: !!secrets?.length,
hasSystemSecret
}),
secretsVal: configuredSecretsVal,
secretsVal: visibleSecretsVal,
hasTokenFee: dbTool?.hasTokenFee ?? false,
intro:
dbTool?.customConfig?.intro ??
Expand Down Expand Up @@ -786,7 +810,11 @@ export class SystemToolRepo {
version: tool.version,
currentCost: dbTool?.currentCost ?? 0,
systemKeyCost: dbTool?.systemKeyCost ?? 0,
secretsVal: isDebugSource ? undefined : SystemToolCodec.getConfiguredSecretsVal(dbTool),
secretsVal: isDebugSource
? undefined
: decryptSystemToolSecrets(
SystemToolCodec.getConfiguredSecretsVal(parentDbTool ?? dbTool)
),
permissions: tool.permission
};
}
Expand All @@ -796,7 +824,9 @@ export class SystemToolRepo {
version,
currentCost: dbTool.currentCost ?? 0,
systemKeyCost: dbTool.systemKeyCost ?? 0,
secretsVal: SystemToolCodec.getConfiguredSecretsVal(dbTool)
secretsVal: isDebugSource
? undefined
: decryptSystemToolSecrets(SystemToolCodec.getConfiguredSecretsVal(parentDbTool ?? dbTool))
};
};

Expand Down
78 changes: 78 additions & 0 deletions packages/service/test/core/app/tool/systemTool/secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { SystemToolSecretMaskedValue } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { decryptSecret, encryptSecret } from '@fastgpt/service/common/secret/aes256gcm';
import {
decryptSystemToolSecrets,
encryptSystemToolSecrets,
getSystemToolSecretKeys,
maskSystemToolSecrets
} from '@fastgpt/service/core/app/tool/systemTool/secrets';

const secretKeys = new Set(['apiKey']);

describe('system tool secrets', () => {
it('encrypts new secret values and leaves non-secret config values unchanged', () => {
const stored = encryptSystemToolSecrets({
secretsVal: { apiKey: 'new-api-key', region: 'us' },
secretKeys
});

expect(stored).toMatchObject({
apiKey: { value: '' },
region: 'us'
});
expect(stored.apiKey).not.toBe('new-api-key');
expect(decryptSystemToolSecrets(stored)).toEqual({
apiKey: 'new-api-key',
region: 'us'
});
});

it('preserves an unedited masked value and migrates legacy plaintext', () => {
const stored = encryptSystemToolSecrets({
secretsVal: { apiKey: SystemToolSecretMaskedValue },
existingSecretsVal: { apiKey: 'legacy-api-key' },
secretKeys
});

expect(stored.apiKey).toMatchObject({ value: '' });
expect(decryptSecret((stored.apiKey as { secret: string }).secret)).toBe('legacy-api-key');
});

it('supports legacy secret wrappers and clears an edited empty value', () => {
const stored = encryptSystemToolSecrets({
secretsVal: { apiKey: '' },
existingSecretsVal: { apiKey: { value: 'legacy-api-key', secret: '' } },
secretKeys
});

expect(stored).toEqual({});
expect(decryptSystemToolSecrets({ apiKey: { value: 'legacy-api-key', secret: '' } })).toEqual({
apiKey: 'legacy-api-key'
});
});

it('masks only configured secret schema fields', () => {
const masked = maskSystemToolSecrets({
secretsVal: {
apiKey: { secret: encryptSecret('api-key'), value: '' },
region: 'us'
},
secretKeys
});

expect(masked).toEqual({
apiKey: SystemToolSecretMaskedValue,
region: 'us'
});
});

it('derives secret keys from secret input schema entries', () => {
expect(
getSystemToolSecretKeys([
{ key: 'apiKey', label: 'API key', inputType: 'secret' },
{ key: 'region', label: 'Region', inputType: 'select' }
])
).toEqual(new Set(['apiKey']));
});
});
Loading
Loading