Skip to content

Commit 63d8d7f

Browse files
authored
refactor: replace str.format with safe_jinja2/safe_str_format (#2315)
1 parent afff62a commit 63d8d7f

9 files changed

Lines changed: 138 additions & 23 deletions

File tree

apiserver/paasng/paasng/accessories/servicehub/remote/manager.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from paasng.platform.applications.models import Application, ApplicationEnvironment, ModuleEnvironment
7171
from paasng.platform.engine.models import EngineApp
7272
from paasng.platform.modules.models import Module
73+
from paasng.utils import safe_jinja2
7374

7475
if TYPE_CHECKING:
7576
import datetime
@@ -330,14 +331,12 @@ def get_instance(self) -> ServiceInstanceObj:
330331
)
331332

332333
def render_params(self, params_tmpl: Dict) -> Dict:
333-
""" "Render params dict by current rel's context, Available keys:
334+
"""Render params dict by current rel's context, Available keys:
334335
335-
Database objects:
336-
337-
- `engine_app`: current EngineApp object
338-
- `application`: current Application object
339-
- `module`: current Module object
340-
- `env`: current ModuleEnvironment object
336+
- `application/module/env/engine_app`: current object(s) to provision.
337+
- `cluster_info`: The cluster information of the current env.
338+
- `app_developers`: The application's developers.
339+
- `bk_monitor_space_id`: The space id of the bkmonitor space, if exists.
341340
"""
342341
result = {}
343342
cluster_info = EnvClusterInfo(self.db_env)
@@ -352,7 +351,8 @@ def render_params(self, params_tmpl: Dict) -> Dict:
352351
bk_monitor_space_id = space.space_uid
353352

354353
for key, tmpl_str in params_tmpl.items():
355-
result[key] = tmpl_str.format(
354+
# `str.format()` was previously used. It has been changed to use jinja2 for better security.
355+
result[key] = safe_jinja2.StrFormatCompatTemplate(tmpl_str).render(
356356
engine_app=self.db_engine_app,
357357
application=self.db_application,
358358
module=self.db_module,

apiserver/paasng/paasng/accessories/smart_advisor/utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from paasng.accessories.smart_advisor.advisor import DocumentaryLinkAdvisor
2626
from paasng.accessories.smart_advisor.tagging import get_deployment_tags
2727
from paasng.platform.engine.models.deployment import Deployment
28+
from paasng.utils.text import basic_str_format
2829

2930

3031
@dataclass
@@ -39,7 +40,7 @@ def render_links(self, params: Dict[str, Any]):
3940
"""Render helper's "link" field with given params"""
4041
for helper in self.helpers:
4142
if "link" in helper:
42-
helper["link"] = helper["link"].format(**params)
43+
helper["link"] = basic_str_format(helper["link"], params)
4344

4445

4546
def get_default_failure_hint() -> DeploymentFailureHint:

apiserver/paasng/paasng/misc/tools/serializers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,11 @@ def validate(self, value):
169169
return value
170170

171171

172-
class PackageStashRequestSLZ(serializers.Serializer):
172+
class ToolPackageStashInputSLZ(serializers.Serializer):
173173
"""Handle package for S-mart build"""
174174

175175
package = serializers.FileField(help_text="待构建的应用源码包")
176176

177177

178-
class PackageStashResponseSLZ(serializers.Serializer):
178+
class ToolPackageStashOutputSLZ(serializers.Serializer):
179179
signature = serializers.CharField(help_text="数字签名")

apiserver/paasng/paasng/misc/tools/views.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from paasng.utils.yaml import IndentDumper
3939

4040
from .app_desc import transform_app_desc_spec2_to_spec3
41-
from .serializers import AppDescSpec2Serializer, PackageStashRequestSLZ, PackageStashResponseSLZ
41+
from .serializers import AppDescSpec2Serializer, ToolPackageStashInputSLZ, ToolPackageStashOutputSLZ
4242

4343

4444
class AppDescTransformAPIView(APIView):
@@ -87,13 +87,13 @@ class SMartBuilderViewSet(viewsets.ViewSet):
8787
permission_classes = [IsAuthenticated]
8888

8989
@swagger_auto_schema(
90-
request_body=PackageStashRequestSLZ,
91-
response_serializer=PackageStashResponseSLZ,
90+
request_body=ToolPackageStashInputSLZ,
91+
response_serializer=ToolPackageStashOutputSLZ,
9292
tags=["S-Mart 包构建"],
9393
)
9494
def upload(self, request):
9595
"""上传一个待构建的源码包,校验通过后将其暂存起来"""
96-
slz = PackageStashRequestSLZ(data=request.data)
96+
slz = ToolPackageStashInputSLZ(data=request.data)
9797
slz.is_valid(raise_exception=True)
9898

