Post

CVE-2023-24329 – URL Blackslisting Bypass in Python's urllib.parse

A vulnerability in Python's urllib.parse module that allows attackers to bypass URL blocklists simply by adding leading whitespace before a URL.

CVE-2023-24329 – URL Blackslisting Bypass in Python's urllib.parse

Introduction

Python is one of the most widely used programming languages, and its standard library powers countless web applications. Because developers often trust these built-in libraries, subtle parsing bugs can have security implications across many projects.

In this article, we’ll take an in-depth look at CVE-2023-24329, a vulnerability in Python’s urllib.parse module that allows attackers to bypass URL blocklists simply by adding leading whitespace before a URL.

We’ll reproduce the vulnerability, analyze the root cause by stepping through the CPython source code, and examine how the issue was fixed.


CVE Information

CVE: CVE-2023-24329

Affected Versions

  • Python ≤ 3.11.3
  • Fixed in Python 3.11.4

Description

An issue in Python’s urllib.parse module allows attackers to bypass URL blocklists by supplying URLs that begin with blank characters. Applications relying on urllib.parse.urlparse() to validate or restrict URL schemes or hostnames may incorrectly classify malicious URLs as safe.


Why This Matters

Many web applications accept user-supplied URLs for features such as:

  • URL previews
  • Webhooks
  • File fetching
  • Image downloading
  • Proxy endpoints
  • Server-side requests (SSRF protection)

A common defense is to parse the URL and reject dangerous schemes such as:

  • file://
  • ftp://
  • gopher://
  • data://

or block specific hostnames.

Applications frequently implement checks similar to:

1
2
3
4
scheme = urllib.parse.urlparse(url).scheme

if scheme == "file":
    reject()

The assumption is that urlparse() will always correctly identify the scheme.

CVE-2023-24329 breaks that assumption.


Reproducing the Vulnerability

For this demonstration, I used Python 3.11.3, the final release before the vulnerability was patched.

We’ll build a small Flask application that accepts a URL from the user and blocks dangerous schemes before opening it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from flask import Flask, render_template, request
import urllib.request
import urllib.error
import urllib.parse

app = Flask(__name__)

def safeURLOpener(inputLink):
    block_schemes = [
        "file",
        "gopher",
        "expect",
        "php",
        "dict",
        "ftp",
        "glob",
        "data"
    ]

    block_host = [
        "instagram.com",
        "youtube.com",
        "tiktok.com"
    ]

    input_scheme = urllib.parse.urlparse(inputLink).scheme
    input_hostname = urllib.parse.urlparse(inputLink).hostname

    if input_scheme in block_schemes:
        return "Input scheme is forbidden"

    if input_hostname in block_host:
        return "Input hostname is forbidden"

    try:
        target = urllib.request.urlopen(inputLink)
        return target.read().decode("utf-8")

    except urllib.error.URLError as e:
        return f"Error opening URL: {e}"

@app.route("/", methods=["GET", "POST"])
def index():
    content = ""

    if request.method == "POST":
        url = request.form.get("domain")
        if url:
            content = safeURLOpener(url)

    return render_template("index.html", content=content)

if __name__ == "__main__":
    app.run(debug=True)

Running the application starts a local server at:

1
http://127.0.0.1:5000

Expected Behavior

Suppose we submit the following payload:

1
file:///C:/Windows/win.ini

The application correctly detects the file scheme and rejects it.

1
Input scheme is forbidden

So far, everything works as intended.


Bypassing the Blocklist

Now prepend a single space:

1
 file:///C:/Windows/win.ini

Despite being almost identical, the application no longer recognizes the URL as using the file scheme.

Instead, the parser treats the entire string as a normal path, allowing the validation logic to succeed and the dangerous URL to be processed.

A single leading blank character completely bypasses the blocklist.


Investigating the Vulnerability

To understand why this happens, we’ll inspect the CPython implementation.

The vulnerable source code can be found in the CPython repository:

