Following system colour scheme - Python 增強提案 Selected dark colour scheme - Python 增強提案 Selected light colour scheme - Python 增強提案

Python 增強提案

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 訊息

目錄

重要

本 PEP 是歷史文件:請參閱 LiteralStringtyping.LiteralString 以獲取最新規範和文件。規範的型別規範儲存在 型別規範站點;執行時型別行為在 CPython 文件中描述。

×

有關如何提議更改型別規範的資訊,請參閱型別規範更新過程

摘要

目前,無法使用型別註解指定函式引數可以是任何字面量字串型別。我們必須指定精確的字面量字串型別,例如 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 586Literal["foo"] 型別的泛化。型別為 LiteralString 的字串不能包含使用者控制的資料。因此,任何只接受 LiteralString 的 API 都將免受注入漏洞的攻擊(具有 實際限制)。

由於我們希望 sqlite3execute 方法禁止使用使用者輸入構建的字串,因此我們將使其 typeshed 存根 接受型別為 LiteralStringsql 查詢

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。因此,LiteralStringstr 相容,反之則不然。

請注意,字面量型別的 Union 自然與 LiteralString 相容,因為 Union 的每個元素都與 LiteralString 單獨相容。因此,Literal["foo", "bar"]LiteralString 相容。

然而,請記住,我們不只是想表示精確的字面量查詢。我們還希望支援兩個字面量字串的組合,例如 query + " LIMIT 1"。這也適用於上述概念。如果 xy 是兩個型別為 LiteralString 的值,那麼 x + y 的型別也將與 LiteralString 相容。我們可以透過檢視特定例項來推斷這一點,例如 Literal["foo"]Literal["bar"];新增的字串 x + y 的值只能是 "foobar",其型別為 Literal["foobar"],因此與 LiteralString 相容。當 xy 是字面量型別的並集時,同樣的推理也適用;分別從 xy 中成對新增任意兩個字面量型別的結果是一個字面量型別,這意味著總體結果是字面量型別的 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

  • 加法:如果 xy 都與 LiteralString 相容,則 x + y 的型別為 LiteralString
  • 連線:如果 sep 的型別與 LiteralString 相容,並且 xs 的型別與 Iterable[LiteralString] 相容,則 sep.join(xs) 的型別為 LiteralString
  • 就地加法:如果 s 的型別為 LiteralStringx 的型別與 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 使用上述方法與 SafeStringmark_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]:這具有可擴充套件到其他字面量型別(例如 bytesint)的優點。然而,我們認為可擴充套件性不值得犧牲可讀性。

總的來說,長時間以來 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_safeJinja2 中的 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:保留 LiteralStringstr 方法

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 時遵循以下指南

  • 如果存根是純函式,我們建議僅當所有相應引數都具有字面量型別(即 LiteralStringLiteral["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


來源:https://github.com/python/peps/blob/main/peps/pep-0675.rst

最後修改:2024-06-11 22:12:09 GMT