Commit 1d7b8768 by Archer Committed by GitHub

fix: guard OpenAPI schema refs during HTTP tool import (#7073)

parent 54a53e7d
/* v8 ignore file */
import SwaggerParser from '@apidevtools/swagger-parser';
import yaml from 'js-yaml';
export const loadOpenAPISchemaFromUrl = async (url: string) => {
return SwaggerParser.bundle(url);
/**
* 解析用户导入的 OpenAPI 文档字符串。
*
* URL 导入场景会先由服务端受控下载文本,再交给该函数解析,避免把 URL
* 直接传给 SwaggerParser 触发其内置远程 resolver。
*/
export const parseOpenAPISchemaString = (schemaText: string) => {
try {
return JSON.parse(schemaText);
} catch {
return yaml.load(schemaText, { schema: yaml.FAILSAFE_SCHEMA });
}
};
/**
* 仅对已取得的 OpenAPI 对象做本地 bundle。
*
* 禁止 SwaggerParser 的 file/http resolver,防止 OpenAPI URL 导入后继续解析
* 远程 `$ref`,绕过服务端 SSRF 校验。
*/
export const bundleOpenAPISchema = async (schema: unknown) => {
return SwaggerParser.bundle(schema as any, {
resolve: {
file: false,
http: false
}
});
};
......@@ -7,11 +7,11 @@ import {
type FlowNodeOutputItemType
} from '../workflow/type/io';
import SwaggerParser from '@apidevtools/swagger-parser';
import yaml from 'js-yaml';
import type { OpenAPIV3 } from 'openapi-types';
import type { OpenApiJsonSchema } from './tool/httpTool/type';
import { i18nT } from '../../common/i18n/utils';
import z from 'zod';
import { parseOpenAPISchemaString } from '../../common/string/swagger';
export const JsonSchemaPropertiesItemSchema = z.object({
// 基本类型定义
......@@ -193,14 +193,13 @@ export const jsonSchema2NodeOutput = ({
export const str2OpenApiSchema = async (yamlStr = ''): Promise<OpenApiJsonSchema> => {
try {
const data = (() => {
try {
return JSON.parse(yamlStr);
} catch (jsonError) {
return yaml.load(yamlStr, { schema: yaml.FAILSAFE_SCHEMA });
const data = parseOpenAPISchemaString(yamlStr);
const jsonSchema = (await SwaggerParser.dereference(data, {
resolve: {
file: false,
http: false
}
})();
const jsonSchema = (await SwaggerParser.dereference(data)) as OpenAPIV3.Document;
})) as OpenAPIV3.Document;
const serverPath = (() => {
if (jsonSchema.servers && jsonSchema.servers.length > 0) {
......
......@@ -10,6 +10,7 @@ import {
getSchemaValueType,
str2OpenApiSchema
} from '@fastgpt/global/core/app/jsonschema';
import { bundleOpenAPISchema } from '@fastgpt/global/common/string/swagger';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeInputItemTypeSchema } from '@fastgpt/global/core/workflow/type/io';
......@@ -572,6 +573,63 @@ describe('getSchemaValueType', () => {
});
describe('str2OpenApiSchema', () => {
it('should dereference local refs without resolving remote refs', async () => {
const openApiJson = JSON.stringify({
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/users': {
post: {
operationId: 'createUser',
requestBody: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/User'
}
}
}
},
responses: { '200': { description: 'OK' } }
}
}
},
components: {
schemas: {
User: {
type: 'object',
properties: {
name: { type: 'string' }
}
}
}
}
});
const result = await str2OpenApiSchema(openApiJson);
expect(result.pathData[0].request.content['application/json'].schema.properties.name).toEqual({
type: 'string'
});
});
it('should reject remote refs without resolving them', async () => {
const openApiJson = JSON.stringify({
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
Leak: {
$ref: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'
}
}
}
});
await expect(str2OpenApiSchema(openApiJson)).rejects.toBe('common:plugin.Invalid Schema');
});
it('should parse valid OpenAPI 3.0 JSON schema', async () => {
const openApiJson = JSON.stringify({
openapi: '3.0.0',
......@@ -891,3 +949,38 @@ paths:
expect(result.pathData[0].params).toHaveLength(2);
});
});
describe('bundleOpenAPISchema', () => {
it('should bundle local refs from an already loaded schema object', async () => {
const result = await bundleOpenAPISchema({
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
User: { type: 'object', properties: { name: { type: 'string' } } },
Payload: { $ref: '#/components/schemas/User' }
}
}
});
expect(result.components.schemas.Payload).toEqual({ $ref: '#/components/schemas/User' });
});
it('should reject remote refs when bundling an already loaded schema object', async () => {
await expect(
bundleOpenAPISchema({
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
Leak: {
$ref: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'
}
}
}
})
).rejects.toThrow('Unable to resolve $ref pointer');
});
});
import { loadOpenAPISchemaFromUrl } from '@fastgpt/global/common/string/swagger';
import {
bundleOpenAPISchema,
parseOpenAPISchemaString
} from '@fastgpt/global/common/string/swagger';
import { NextAPI } from '@/service/middleware/entry';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { isInternalAddress } from '@fastgpt/service/common/system/utils';
import { axios } from '@fastgpt/service/common/api/axios';
import { checkUrlSafety } from '@fastgpt/service/common/system/utils';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
......@@ -11,7 +15,7 @@ import {
type GetApiSchemaByUrlResponseType
} from '@fastgpt/global/openapi/core/app/httpTools/api';
async function handler(
export async function handler(
req: ApiRequestProps<GetApiSchemaByUrlBodyType>
): Promise<GetApiSchemaByUrlResponseType> {
const {
......@@ -23,11 +27,18 @@ async function handler(
await authCert({ req, authToken: true });
if (await isInternalAddress(url)) {
return Promise.reject('Invalid url');
}
await checkUrlSafety(url, 'OpenAPI Schema URL');
const { data } = await axios.get<string>(url, {
responseType: 'text',
maxRedirects: 0,
timeout: 30000,
transformResponse: (value) => value
});
return GetApiSchemaByUrlResponseSchema.parse(await loadOpenAPISchemaFromUrl(url));
return GetApiSchemaByUrlResponseSchema.parse(
await bundleOpenAPISchema(parseOpenAPISchemaString(data))
);
}
export default NextAPI(handler);
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import type { GetApiSchemaByUrlBodyType } from '@fastgpt/global/openapi/core/app/httpTools/api';
const mocks = vi.hoisted(() => ({
authCert: vi.fn(),
checkUrlSafety: vi.fn(),
axiosGet: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: any) => handler
}));
vi.mock('@fastgpt/service/support/permission/auth/common', () => ({
authCert: mocks.authCert
}));
vi.mock('@fastgpt/service/common/system/utils', () => ({
checkUrlSafety: mocks.checkUrlSafety
}));
vi.mock('@fastgpt/service/common/api/axios', () => ({
axios: {
get: mocks.axiosGet
}
}));
import { handler } from '@/pages/api/core/app/httpTools/getApiSchemaByUrl';
describe('getApiSchemaByUrl handler', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.authCert.mockResolvedValue({ teamId: 'team-1', tmbId: 'tmb-1' });
mocks.checkUrlSafety.mockResolvedValue(undefined);
});
it('downloads schema through guarded axios before bundling', async () => {
mocks.axiosGet.mockResolvedValue({
data: JSON.stringify({
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/users': {
get: {
operationId: 'getUsers',
responses: { '200': { description: 'OK' } }
}
}
}
})
});
const result = await handler({
body: {
url: 'https://example.com/openapi.json'
}
} as ApiRequestProps<GetApiSchemaByUrlBodyType>);
expect(mocks.authCert).toHaveBeenCalledWith({ req: expect.any(Object), authToken: true });
expect(mocks.checkUrlSafety).toHaveBeenCalledWith(
'https://example.com/openapi.json',
'OpenAPI Schema URL'
);
expect(mocks.axiosGet).toHaveBeenCalledWith(
'https://example.com/openapi.json',
expect.objectContaining({
responseType: 'text',
maxRedirects: 0,
timeout: 30000
})
);
expect(result.paths['/users']).toBeDefined();
});
it('rejects external refs from downloaded schemas', async () => {
mocks.axiosGet.mockResolvedValue({
data: JSON.stringify({
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
Leak: {
$ref: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'
}
}
}
})
});
await expect(
handler({
body: {
url: 'https://example.com/openapi.json'
}
} as ApiRequestProps<GetApiSchemaByUrlBodyType>)
).rejects.toThrow('Unable to resolve $ref pointer');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment