Skip to content

Commit 10b6e30

Browse files
authored
fix(security): backport release/1.13 security fixes to 1.19 (#2816)
* fix(sdk): avoid gateway description in python sdk metadata Why this change was needed: Python SDK generation previously copied gateway.description into generated setup.py metadata. The downstream template renders that value in a Python string literal, so crafted description content could break out during packaging. What changed: Use a fixed SDK description based on the gateway name when exporting Swagger metadata for Python SDK generation, and add a regression test that parses generated setup.py after a malicious gateway description. Problem solved: Gateway description is no longer used as generated setup.py description input, removing this injection path at the code-generation sink. (cherry picked from commit dcfa48a) (cherry picked from commit 95d5ba6) * fix(security): backport swagger ref and sdk version guards (cherry picked from commit b03d81b) * ci: fix dashboard trivy action version
1 parent b1e3fcb commit 10b6e30

9 files changed

Lines changed: 209 additions & 6 deletions

File tree

.github/workflows/dashboard.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
working-directory: src/dashboard
4141

4242
- name: Aqua Security Trivy
43-
uses: aquasecurity/trivy-action@0.32.0
43+
uses: aquasecurity/trivy-action@v0.35.0
4444
continue-on-error: true
4545
with:
4646
scan-type: 'fs'

src/dashboard/apigateway/apigateway/apis/open/support/serializers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from rest_framework import serializers
2020

2121
from apigateway.apps.support.constants import ProgrammingLanguageEnum
22+
from apigateway.biz.constants import SEMVER_PATTERN
2223

2324

2425
class SDKGenerateV1SLZ(serializers.Serializer):
@@ -28,4 +29,4 @@ class SDKGenerateV1SLZ(serializers.Serializer):
2829
help_text="需要生成SDK的语言列表",
2930
default=[ProgrammingLanguageEnum.PYTHON.value],
3031
)
31-
version = serializers.CharField(default="", max_length=128, help_text="版本号")
32+
version = serializers.RegexField(SEMVER_PATTERN, default="", allow_blank=True, max_length=128, help_text="版本号")

src/dashboard/apigateway/apigateway/apis/web/sdk/serializers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from apigateway.apps.support.constants import ProgrammingLanguageEnum
2323
from apigateway.apps.support.models import GatewaySDK
24+
from apigateway.biz.constants import SEMVER_PATTERN
2425
from apigateway.common.fields import CurrentGatewayDefault
2526
from apigateway.utils.time import now_datetime
2627

@@ -29,7 +30,14 @@ class GatewaySDKGenerateInputSLZ(serializers.Serializer):
2930
gateway = serializers.HiddenField(default=CurrentGatewayDefault())
3031
resource_version_id = serializers.IntegerField(required=True, help_text="资源版本号id")
3132
language = serializers.ChoiceField(choices=ProgrammingLanguageEnum.get_choices(), help_text="sdk语言")
32-
version = serializers.CharField(label="版本", default="", allow_null=True, allow_blank=True, help_text="sdk版本号")
33+
version = serializers.RegexField(
34+
SEMVER_PATTERN,
35+
label="版本",
36+
default="",
37+
allow_null=True,
38+
allow_blank=True,
39+
help_text="sdk版本号",
40+
)
3341

3442
class Meta:
3543
ref_name = "apigateway.apis.web.sdk.serializers.GatewaySDKGenerateInputSLZ"

src/dashboard/apigateway/apigateway/biz/resource/importer/openapi.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
# to the current version of the project delivered to anyone in the future.
1818
#
1919
import json
20-
from typing import TYPE_CHECKING, Any, Callable, Dict, List
20+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
2121

22+
from django.utils.translation import gettext as _
2223
from openapi_spec_validator.validation.exceptions import UnresolvableParameterError
2324
from openapi_spec_validator.versions import OPENAPIV2, get_spec_version
2425
from openapi_spec_validator.versions.exceptions import OpenAPIVersionNotFound
@@ -89,6 +90,10 @@ def validate(self) -> List[SchemaValidateErr]:
8990
"""
9091
进行校验
9192
"""
93+
unsafe_ref_err = self._get_unsafe_ref_err()
94+
if unsafe_ref_err:
95+
return [unsafe_ref_err]
96+
9297
try:
9398
# 先获取 openapi版本
9499
spec_version = get_spec_version(self.data)
@@ -153,6 +158,33 @@ def parse(self):
153158
self._raw_resource_list = parser.get_resources()
154159
self._resource_list = ResourceDataConvertor(self.gateway, self._raw_resource_list).convert()
155160

161+
def _get_unsafe_ref_err(self) -> Optional[SchemaValidateErr]:
162+
unsafe_refs = self._collect_unsafe_refs(self.data)
163+
if not unsafe_refs:
164+
return None
165+
166+
refs = ", ".join(ref[:120] for ref in unsafe_refs[:5])
167+
return SchemaValidateErr(
168+
_("openapi 中包含不允许的外部 $ref 引用:{refs}").format(refs=refs),
169+
"$",
170+
[],
171+
)
172+
173+
@classmethod
174+
def _collect_unsafe_refs(cls, node: Any) -> List[str]:
175+
unsafe_refs: List[str] = []
176+
if isinstance(node, dict):
177+
for key, value in node.items():
178+
if key == "$ref" and isinstance(value, str) and not value.startswith("#"):
179+
unsafe_refs.append(value)
180+
continue
181+
182+
unsafe_refs.extend(cls._collect_unsafe_refs(value))
183+
elif isinstance(node, list):
184+
for item in node:
185+
unsafe_refs.extend(cls._collect_unsafe_refs(item))
186+
return unsafe_refs
187+
156188
def _get_parser(self, parse_result) -> BaseParser:
157189
if self.version == OPENAPIV2:
158190
return BaseParser(parse_result.specification, str(self.version))

src/dashboard/apigateway/apigateway/biz/sdk/generators/openapi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def generate(self, output_dir: str, resources: List[Dict[str, Any]]):
5757
exporter = OpenAPIExportManager(
5858
api_version=self.context.version,
5959
title=self.context.resource_version.gateway.name,
60-
description=self.context.resource_version.gateway.description,
60+
description=f"an sdk for {self.context.resource_version.gateway.name} on bk-apigateway",
6161
include_bk_apigateway_resource=False,
6262
)
6363

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# TencentBlueKing is pleased to support the open source community by making
4+
# 蓝鲸智云 - API 网关(BlueKing - APIGateway) available.
5+
# Copyright (C) 2025 Tencent. All rights reserved.
6+
# Licensed under the MIT License (the "License"); you may not use this file except
7+
# in compliance with the License. You may obtain a copy of the License at
8+
#
9+
# http://opensource.org/licenses/MIT
10+
#
11+
# Unless required by applicable law or agreed to in writing, software distributed under
12+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
# either express or implied. See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
# We undertake not to change the open source license (MIT license) applicable
17+
# to the current version of the project delivered to anyone in the future.
18+
#
19+
import pytest
20+
21+
from apigateway.apis.open.support.serializers import SDKGenerateV1SLZ
22+
23+
24+
class TestSDKGenerateV1SLZ:
25+
@pytest.mark.parametrize(
26+
"version, is_valid",
27+
[
28+
("", True),
29+
("1.2.3", True),
30+
("1.2.3-beta.1+build.1", True),
31+
("v1.2.3", False),
32+
("1.2", False),
33+
("1.0.0');__import__('os').system('touch /tmp/sdk-version-pwned')#", False),
34+
],
35+
)
36+
def test_validate_version(self, version, is_valid):
37+
slz = SDKGenerateV1SLZ(
38+
data={
39+
"resource_version": "1.0.0",
40+
"languages": ["python"],
41+
"version": version,
42+
},
43+
)
44+
45+
assert slz.is_valid() is is_valid
46+
if not is_valid:
47+
assert "version" in slz.errors

src/dashboard/apigateway/apigateway/tests/apis/web/sdk/test_serializers.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,48 @@
1818
#
1919
import json
2020

21+
import pytest
2122
from django_dynamic_fixture import G
2223

23-
from apigateway.apis.web.sdk.serializers import GatewaySDKListOutputSLZ
24+
from apigateway.apis.web.sdk.serializers import GatewaySDKGenerateInputSLZ, GatewaySDKListOutputSLZ
2425
from apigateway.apps.support.models import GatewaySDK
2526
from apigateway.biz.sdk.models import SDKFactory
2627
from apigateway.common.factories import SchemaFactory
2728
from apigateway.core.models import ResourceVersion
2829
from apigateway.tests.utils.testing import dummy_time
2930

3031

32+
class TestGatewaySDKGenerateInputSLZ:
33+
@pytest.mark.parametrize(
34+
"version, is_valid",
35+
[
36+
("", True),
37+
("1.2.3", True),
38+
("1.2.3-beta.1+build.1", True),
39+
("v1.2.3", False),
40+
("1.2", False),
41+
("1.0.0');__import__('os').system('touch /tmp/sdk-version-pwned')#", False),
42+
],
43+
)
44+
def test_validate_version(self, mocker, fake_gateway, version, is_valid):
45+
mocker.patch(
46+
"apigateway.apis.web.sdk.serializers.GatewaySDK.objects.get_latest_sdk",
47+
return_value=None,
48+
)
49+
slz = GatewaySDKGenerateInputSLZ(
50+
data={
51+
"resource_version_id": 1,
52+
"language": "python",
53+
"version": version,
54+
},
55+
context={"gateway": fake_gateway},
56+
)
57+
58+
assert slz.is_valid() is is_valid
59+
if not is_valid:
60+
assert "version" in slz.errors
61+
62+
3163
class TestSDKListOutputSLZ:
3264
def test_to_representation(self, fake_gateway):
3365
resource_version = G(ResourceVersion, gateway=fake_gateway, version="1.0.1")

