PEP 675 – 任意字面量字符串类型
- 作者:
- Pradeep Kumar Srinivasan <gohanpra at gmail.com>, Graham Bleaney <gbleaney at gmail.com>
- 发起人:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 讨论至:
- Typing-SIG 讨论串
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 类型标注
- 创建日期:
- 2021年11月30日
- Python 版本:
- 3.11
- 发布历史:
- 2022年2月7日
- 决议:
- Python-Dev 消息
摘要
目前,无法使用类型注解指定函数参数可以是任何字面量字符串类型。我们必须指定精确的字面量字符串类型,例如 Literal["foo"]
。本 PEP 引入了字面量字符串类型的超类型:LiteralString
。这允许函数接受任意字面量字符串类型,例如 Literal["foo"]
或 Literal["bar"]
。
动机
执行 SQL 或 shell 命令的强大 API 通常建议使用字面量字符串调用它们,而不是任意用户控制的字符串。然而,目前无法在类型系统中表达此建议,这意味着当开发人员未能遵循此建议时,有时会发生安全漏洞。例如,从数据库查找用户记录的一种简单方法是接受用户 ID 并将其插入预定义的 SQL 查询中
def query_user(conn: Connection, user_id: str) -> User:
query = f"SELECT * FROM data WHERE user_id = {user_id}"
conn.execute(query)
... # Transform data to a User object and return it
query_user(conn, "user123") # OK.
然而,用户控制的数据 user_id
与 SQL 命令字符串混合在一起,这意味着恶意用户可以运行任意 SQL 命令
# Delete the table.
query_user(conn, "user123; DROP TABLE data;")
# Fetch all users (since 1 = 1 is always true).
query_user(conn, "user123 OR 1 = 1")
为了防止此类 SQL 注入攻击,SQL API 提供了参数化查询,它将执行的查询与用户控制的数据分开,并使运行任意查询成为不可能。例如,使用 sqlite3,我们原来的函数将安全地编写为带参数的查询
def query_user(conn: Connection, user_id: str) -> User:
query = "SELECT * FROM data WHERE user_id = ?"
conn.execute(query, (user_id,))
...
问题是无法强制执行此规则。sqlite3 自己的 文档 只能告诫读者不要从外部输入动态构建 sql
参数;API 的作者无法通过类型系统表达这一点。用户仍然可以(而且经常)像以前一样使用方便的 f-string,并使他们的代码容易受到 SQL 注入的攻击。
现有工具,例如流行的安全 linter Bandit,试图通过检查 AST 或其他语义模式匹配来检测 SQL API 中使用的不安全外部数据。然而,这些工具排除了常见用法,例如在执行大型多行查询之前将其存储在变量中,根据某些条件向查询添加字面量字符串修饰符,或使用函数转换查询字符串。(我们在 被拒绝的替代方案 部分中调查了现有工具。)例如,许多工具将在以下无害代码片段中检测到误报问题
def query_data(conn: Connection, user_id: str, limit: bool) -> None:
query = """
SELECT
user.name,
user.age
FROM data
WHERE user_id = ?
"""
if limit:
query += " LIMIT 1"
conn.execute(query, (user_id,))
我们希望禁止有害地执行用户控制的数据,同时仍然允许上述良性用法,并且不要求用户进行额外的工作。
为了实现这一目标,我们引入了 LiteralString
类型,它只接受已知由字面量构成的字符串值。这是 PEP 586 中 Literal["foo"]
类型的泛化。类型为 LiteralString
的字符串不能包含用户控制的数据。因此,任何只接受 LiteralString
的 API 都将免受注入漏洞的攻击(具有 实际限制)。
由于我们希望 sqlite3
的 execute
方法禁止使用用户输入构建的字符串,因此我们将使其 typeshed 存根 接受类型为 LiteralString
的 sql
查询
from typing import LiteralString
def execute(self, sql: LiteralString, parameters: Iterable[str] = ...) -> Cursor: ...
这成功地禁止了我们不安全的 SQL 示例。下面的变量 query
被推断为类型 str
,因为它是由使用 user_id
的格式字符串创建的,不能传递给 execute
def query_user(conn: Connection, user_id: str) -> User:
query = f"SELECT * FROM data WHERE user_id = {user_id}"
conn.execute(query) # Error: Expected LiteralString, got str.
...
该方法仍然足够灵活,可以允许我们更复杂的示例
def query_data(conn: Connection, user_id: str, limit: bool) -> None:
# This is a literal string.
query = """
SELECT
user.name,
user.age
FROM data
WHERE user_id = ?
"""
if limit:
# Still has type LiteralString because we added a literal string.
query += " LIMIT 1"
conn.execute(query, (user_id,)) # OK
请注意,用户根本不必更改他们的 SQL 代码。类型检查器能够推断字面量字符串类型,并且只在违反规则时发出警告。
LiteralString
在其他需要严格命令-数据分离的情况下也很有用,例如构建 shell 命令或在不转义的情况下将字符串渲染为 HTML 响应时(请参阅 附录 A:其他用途)。总的来说,这种严格性和灵活性的结合使得在敏感代码中强制执行更安全的 API 使用变得容易,而不会给用户带来负担。
使用统计
在对使用 sqlite3
的开源项目进行抽样调查时,我们发现 conn.execute
在 约 67% 的时间 是使用安全的字符串字面量调用的,而在 约 33% 的时间 是使用潜在不安全的局部字符串变量调用的。使用本 PEP 的字面量字符串类型和类型检查器将防止这 33% 情况中不安全的部分(即用户控制的数据被合并到查询中的情况),同时无缝地允许安全的部分保留。
基本原理
首先,为什么使用 类型 来防止安全漏洞?
在文档中警告用户是不够的——大多数用户要么从未看到这些警告,要么忽略它们。使用现有的动态或静态分析方法过于严格——这些方法会阻止自然用法,正如我们在 动机 部分(并将在 被拒绝的替代方案 部分更广泛地讨论)中看到的那样。本 PEP 中基于类型的方法在严格性和灵活性之间取得了用户友好的平衡。
运行时方法不起作用,因为在运行时,查询字符串是一个普通的 str
。虽然我们可以使用启发式方法(例如,对明显恶意的有效负载进行正则表达式过滤)来防止某些攻击,但总会有办法绕过它们(完美区分好坏查询的问题归结为停机问题)。
静态方法,例如检查 AST 以查看查询字符串是否是字面量字符串表达式,无法分辨字符串何时被分配给中间变量或何时被良性函数转换。这使得它们过于严格。
令人惊讶的是,类型检查器比两者都做得更好,因为它拥有运行时或静态分析方法中无法获得的信息。具体来说,类型检查器可以告诉我们表达式是否具有字面量字符串类型,例如 Literal["foo"]
。类型检查器已经通过变量赋值或函数调用传播类型。
在当前的类型系统中,如果 SQL 或 shell 命令执行函数只接受三种可能的输入字符串,我们的工作就完成了。我们只会说
def execute(query: Literal["foo", "bar", "baz"]) -> None: ...
但是,当然,execute
可以接受 任何 可能的查询。我们如何确保查询不包含任意的、用户控制的字符串?
我们希望指定值必须是某种类型 Literal[<...>]
,其中 <...>
是某个字符串。这就是 LiteralString
所代表的。LiteralString
是所有字面量字符串类型的“超类型”。实际上,本 PEP 只是在 Literal["foo"]
和 str
之间引入了类型层次结构中的一种类型。任何特定的字面量字符串,例如 Literal["foo"]
或 Literal["bar"]
,都与 LiteralString
兼容,反之则不然。LiteralString
本身的“超类型”是 str
。因此,LiteralString
与 str
兼容,反之则不然。
请注意,字面量类型的 Union
自然与 LiteralString
兼容,因为 Union
的每个元素都与 LiteralString
单独兼容。因此,Literal["foo", "bar"]
与 LiteralString
兼容。
然而,请记住,我们不只是想表示精确的字面量查询。我们还希望支持两个字面量字符串的组合,例如 query + " LIMIT 1"
。这也适用于上述概念。如果 x
和 y
是两个类型为 LiteralString
的值,那么 x + y
的类型也将与 LiteralString
兼容。我们可以通过查看特定实例来推断这一点,例如 Literal["foo"]
和 Literal["bar"]
;添加的字符串 x + y
的值只能是 "foobar"
,其类型为 Literal["foobar"]
,因此与 LiteralString
兼容。当 x
和 y
是字面量类型的并集时,同样的推理也适用;分别从 x
和 y
中成对添加任意两个字面量类型的结果是一个字面量类型,这意味着总体结果是字面量类型的 Union
,因此与 LiteralString
兼容。
通过这种方式,我们能够利用 Python 的 Literal
字符串类型概念来指定我们的 API 只能接受已知由字面量构造的字符串。更具体的细节将在其余部分中介绍。
规范
运行时行为
我们建议将 LiteralString
添加到 typing.py
,其实现类似于 typing.NoReturn
。
请注意,LiteralString
是一种仅用于类型检查的特殊形式。在运行时,没有表达式的 type(<expr>)
会产生 LiteralString
。因此,我们没有在实现中指定它是 str
的子类。
LiteralString
的有效位置
LiteralString
可以在任何其他类型可以使用的位置使用
variable_annotation: LiteralString
def my_function(literal_string: LiteralString) -> LiteralString: ...
class Foo:
my_attribute: LiteralString
type_argument: List[LiteralString]
T = TypeVar("T", bound=LiteralString)
它不能嵌套在 Literal
类型的联合中
bad_union: Literal["hello", LiteralString] # Not OK
bad_nesting: Literal[LiteralString] # Not OK
类型推断
推断 LiteralString
任何字面量字符串类型都与 LiteralString
兼容。例如,x: LiteralString = "foo"
是有效的,因为 "foo"
被推断为类型 Literal["foo"]
。
根据 原理,我们还在以下情况下推断 LiteralString
- 加法:如果
x
和y
都与LiteralString
兼容,则x + y
的类型为LiteralString
。 - 连接:如果
sep
的类型与LiteralString
兼容,并且xs
的类型与Iterable[LiteralString]
兼容,则sep.join(xs)
的类型为LiteralString
。 - 就地加法:如果
s
的类型为LiteralString
且x
的类型与LiteralString
兼容,则s += x
会将s
的类型保留为LiteralString
。 - 字符串格式化:f-string 的类型为
LiteralString
当且仅当其组成表达式是字面量字符串。s.format(...)
的类型为LiteralString
当且仅当s
和参数的类型与LiteralString
兼容。 - 字面量保留方法:在 附录 C 中,我们提供了保留
LiteralString
类型的str
方法的详尽列表。
在所有其他情况下,如果一个或多个组合值具有非字面量类型 str
,则类型的组合将具有类型 str
。例如,如果 s
的类型为 str
,则 "hello" + s
的类型为 str
。这与类型检查器的现有行为匹配。
LiteralString
与类型 str
兼容。它继承了 str
的所有方法。因此,如果有一个类型为 LiteralString
的变量 s
,则编写 s.startswith("hello")
是安全的。
有些类型检查器在进行相等性检查时会细化字符串的类型
def foo(s: str) -> None:
if s == "bar":
reveal_type(s) # => Literal["bar"]
if-block 中的这种细化类型也与 LiteralString
兼容,因为它的类型是 Literal["bar"]
。
示例
请参阅以下示例以帮助阐明上述规则
literal_string: LiteralString
s: str = literal_string # OK
literal_string: LiteralString = s # Error: Expected LiteralString, got str.
literal_string: LiteralString = "hello" # OK
字面量字符串的加法
def expect_literal_string(s: LiteralString) -> None: ...
expect_literal_string("foo" + "bar") # OK
expect_literal_string(literal_string + "bar") # OK
literal_string2: LiteralString
expect_literal_string(literal_string + literal_string2) # OK
plain_string: str
expect_literal_string(literal_string + plain_string) # Not OK.
使用字面量字符串连接
expect_literal_string(",".join(["foo", "bar"])) # OK
expect_literal_string(literal_string.join(["foo", "bar"])) # OK
expect_literal_string(literal_string.join([literal_string, literal_string2])) # OK
xs: List[LiteralString]
expect_literal_string(literal_string.join(xs)) # OK
expect_literal_string(plain_string.join([literal_string, literal_string2]))
# Not OK because the separator has type 'str'.
使用字面量字符串的就地加法
literal_string += "foo" # OK
literal_string += literal_string2 # OK
literal_string += plain_string # Not OK
使用字面量字符串的格式字符串
literal_name: LiteralString
expect_literal_string(f"hello {literal_name}")
# OK because it is composed from literal strings.
expect_literal_string("hello {}".format(literal_name)) # OK
expect_literal_string(f"hello") # OK
username: str
expect_literal_string(f"hello {username}")
# NOT OK. The format-string is constructed from 'username',
# which has type 'str'.
expect_literal_string("hello {}".format(username)) # Not OK
其他字面量类型,例如字面量整数,与 LiteralString
不兼容
some_int: int
expect_literal_string(some_int) # Error: Expected LiteralString, got int.
literal_one: Literal[1] = 1
expect_literal_string(literal_one) # Error: Expected LiteralString, got Literal[1].
我们可以在字面量字符串上调用函数
def add_limit(query: LiteralString) -> LiteralString:
return query + " LIMIT = 1"
def my_query(query: LiteralString, user_id: str) -> None:
sql_connection().execute(add_limit(query), (user_id,)) # OK
条件语句和表达式按预期工作
def return_literal_string() -> LiteralString:
return "foo" if condition1() else "bar" # OK
def return_literal_str2(literal_string: LiteralString) -> LiteralString:
return "foo" if condition1() else literal_string # OK
def return_literal_str3() -> LiteralString:
if condition1():
result: Literal["foo"] = "foo"
else:
result: LiteralString = "bar"
return result # OK
与 TypeVars 和泛型的交互
TypeVars 可以绑定到 LiteralString
from typing import Literal, LiteralString, TypeVar
TLiteral = TypeVar("TLiteral", bound=LiteralString)
def literal_identity(s: TLiteral) -> TLiteral:
return s
hello: Literal["hello"] = "hello"
y = literal_identity(hello)
reveal_type(y) # => Literal["hello"]
s: LiteralString
y2 = literal_identity(s)
reveal_type(y2) # => LiteralString
s_error: str
literal_identity(s_error)
# Error: Expected TLiteral (bound to LiteralString), got str.
LiteralString
可以用作泛型类的类型参数
class Container(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
literal_string: LiteralString = "hello"
x: Container[LiteralString] = Container(literal_string) # OK
s: str
x_error: Container[LiteralString] = Container(s) # Not OK
像 List
这样的标准容器按预期工作
xs: List[LiteralString] = ["foo", "bar", "baz"]
与重载的交互
字面量字符串和重载不需要以特殊方式交互:现有规则工作正常。LiteralString
可以用作回退重载,当特定的 Literal["foo"]
类型不匹配时
@overload
def foo(x: Literal["foo"]) -> int: ...
@overload
def foo(x: LiteralString) -> bool: ...
@overload
def foo(x: str) -> str: ...
x1: int = foo("foo") # First overload.
x2: bool = foo("bar") # Second overload.
s: str
x3: str = foo(s) # Third overload.
向后兼容性
我们建议为早期 Python 版本添加 typing_extensions.LiteralString
。
正如 PEP 586 所述,类型检查器“应该自由地尝试更复杂的推断技术”。因此,如果类型检查器为用字面量字符串初始化的未注解变量推断出字面量字符串类型,则以下示例应该没问题
x = "hello"
expect_literal_string(x)
# OK, because x is inferred to have type 'Literal["hello"]'.
这使得可以在不注解代码的情况下对惯用 SQL 查询代码进行精确的类型检查(如 动机 部分的示例所示)。
但是,与 PEP 586 一样,本 PEP 不强制执行上述推断策略。如果类型检查器没有推断 x
具有类型 Literal["hello"]
,用户可以通过将其显式注解为 x: LiteralString
来帮助类型检查器
x: LiteralString = "hello"
expect_literal_string(x)
被拒绝的替代方案
为什么不使用工具 X?
捕获 SQL 注入等问题的工具似乎有三种类型:基于 AST、函数级分析和污点流分析。
基于 AST 的工具:Bandit 有一个插件,用于在 SQL 查询不是字面量字符串时发出警告。问题是许多完全安全的 SQL 查询是由字符串字面量动态构建的,如 动机 部分所示。在 AST 级别,生成的 SQL 查询将不再显示为字符串字面量,因此与潜在的恶意字符串无法区分。使用这些工具将需要显著限制开发人员构建 SQL 查询的能力。LiteralString
可以在限制更少的情况下提供类似的安全保证。
Semgrep 和 pyanalyze:Semgrep 支持更复杂的函数级分析,包括函数内的 常量传播。这使我们能够在函数内防止注入攻击,同时允许某些形式的安全动态 SQL 查询。pyanalyze 具有类似的扩展。但两者都无法处理构建并返回安全 SQL 查询的函数调用。例如,在下面的代码示例中,build_insert_query
是一个帮助函数,用于创建将多个值插入到相应列的查询。Semgrep 和 pyanalyze 禁止这种自然用法,而 LiteralString
在不给程序员带来负担的情况下处理它
def build_insert_query(
table: LiteralString
insert_columns: Iterable[LiteralString],
) -> LiteralString:
sql = "INSERT INTO " + table
column_clause = ", ".join(insert_columns)
value_clause = ", ".join(["?"] * len(insert_columns))
sql += f" ({column_clause}) VALUES ({value_clause})"
return sql
def insert_data(
conn: Connection,
kvs_to_insert: Dict[LiteralString, str]
) -> None:
query = build_insert_query("data", kvs_to_insert.keys())
conn.execute(query, kvs_to_insert.values())
# Example usage
data_to_insert = {
"column_1": value_1, # Note: values are not literals
"column_2": value_2,
"column_3": value_3,
}
insert_data(conn, data_to_insert)
污点流分析:Pysa 或 CodeQL 等工具能够跟踪从用户控制的输入流向 SQL 查询的数据。这些工具功能强大,但设置 CI 中的工具、定义“污点”接收器和源以及教开发人员如何使用它们涉及相当大的开销。它们通常也比类型检查器运行时间更长(几分钟而不是几秒),这意味着反馈不及时。最后,它们将防止漏洞的负担转移到库用户身上,而不是允许库本身精确指定其 API 必须如何调用(如 LiteralString
所能做到的那样)。
最后 preferring 使用新类型而不是专用工具的另一个原因是类型检查器比专用安全工具使用更广泛;例如,MyPy 在 2022 年 1 月的下载量 超过 700 万次,而 Bandit 的下载量 不到 200 万次。将安全保护直接内置到类型检查器中意味着更多的开发人员将从中受益。
为什么不为 str
使用 NewType
?
任何适合使用 LiteralString
的 API 都可以改为更新为接受 Python 类型系统内创建的不同类型,例如 NewType("SafeSQL", str)
SafeSQL = NewType("SafeSQL", str)
def execute(self, sql: SafeSQL, parameters: Iterable[str] = ...) -> Cursor: ...
execute(SafeSQL("SELECT * FROM data WHERE user_id = ?"), user_id) # OK
user_query: str
execute(user_query) # Error: Expected SafeSQL, got str.
为了调用 API 而必须创建新类型可能会让一些开发人员犹豫不决,并鼓励他们更加谨慎,但这并不能保证开发人员不会只是将用户控制的字符串转换为新类型,并将其传递给修改后的 API
query = f"SELECT * FROM data WHERE user_id = f{user_id}"
execute(SafeSQL(query)) # No error!
我们又回到了原点,面临着阻止任意输入 SafeSQL
的问题。这也不是一个理论上的担忧。Django 使用上述方法与 SafeString
和 mark_safe。诸如 CVE-2020-13596 之类的问题表明这种技术如何 失败。
另请注意,这需要对源代码进行侵入性更改(用 SafeSQL
包装查询),而 LiteralString
不需要此类更改。只要用户将字面量字符串传递给敏感 API,他们就可以对此一无所知。
为什么不尝试模拟 Trusted Types?
Trusted Types 是 W3C 规范,用于防止基于 DOM 的跨站脚本 (XSS)。当危险的浏览器 API 接受原始用户控制的字符串时,就会发生 XSS。该规范修改这些 API,使其只接受由指定消毒函数返回的“Trusted Types”。这些消毒函数必须接收一个潜在恶意的字符串并对其进行验证或以某种方式使其无害,例如通过验证它是否是有效的 URL 或对其进行 HTML 编码。
人们可能会认为将 Trusted Types 的概念移植到 Python 可以解决问题。然而,根本区别在于 Trusted Types 消毒器的输出通常 不打算作为可执行代码。因此,很容易对输入进行 HTML 编码,去除危险标签,或以其他方式使其失效。对于 SQL 查询或 shell 命令,最终结果 仍然需要是可执行代码。无法编写一个消毒器能够可靠地找出输入字符串的哪些部分是良性的,哪些部分是潜在恶意的。
运行时可检查的 LiteralString
LiteralString
概念可以扩展到静态类型检查之外,成为 str
对象的运行时可检查属性。这将提供一些好处,例如允许框架对动态字符串引发错误。此类运行时错误将是比类型错误更强大的防御机制,类型错误可能会被抑制、忽略,甚至如果作者不使用类型检查器,则根本不会被发现。
对 LiteralString
概念的这种扩展将通过要求更改 Python 中最基本的类型之一而大幅增加提案的范围。虽然字符串上的运行时污点检查,类似于 Perl 的 taint,在过去曾被 考虑 并 尝试 过,并且将来其他人可能会考虑,但此类扩展超出了本 PEP 的范围。
被拒绝的名称
我们考虑了字面量字符串类型的各种名称,并在 typing-sig 上征集了意见。一些值得注意的替代方案是
Literal[str]
:这是Literal["foo"]
类型名称的自然扩展,但 typing-sig 反对 称用户可能会将其误认为是str
类的字面量类型。LiteralStr
:这比LiteralString
短,但在 PEP 作者看来有些奇怪。LiteralDerivedString
:这(以及MadeFromLiteralString
)最能捕捉该类型的技术含义。它不仅代表字面量表达式的类型,例如"foo"
,还代表由字面量组成的表达式的类型,例如"foo" + "bar"
。然而,这两个名称都显得冗长。StringLiteral
:用户可能会将其与现有的 “字符串字面量” 概念混淆,其中字符串作为语法标记存在于源代码中,而我们的概念更通用。SafeString
:虽然这接近我们的预期含义,但它可能会误导用户认为字符串已以某种方式进行消毒,例如通过转义 HTML 标签或 shell 相关特殊字符。ConstantStr
:这未能捕捉到组合字面量字符串的思想。StaticStr
:这暗示字符串是静态可计算的,即无需运行程序即可计算,但事实并非如此。字面量字符串可能因运行时标志而异,如 动机 示例所示。LiteralOnly[str]
:这具有可扩展到其他字面量类型(例如bytes
或int
)的优点。然而,我们认为可扩展性不值得牺牲可读性。
总的来说,长时间以来 typing-sig 上没有明显的赢家,所以我们决定倾向于 LiteralString
。
LiteralBytes
我们可以将字面量字节类型(例如 Literal[b"foo"]
)泛化为 LiteralBytes
。然而,字面量字节类型的使用频率远低于字面量字符串类型,我们没有发现用户对 LiteralBytes
有太多需求,因此我们决定不将其包含在此 PEP 中。但是,其他人可以在未来的 PEP 中考虑它。
参考实现
这已在 Pyre v0.9.8 中实现并正在积极使用。
该实现只是将类型检查器扩展为将 LiteralString
作为字面量字符串类型的超类型。
为了支持通过加法、连接等进行组合,只需在 Pyre 的 typeshed 副本中重载 str
的存根即可。
附录 A:其他用途
为了简化讨论并尽量减少安全知识要求,我们在整个 PEP 中重点关注 SQL 注入。LiteralString
还可以用于防止许多其他类型的 注入漏洞。
命令注入
诸如 subprocess.run
之类的 API 接受一个可以作为 shell 命令运行的字符串
subprocess.run(f"echo 'Hello {name}'", shell=True)
如果用户控制的数据包含在命令字符串中,代码就容易受到“命令注入”的攻击;即攻击者可以运行恶意命令。例如,' && rm -rf / #
的值将导致运行以下破坏性命令
echo 'Hello ' && rm -rf / #'
通过更新 run
,使其在 shell=True
模式下仅接受 LiteralString
,可以防止此漏洞。这是一个简化的存根
def run(command: LiteralString, *args: str, shell: bool=...): ...
跨站脚本 (XSS)
大多数流行的 Python Web 框架,例如 Django,使用模板引擎从用户数据生成 HTML。这些模板语言在将用户数据插入 HTML 模板之前会自动转义,从而防止跨站脚本 (XSS) 漏洞。
但是,绕过自动转义 并按原样渲染 HTML 的常见方法是使用函数,例如 Django 中的 mark_safe
或 Jinja2 中的 do_mark_safe
,这会导致 XSS 漏洞
dangerous_string = django.utils.safestring.mark_safe(f"<script>{user_input}</script>")
return(dangerous_string)
通过更新 mark_safe
仅接受 LiteralString
,可以防止此漏洞
def mark_safe(s: LiteralString) -> str: ...
服务器端模板注入 (SSTI)
Jinja 等模板框架允许 Python 表达式,这些表达式将被评估并替换到渲染结果中
template_str = "There are {{ len(values) }} values: {{ values }}"
template = jinja2.Template(template_str)
template.render(values=[1, 2])
# Result: "There are 2 values: [1, 2]"
如果攻击者控制了模板字符串的全部或部分,他们可以插入执行任意代码的表达式,从而 损害 应用程序
malicious_str = "{{''.__class__.__base__.__subclasses__()[408]('rm - rf /',shell=True)}}"
template = jinja2.Template(malicious_str)
template.render()
# Result: The shell command 'rm - rf /' is run
通过更新 Template
API 仅接受 LiteralString
,可以防止此类模板注入攻击
class Template:
def __init__(self, source: LiteralString): ...
日志格式字符串注入
日志框架通常允许其输入字符串包含格式指令。最糟糕的是,允许用户控制日志字符串导致了 CVE-2021-44228(俗称 log4shell
),这被描述为 “过去十年中最关键的漏洞”。虽然目前没有 Python 框架已知易受类似攻击,但内置的日志框架确实提供了格式化选项,这些选项易受外部控制的日志字符串的拒绝服务攻击。以下示例说明了一个简单的拒绝服务场景
external_string = "%(foo)999999999s"
...
# Tries to add > 1GB of whitespace to the logged string:
logger.info(f'Received: {external_string}', some_dict)
可以通过要求传递给日志记录器的格式字符串是 LiteralString
,并且所有外部控制的数据都单独作为参数传递来防止这种攻击(如 Issue 46200 中所提议)
def info(msg: LiteralString, *args: object) -> None:
...
附录 B:局限性
在以下几种情况下,LiteralString
仍可能无法阻止用户将由非字面量数据构建的字符串传递给 API
1. 如果开发人员不使用类型检查器或不添加类型注解,则违规行为将无法被捕获。
2. cast(LiteralString, non_literal_string)
可以用来欺骗类型检查器,允许动态字符串值伪装成 LiteralString
。对于类型为 Any
的变量也是如此。
3. 诸如 # type: ignore
之类的注释可以用来忽略关于非字面量字符串的警告。
4. 可以构造简单的函数来将 str
转换为 LiteralString
def make_literal(s: str) -> LiteralString:
letters: Dict[str, LiteralString] = {
"A": "A",
"B": "B",
...
}
output: List[LiteralString] = [letters[c] for c in s]
return "".join(output)
我们可以通过 linting、代码审查等方式来缓解上述问题,但最终,试图规避 LiteralString
提供的保护的聪明、恶意的开发人员总会成功。重要的是要记住 LiteralString
并非旨在防止 恶意 开发人员;它旨在防止无意中以危险方式使用敏感 API 的良性开发人员(在其他方面不碍事)。
如果没有 LiteralString
,API 作者拥有的最佳强制工具是文档,它很容易被忽略,通常甚至不会被看到。有了 LiteralString
,API 滥用需要有意识的思考和代码中的人工制品,这些人工制品可以被审阅者和未来的开发人员注意到。
附录 C:保留 LiteralString
的 str
方法
str
类有几个方法将受益于 LiteralString
。例如,用户可能期望 "hello".capitalize()
具有 LiteralString
类型,类似于我们在 推断 LiteralString 部分中看到的其他示例。推断类型 LiteralString
是正确的,因为字符串不是任意用户提供的字符串——我们知道它的类型是 Literal["HELLO"]
,它与 LiteralString
兼容。换句话说,capitalize
方法保留了 LiteralString
类型。还有其他几个 str
方法保留 LiteralString
。
我们建议更新 typeshed 中 str
的存根,以便使用保留 LiteralString
的版本重载方法。这意味着类型检查器无需为每个方法硬编码 LiteralString
行为。它还允许我们通过更新 typeshed 存根轻松支持未来的新方法。
例如,为了保留 capitalize
方法的字面量类型,我们将按如下所示更改存根
# before
def capitalize(self) -> str: ...
# after
@overload
def capitalize(self: LiteralString) -> LiteralString: ...
@overload
def capitalize(self) -> str: ...
更改 str
存根的缺点是存根变得更加复杂,并且可能使错误消息更难理解。类型检查器可能需要对 str
进行特殊处理,以使错误消息对用户而言易于理解。
以下是 str
方法的详尽列表,当使用 LiteralString
类型的参数调用时,必须将其视为返回 LiteralString
。如果本 PEP 被接受,我们将更新 typeshed 中的这些方法签名
@overload
def capitalize(self: LiteralString) -> LiteralString: ...
@overload
def capitalize(self) -> str: ...
@overload
def casefold(self: LiteralString) -> LiteralString: ...
@overload
def casefold(self) -> str: ...
@overload
def center(self: LiteralString, __width: SupportsIndex, __fillchar: LiteralString = ...) -> LiteralString: ...
@overload
def center(self, __width: SupportsIndex, __fillchar: str = ...) -> str: ...
if sys.version_info >= (3, 8):
@overload
def expandtabs(self: LiteralString, tabsize: SupportsIndex = ...) -> LiteralString: ...
@overload
def expandtabs(self, tabsize: SupportsIndex = ...) -> str: ...
else:
@overload
def expandtabs(self: LiteralString, tabsize: int = ...) -> LiteralString: ...
@overload
def expandtabs(self, tabsize: int = ...) -> str: ...
@overload
def format(self: LiteralString, *args: LiteralString, **kwargs: LiteralString) -> LiteralString: ...
@overload
def format(self, *args: str, **kwargs: str) -> str: ...
@overload
def join(self: LiteralString, __iterable: Iterable[LiteralString]) -> LiteralString: ...
@overload
def join(self, __iterable: Iterable[str]) -> str: ...
@overload
def ljust(self: LiteralString, __width: SupportsIndex, __fillchar: LiteralString = ...) -> LiteralString: ...
@overload
def ljust(self, __width: SupportsIndex, __fillchar: str = ...) -> str: ...
@overload
def lower(self: LiteralString) -> LiteralString: ...
@overload
def lower(self) -> LiteralString: ...
@overload
def lstrip(self: LiteralString, __chars: LiteralString | None = ...) -> LiteralString: ...
@overload
def lstrip(self, __chars: str | None = ...) -> str: ...
@overload
def partition(self: LiteralString, __sep: LiteralString) -> tuple[LiteralString, LiteralString, LiteralString]: ...
@overload
def partition(self, __sep: str) -> tuple[str, str, str]: ...
@overload
def replace(self: LiteralString, __old: LiteralString, __new: LiteralString, __count: SupportsIndex = ...) -> LiteralString: ...
@overload
def replace(self, __old: str, __new: str, __count: SupportsIndex = ...) -> str: ...
if sys.version_info >= (3, 9):
@overload
def removeprefix(self: LiteralString, __prefix: LiteralString) -> LiteralString: ...
@overload
def removeprefix(self, __prefix: str) -> str: ...
@overload
def removesuffix(self: LiteralString, __suffix: LiteralString) -> LiteralString: ...
@overload
def removesuffix(self, __suffix: str) -> str: ...
@overload
def rjust(self: LiteralString, __width: SupportsIndex, __fillchar: LiteralString = ...) -> LiteralString: ...
@overload
def rjust(self, __width: SupportsIndex, __fillchar: str = ...) -> str: ...
@overload
def rpartition(self: LiteralString, __sep: LiteralString) -> tuple[LiteralString, LiteralString, LiteralString]: ...
@overload
def rpartition(self, __sep: str) -> tuple[str, str, str]: ...
@overload
def rsplit(self: LiteralString, sep: LiteralString | None = ..., maxsplit: SupportsIndex = ...) -> list[LiteralString]: ...
@overload
def rsplit(self, sep: str | None = ..., maxsplit: SupportsIndex = ...) -> list[str]: ...
@overload
def rstrip(self: LiteralString, __chars: LiteralString | None = ...) -> LiteralString: ...
@overload
def rstrip(self, __chars: str | None = ...) -> str: ...
@overload
def split(self: LiteralString, sep: LiteralString | None = ..., maxsplit: SupportsIndex = ...) -> list[LiteralString]: ...
@overload
def split(self, sep: str | None = ..., maxsplit: SupportsIndex = ...) -> list[str]: ...
@overload
def splitlines(self: LiteralString, keepends: bool = ...) -> list[LiteralString]: ...
@overload
def splitlines(self, keepends: bool = ...) -> list[str]: ...
@overload
def strip(self: LiteralString, __chars: LiteralString | None = ...) -> LiteralString: ...
@overload
def strip(self, __chars: str | None = ...) -> str: ...
@overload
def swapcase(self: LiteralString) -> LiteralString: ...
@overload
def swapcase(self) -> str: ...
@overload
def title(self: LiteralString) -> LiteralString: ...
@overload
def title(self) -> str: ...
@overload
def upper(self: LiteralString) -> LiteralString: ...
@overload
def upper(self) -> str: ...
@overload
def zfill(self: LiteralString, __width: SupportsIndex) -> LiteralString: ...
@overload
def zfill(self, __width: SupportsIndex) -> str: ...
@overload
def __add__(self: LiteralString, __s: LiteralString) -> LiteralString: ...
@overload
def __add__(self, __s: str) -> str: ...
@overload
def __iter__(self: LiteralString) -> Iterator[str]: ...
@overload
def __iter__(self) -> Iterator[str]: ...
@overload
def __mod__(self: LiteralString, __x: Union[LiteralString, Tuple[LiteralString, ...]]) -> str: ...
@overload
def __mod__(self, __x: Union[str, Tuple[str, ...]]) -> str: ...
@overload
def __mul__(self: LiteralString, __n: SupportsIndex) -> LiteralString: ...
@overload
def __mul__(self, __n: SupportsIndex) -> str: ...
@overload
def __repr__(self: LiteralString) -> LiteralString: ...
@overload
def __repr__(self) -> str: ...
@overload
def __rmul__(self: LiteralString, n: SupportsIndex) -> LiteralString: ...
@overload
def __rmul__(self, n: SupportsIndex) -> str: ...
@overload
def __str__(self: LiteralString) -> LiteralString: ...
@overload
def __str__(self) -> str: ...
附录 D:在存根中使用 LiteralString
的指南
源代码中不包含类型注解的库可以在 Typeshed 中指定类型存根。用其他语言编写的库,例如机器学习库,也可以提供 Python 类型存根。这意味着类型检查器无法验证类型注解是否与源代码匹配,并且必须信任类型存根。因此,类型存根的作者在使用 LiteralString
时需要小心,因为函数可能看起来安全但实际上并不安全。
我们建议在存根中使用 LiteralString
时遵循以下指南
- 如果存根是纯函数,我们建议仅当所有相应参数都具有字面量类型(即
LiteralString
或Literal["a", "b"]
)时,才在函数或其重载的返回类型中使用LiteralString
。# OK @overload def my_transform(x: LiteralString, y: Literal["a", "b"]) -> LiteralString: ... @overload def my_transform(x: str, y: str) -> str: ... # Not OK @overload def my_transform(x: LiteralString, y: str) -> LiteralString: ... @overload def my_transform(x: str, y: str) -> str: ...
- 如果存根是
staticmethod
,我们建议遵循与上述相同的指南。 - 如果存根是其他任何类型的方法,我们建议不要在方法或其任何重载的返回类型中使用
LiteralString
。这是因为,即使所有显式参数都具有LiteralString
类型,对象本身也可能使用用户数据创建,因此返回类型可能受用户控制。 - 如果存根是类属性或全局变量,我们也不建议使用
LiteralString
,因为未类型化的代码可能会向该属性写入任意值。
然而,我们最终将决定权留给库作者。如果他们确信方法或函数返回的字符串或属性中存储的字符串保证具有字面量类型——即,字符串是通过对字符串字面量应用仅保留字面量属性的 str
操作而创建的,那么他们可以使用 LiteralString
。
请注意,这些指南不适用于内联类型注解,因为类型检查器可以验证(例如)返回 LiteralString
的方法确实返回该类型的表达式。
资源
Scala 中的字面量字符串类型
Scala 使用 Singleton
作为单例类型的超类型,其中包括字面量字符串类型,例如 "foo"
。Singleton
是 Scala 对本 PEP 的 LiteralString
的广义模拟。
Tamer Abdulradi 展示了 Scala 的字面量字符串类型如何用于“在编译时防止 SQL 注入”,Scala Days 演讲 字面量类型:它们有什么用?(幻灯片 52 至 68)。
致谢
感谢以下人员对本 PEP 的反馈
Edward Qiu, Jia Chen, Shannon Zhu, Gregory P. Smith, Никита Соболев, CAM Gerlach, Arie Bovenberg, David Foster, and Shengye Wan
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0675.rst