February 19, 2021·4 min read·engineeringpythonsecurityhttp

Python 3.9.2 changes the meaning of query separators

Python 3.9.2 restricts query parsing to one separator by default. The security issue comes from disagreement between components interpreting the same URL.

A web cache and an application can receive the same query string and disagree about which parameters it contains. Python's willingness to accept two query separators makes that disagreement possible when another component recognizes only one. Python 3.9.2, released on February 19, changes that default as part of the fix for CVE-2021-23336. The compatibility change belongs in the security discussion because retaining every accepted input would also retain the ambiguous interpretation.

The release announcement identifies two security fixes. The other, CVE-2021-3177, removes static buffers from the representation of ctypes.c_double and ctypes.c_longdouble values. These are separate defects with separate exposure conditions. The query parser change deserves particular attention from web application maintainers because it changes the interpretation of otherwise ordinary request data.

I value Python's library ecosystem, but familiar standard-library calls can conceal assumptions that matter outside the interpreter. Query parsing is one such assumption. A function can behave consistently in isolation while disagreeing with the component that decides whether a response may be reused.

One string can describe different requests

The documentation shipped with Python 3.9.2 records the exact change. Earlier versions accepted both ampersands and semicolons as query parameter separators. The updated parse_qs and parse_qsl accept a separator argument and default it to an ampersand. Choosing a semicolon explicitly remains possible, but a single configured separator now determines the split.

The difference appears in a short example. Under the new default, the semicolon stays inside a value.

from urllib.parse import parse_qs

query = "page=home;role=admin&lang=en"

assert parse_qs(query) == {
    "page": ["home;role=admin"],
    "lang": ["en"],
}

assert parse_qs("page=home;lang=en", separator=";") == {
    "page": ["home"],
    "lang": ["en"],
}

Before the change, the first query could supply a separate role parameter to Python even if a component in front of it regarded that text as part of the page value. This example illustrates parser disagreement. It does not establish that an arbitrary application grants privileges through such a parameter, or that every cache configuration is vulnerable.

The original issue report identifies disagreement with proxies that recognize only ampersands. Cache poisoning requires that this disagreement affect how a stored response corresponds to a later request. For example, an intermediary might exclude some apparent query data from the cache key while the application interprets that data as a meaningful parameter. A response influenced by the hidden parameter could then be stored under a key shared with requests that did not request that behavior. The application's response logic and the cache's key construction determine the consequences.

The implementation defines the order of interpretation

The versioned parser implementation makes the mechanism inspectable. parse_qs delegates to parse_qsl, then groups values by parameter name. parse_qsl splits on the selected separator, splits each resulting field at its first equals sign, and decodes the names and values. Repeated names therefore still produce lists in parse_qs. The fix changes field boundaries without changing that return convention.

Decoding follows the field split. That ordering distinguishes a literal separator from a percent-encoded character inside a value. A query containing an encoded ampersand does not gain another field merely because decoding later produces that character. This matters when a service rebuilds or forwards a query string. Reinterpreting an already decoded value as query syntax would introduce a second parsing step with different boundaries.

The same implementation counts fields using the configured separator when max_num_fields is set. That limit and separator selection address different requirements. The limit bounds how many fields the parser accepts, while the separator determines which fields exist. Setting a field limit cannot establish agreement with an upstream cache about their names or values.

Similarly, strict_parsing=True does not choose the separator. The documentation assigns it responsibility for parsing errors, which otherwise may be ignored. An application cannot express its separator policy by enabling strict parsing alone. The new argument gives that policy a direct representation in the call.

Compatibility has to include the request path

Applications that intentionally accepted semicolon-separated input face a real behavior change. Selecting separator=";" can retain that particular input format. It also requires agreement with every component that treats the query as structured data. Reproducing the old dual-separator behavior elsewhere would preserve the disagreement that motivated the fix.

This is why a parser update cannot be assessed solely by comparing the application's returned dictionary. The request's interpretation also matters wherever the system constructs a cache key or forwards parameters. A service with no shared cache may still depend on the old syntax, while a cached service may need to examine how the same request is understood at multiple points.

I prefer the narrower default because it removes an implicit compatibility rule from security-sensitive input handling. The explicit override is useful when a protocol actually requires another separator. That decision should describe the accepted request format, with the parser configured to match it.

Python 3.9.2 makes the default query grammar more specific. Existing ampersand-separated requests retain their field boundaries, and semicolons remain data unless the caller selects them as separators. For a web system that delegates request handling across multiple components, that provides a concrete rule against which to compare each component's interpretation.