9999
with generate_temp_dir() as tmp_dir:
@@ -118,7 +118,7 @@ def upload(self, request):
118118
# Store as prepared package for later build
119119
PreparedSourcePackage(request, namespace=self._get_store_namespace(app_desc.code)).store(filepath)
120120

121-
return Response(PackageStashResponseSLZ({"signature": stat.sha256_signature}).data)
121+
return Response(ToolPackageStashOutputSLZ({"signature": stat.sha256_signature}).data)
122122

123123
@staticmethod
124124
def _validate_app_desc(app_desc: ApplicationDesc):

apiserver/paasng/paasng/plat_admin/numbers/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from paasng.platform.sourcectl.repo_controller import BaseGitRepoController
5353
from paasng.platform.sourcectl.source_types import get_sourcectl_names, get_sourcectl_type
5454
from paasng.platform.sourcectl.svn.server_config import get_bksvn_config
55+
from paasng.utils.text import basic_str_format
5556

5657
try:
5758
from paasng.infras.legacydb_te.models import LApplication, LApplicationUseRecord
@@ -212,7 +213,9 @@ def get_tag_display_name(app: Application):
212213
def get_market_address(application: Application) -> Optional[str]:
213214
"""获取市场访问地址,兼容精简版应用。普通应用如未打开市场开关,返回 None"""
214215
region = get_region(application.region)
215-
addr = region.basic_info.link_production_app.format(code=application.code, region=application.region)
216+
addr = basic_str_format(
217+
region.basic_info.link_production_app, {"code": application.code, "region": application.region}
218+
)
216219
if not application.engine_enabled:
217220
return addr
218221

apiserver/paasng/paasng/platform/engine/views/configvar.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,9 +396,14 @@ class ConflictedConfigVarsViewSet(viewsets.ViewSet, ApplicationCodeInPathMixin):
396396

397397
permission_classes = [IsAuthenticated, application_perm_class(AppAction.BASIC_DEVELOP)]
398398

399+
@swagger_auto_schema(
400+
responses={200: ConflictedKeyOutputSLZ(many=True)},
401+
)
399402
def get_user_conflicted_keys(self, request, code, module_name):
400-
"""获取当前模块中有冲突的环境变量 Key 列表,“冲突”指用户自定义变量与平台内置变量同名。
401-
不同类型的应用,平台处理冲突变量的行为有所不同,本接口返回的 key 列表主要作引导和提示用。
403+
"""获取当前模块中有冲突的环境变量 Key 列表
404+
405+
“冲突”指用户自定义变量与平台内置变量同名。 不同类型的应用,平台处理冲突变量的行为有所不同,
406+
本接口返回的 key 列表主要作引导和提示用。
402407
403408
客户端展示建议:
404409

apiserver/paasng/paasng/utils/safe_jinja2.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,19 @@
1919

2020
import os
2121
import typing as t
22+
from functools import partial
2223

2324
import jinja2
2425
from jinja2.sandbox import SandboxedEnvironment
2526

2627