https://github.com/python/cpython/tree/3.11/Lib/urllib

We’ll use a simple example:

1
2
3
4
5
from urllib.parse import urlparse

url = " https://www.example.com"

print(urlparse(url))

Set a breakpoint on the urlparse() call and step into the implementation.

Eventually, the debugger enters:

1
urllib/parse.py

Inside urlparse()

The urlparse() function first prepares the arguments and then delegates most of the parsing work to urlsplit().

The call sequence is roughly:

1
2
3
4
5
urlparse()
    │
    ├── _coerce_args()
    │
    └── urlsplit()

_coerce_args()

This helper simply ensures the supplied arguments are all strings (or all bytes).

If the types don’t match, it raises an exception.

Nothing security-related happens here.


urlsplit()

This is where the interesting logic lives.

Before parsing begins, Python removes a few unsafe characters using:

1
UNSAFE_URL_BYTES_TO_REMOVE

These characters include:

  • \t
  • \r
  • \n

This prevents several injection-related attacks.

Notice what’s missing:

Spaces are not removed.


Detecting the Scheme

The parser searches for the first colon:

1
url.find(":")

Everything before the colon is treated as a potential scheme.

Python then validates every character:

1
for c in url[:i]:

Each character must belong to the allowed character set stored in:

1
scheme_chars

When the URL begins with a blank character, the first character is not a valid scheme character.

Instead of rejecting the URL, the parser simply fails to identify a scheme and continues parsing as though no scheme exists.

As a result:

1
scheme = ""

and the remainder of the URL is interpreted as part of the path.

This is the core of CVE-2023-24329.


Continuing the Parsing Process

Since no scheme is detected, parsing proceeds normally.

The parser then:

  • checks for a network location (//)
  • validates IPv6 syntax
  • extracts fragments (#)
  • extracts query parameters (?)
  • validates the hostname through _checknetloc()

Finally, it constructs a ParseResult.

Because no scheme was ever recognized, the resulting object looks roughly like:

1
2
3
4
5
6
7
8
ParseResult(
    scheme='',
    netloc='',
    path=' file:///C:/Windows/win.ini',
    params='',
    query='',
    fragment=''
)

The dangerous URL is now hiding inside the path field rather than the scheme field.

Applications checking only:

1
if parsed.scheme == "file":

will incorrectly conclude the URL is safe.


Root Cause

The vulnerability stems from how urlsplit() handles invalid characters before the scheme.

Rather than rejecting the input, it silently abandons scheme parsing and continues processing the URL.

This behavior causes security checks that rely on parsed.scheme or parsed.hostname to fail.


The Patch

The fix was introduced in Python 3.11.4.

Patch:

https://github.com/python/cpython/pull/99421

The update expands validation and introduces additional test cases covering malformed URL schemes.

The new tests verify that invalid prefixes—including leading whitespace, invalid symbols, digits, and non-ASCII characters—are handled correctly rather than silently ignored.

This prevents attackers from bypassing validation using malformed URLs.


Mitigation

If your application depends on urllib.parse for security decisions:

  • Upgrade to Python 3.11.4 or later.
  • Avoid relying solely on blocklists.
  • Normalize and trim user input before parsing.
  • Prefer allowlists of approved schemes (such as http and https) instead of attempting to block dangerous ones.

Final Thoughts

CVE-2023-24329 is an excellent example of how seemingly harmless parsing behavior can introduce meaningful security risks.

The vulnerability isn’t caused by complex memory corruption or advanced exploitation techniques. Instead, it comes down to a simple assumption: developers trusted that urlparse() would always identify a URL scheme correctly.

A single leading space was enough to break that assumption.

This serves as an important reminder that parsing libraries are not security boundaries by themselves. When building security-sensitive applications, validation should be explicit, input should be normalized before inspection, and dependencies should be kept up to date.

Sometimes, the smallest parsing edge case can become a surprisingly effective security bypass.


References

This post is licensed under CC BY 4.0 by the author.