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-02-07
- 决议:
- 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-字符串,并使他们的代码容易受到 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中,我们提供了
str
方法的详尽列表,这些方法保留LiteralString
类型。
在所有其他情况下,如果一个或多个组合值具有非字面量类型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 代码块中此类细化的类型也与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.
向后兼容性
我们建议添加typing_extensions.LiteralString
以便在早期版本的 Python 中使用。
如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
所可能做到的)。
更喜欢使用新类型而不是专用工具的最后一个原因是,类型检查器比专用的安全工具使用更广泛;例如,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)
我们可以使用 lint、代码审查等方法来缓解上述问题,但最终,试图规避 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
,我们建议遵循与上述相同的准则。 - 如果存根用于任何其他类型的 method,我们建议不要在 method 或其任何重载的返回类型中使用
LiteralString
。这是因为,即使所有显式参数都具有类型LiteralString
,对象本身也可能使用用户数据创建,因此返回类型可能受用户控制。 - 如果存根用于类属性或全局变量,我们也建议不要使用
LiteralString
,因为未类型化的代码可能会将任意值写入该属性。
但是,我们最终的决定权交给库作者。如果他们确信 method 或 function 返回的字符串或存储在属性中的字符串保证具有文字类型(即,该字符串仅通过对字符串文字应用保留文字的 str
操作创建),则可以使用 LiteralString
。
请注意,这些准则不适用于内联类型注解,因为类型检查器可以验证例如返回 LiteralString
的 method 是否确实返回该类型的表达式。
资源
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 和 Shengye Wan。
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证发布,以较宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0675.rst
上次修改时间:2024-06-11 22:12:09 GMT