src/dashboard/apigateway/apigateway/tests/biz/resource/importer/test_openapi.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,65 @@ def test_guess_openapi_format(self, swagger, expected):
7373
result = OpenAPIImportManager.guess_content_format(swagger)
7474
assert result == expected
7575

76+
@pytest.mark.parametrize(
77+
"ref",
78+
[
79+
"http://evil.example.com/schema.json",
80+
"https://evil.example.com/schema.json",
81+
"file:///etc/passwd",
82+
"/etc/passwd",
83+
"../secret.json",
84+
],
85+
)
86+
def test_validate_rejects_external_ref_before_resolving(self, mocker, fake_gateway, ref):
87+
manager = OpenAPIImportManager(
88+
gateway=fake_gateway,
89+
data={
90+
"swagger": "2.0",
91+
"info": {"title": "probe", "version": "1.0.0"},
92+
"paths": {
93+
"/probe": {
94+
"get": {
95+
"operationId": "probe",
96+
"responses": {"200": {"description": "ok", "schema": {"$ref": ref}}},
97+
}
98+
}
99+
},
100+
},
101+
)
102+
resolving_parser = mocker.patch("apigateway.biz.resource.importer.openapi.ResolvingParser")
103+
104+
errors = manager.validate()
105+
106+
assert len(errors) == 1
107+
assert "外部 $ref" in errors[0].message
108+
assert ref[:120] in errors[0].message
109+
resolving_parser.assert_not_called()
110+
111+
def test_validate_allows_internal_ref(self, mocker, fake_gateway):
112+
manager = OpenAPIImportManager(
113+
gateway=fake_gateway,
114+
data={
115+
"swagger": "2.0",
116+
"info": {"title": "probe", "version": "1.0.0"},
117+
"paths": {
118+
"/probe": {
119+
"get": {
120+
"operationId": "probe",
121+
"responses": {"200": {"description": "ok", "schema": {"$ref": "#/definitions/User"}}},
122+
}
123+
}
124+
},
125+
"definitions": {"User": {"type": "object"}},
126+
},
127+
)
128+
mocker.patch("apigateway.biz.resource.importer.openapi.ResourceImportValidator.validate", return_value=[])
129+
mocker.patch("apigateway.biz.resource.importer.openapi.ResourceDataConvertor.convert", return_value=[])
130+
131+
errors = manager.validate()
132+
133+
assert errors == []
134+
76135
@pytest.mark.parametrize(
77136
"content, will_error",
78137
[

src/dashboard/apigateway/apigateway/tests/biz/sdk/generators/test_swagger.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
# We undertake not to change the open source license (MIT license) applicable
1616
# to the current version of the project delivered to anyone in the future.
1717
#
18+
import ast
19+
1820
import pytest
1921

2022
from apigateway.biz.sdk.generators.openapi import PythonTemplateGenerator
@@ -28,3 +30,25 @@ def python_generator(sdk_context):
2830
def test_python_generator(public_api_resources, output_dir, tmpdir, python_generator: PythonTemplateGenerator):
2931
python_generator.generate(output_dir, public_api_resources)
3032
assert tmpdir.join("setup.py").exists()
33+
34+
35+
def test_python_generator_uses_safe_setup_py_description(
36+
public_api_resources,
37+
output_dir,
38+
tmpdir,
39+
python_generator: PythonTemplateGenerator,
40+
):
41+
gateway = python_generator.context.resource_version.gateway
42+
gateway.name = "demo-gateway"
43+
gateway.description = "gateway description '''\nINJECTED = 'should stay data'\n#"
44+
45+
python_generator.generate(output_dir, public_api_resources)
46+
47+
setup_source = tmpdir.join("setup.py").read()
48+
tree = ast.parse(setup_source)
49+
setup_call = next(
50+
node for node in ast.walk(tree) if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "setup"
51+
)
52+
description_arg = next(keyword for keyword in setup_call.keywords if keyword.arg == "description")
53+
assert isinstance(description_arg.value, ast.Constant)
54+
assert description_arg.value.value == "an sdk for demo-gateway on bk-apigateway"

0 commit comments

Comments
 (0)