28+
class NoCallableSandboxedEnvironment(SandboxedEnvironment):
29+
"""A SandboxedEnvironment that does not allow any function calls."""
30+
31+
def is_safe_callable(self, obj: t.Any) -> bool:
32+
return False
33+
34+
2735
def _get_file_environment(
2836
searchpath: t.Union[str, "os.PathLike[str]", t.Sequence[t.Union[str, "os.PathLike[str]"]]],
2937
trim_blocks: bool = False,
@@ -34,13 +42,24 @@ def _get_file_environment(
3442
FileEnvironment = _get_file_environment
3543

3644

37-
_default_env = SandboxedEnvironment()
45+
_default_env = NoCallableSandboxedEnvironment()
3846

3947

40-
def _safe_template(source, *args, **kwargs):
48+
def _safe_template(source, env_cls=NoCallableSandboxedEnvironment, *args, **kwargs):
4149
if not args and not kwargs:
4250
return _default_env.from_string(source)
43-
return SandboxedEnvironment(*args, **kwargs).from_string(source)
51+
return env_cls(*args, **kwargs).from_string(source)
4452

4553

4654
Template = _safe_template
55+
56+
57+
# StrFormatCompatTemplate is a Template that uses a single brace `{}` as delimiters, which is
58+
# compatible with str.format() and can be used as a replacement when the template is untrusted.
59+
StrFormatCompatTemplate = partial(
60+
_safe_template,
61+
# Disable any function/method calls because supporting `obj.delete()` can be dangerous.
62+
env_cls=NoCallableSandboxedEnvironment,
63+
variable_start_string="{",
64+
variable_end_string="}",
65+
)

apiserver/paasng/paasng/utils/text.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import random
1919
import re
20+
import string
2021
import uuid
2122
from typing import Collection
2223

@@ -124,3 +125,42 @@ def calculate_percentage(x: float, y: float, decimal_places: int = 2) -> str:
124125
# 否则,将结果转换为百分制,并保留指定位数的小数
125126
else:
126127
return "{:.{decimal_places}%}".format(result, decimal_places=decimal_places)
128+
129+
130+
class BraceOnlyTemplate(string.Template):
131+
"""A template class similar to `string.Template`, but use `{` as the delimiter and
132+
only supports single-braces formatted variables: `{<name>}`.
133+
134+
- Use '{{' to escape a single brace
135+
- Only '{' need to be escaped, '}' is treated as a normal character
136+
"""
137+
138+
# Override the delimiter property because `string.Template` uses it
139+
delimiter = "{"
140+
141+
delim = delimiter
142+
# The identifier is copied from `string.Template`
143+
id = r"(?a:[_a-z][_a-z0-9]*)"
144+
145+
# "named" and "braced" patterns are modified
146+
pattern = rf"""
147+
{delim}(?:
148+
(?P<escaped>{delim}) | # Escape sequence of two delimiters
149+
(?P<named>{id})}} | # delimiter and a Python identifier, **modified to ends with a bracket**
150+
(?P<braced>\b\B) | # delimiter and a braced identifier, **modified to never match anything**
151+
(?P<invalid>) # Other ill-formed delimiter exprs
152+
)
153+
""" # type: ignore
154+
155+
156+
def basic_str_format(template: str, context: dict[str, str]) -> str:
157+
"""This function is similar to `str.format`, but only support basic string substitution,
158+
features such as attribute access and slicing are not supported.
159+
160+
It's recommended to use this function when the template is untrusted.
161+
162+
:param template: The template string.
163+
:param context: The context dictionary.
164+
:return: The formatted string.
165+
"""
166+
return BraceOnlyTemplate(template).substitute(**context)

apiserver/paasng/tests/paasng/test_utils/test_text.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@
1717

1818
import pytest
1919

20-
from paasng.utils.text import calculate_percentage, camel_to_snake, remove_suffix, strip_html_tags
20+
from paasng.utils.text import (
21+
BraceOnlyTemplate,
22+
basic_str_format,
23+
calculate_percentage,
24+
camel_to_snake,
25+
remove_suffix,
26+
strip_html_tags,
27+
)
2128

2229

2330
class TestStripHTMLTags:
@@ -101,3 +108,43 @@ def test_calculate_percentage(x, y, decimal_places, expected):
101108
else:
102109
# 否则,应该返回期望值
103110
assert calculate_percentage(x, y, decimal_places) == expected
111+
112+
113+
class TestBraceOnlyTemplate:
114+
@pytest.mark.parametrize(
115+
("template_str", "kwargs", "expected"),
116+
[
117+
# Basic substitution
118+
("Hello {name}, welcome to {place}!", {"name": "foo", "place": "bar"}, "Hello foo, welcome to bar!"),
119+
# Variable inside word
120+
("prefix_{var}_suffix", {"var": "test"}, "prefix_test_suffix"),
121+
# Underscore variable
122+
("{underscore_var}", {"underscore_var": "value"}, "value"),
123+
],
124+
)
125+
def test_various_valid_patterns(self, template_str, kwargs, expected):
126+
template = BraceOnlyTemplate(template_str)
127+
assert template.substitute(**kwargs) == expected
128+
129+
def test_escaped_braces(self):
130+
# Only "{" needs to be escaped
131+
template = BraceOnlyTemplate("Use {{ to escape braces, like {{name}")
132+
assert template.substitute(name="foo") == "Use { to escape braces, like {name}"
133+
134+
def test_no_substitution_needed(self):
135+
template = BraceOnlyTemplate("No variables here!")
136+
assert template.substitute() == "No variables here!"
137+
138+
def test_missing_variable_raises_error(self):
139+
template = BraceOnlyTemplate("Hello {name}!")
140+
with pytest.raises(KeyError):
141+
template.substitute()
142+
143+
144+
class Test__basic_str_format:
145+
def test_basic(self):
146+
assert basic_str_format("Hello {name}!", {"name": "foo"}) == "Hello foo!"
147+
148+
def test_index_access_should_fail(self):
149+
with pytest.raises(ValueError, match="Invalid placeholder .*"):
150+
basic_str_format("Hello {names[0]}!", {"names": "foobar"})

0 commit comments

Comments
 (0)