Back to skill

Security audit

MarkItDown

Security checks for vulnerabilities and agentic risk

Overview

The skill mostly matches its document-to-Markdown purpose, but its web-page converter can run untrusted sites in a browser with weakened sandboxing and incomplete internal-network filtering, so it belongs in Review before installation.

Install only if you are comfortable running webpage conversion in an isolated, non-root environment. Avoid internal, private, login-protected, or sensitive URLs; do not use --allow-internal for untrusted content; and keep optional OpenAI/Azure/plugin features disabled unless you explicitly approve where the content will go. A safer version would fail closed when Chromium cannot run sandboxed and would filter every browser request, redirect, frame, worker, and subresource against private-network targets.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (3)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/url_fetch.py:67
Finding
Headless browser redirects and subresources bypass complete SSRF validation## Vulnerability Details **File Location**: `scripts/url_fetch.py:67-123` **Vulnerability Type**: Incomplete SSRF protection in the browser-rendering path **Risk Level**: High ### Vulnerable Code ```python def render_with_browser(url, browser, virtual_time=8000, allow_internal=False): # Defense-in-depth: re-verify the target INSIDE this function so the browser # is never launched for an internal/private URL even if an upstream guard # were skipped. --allow-internal (trusted local dev) still overrides. try: from url_security import _is_blocked_target except ImportError: # used standalone without the package; upstream guard applies _is_blocked_target = None if _is_blocked_target is not None: blocked, reason = _is_blocked_target(url, allow_internal) if blocked: print("[spa-fallback] in-function SSRF re-check refused target: %s" % reason, file=sys.stderr) return None # Pin the hostname to its validated address for the renderer as well, so a # rebinding resolver cannot send the browser to an internal address. The # mapping only covers this hostname (sub-resource hosts are untested by # design — see the documented limitation in SKILL.md). pin_rule = None try: from url_security import resolve_and_check _host = urllib.parse.urlparse(url).hostname or "" _blocked_dns, _reason_dns = resolve_and_check(_host, allow_internal) if _blocked_dns: print("[spa-fallback] in-function DNS re-check refused target: %s" % _reason_dns, file=sys.stderr) return None _ip = _resolve_first_ipv4(_host) if _ip and _host and _host != _ip: pin_rule = "MAP %s %s" % (_host, _ip) except Exception: # noqa: BLE001 - pinning is best effort, never fatal pin_rule = None html_path = _make_tem ...[truncated 3626 chars]
Remediation
## Remediation Suggestions 1. Intercept every Chromium request through the Chrome DevTools Protocol or an equivalent browser automation API. 2. Validate all navigation, redirect, frame, worker, and subresource destinations before allowing them. 3. Resolve every destination hostname and reject any address that is loopback, private, link-local, reserved, multicast, CGNAT, or otherwise non-public. 4. Pin each approved hostname to the specific validated address, not only the original page hostname. 5. Prefer running browser traffic through a dedicated egress-filtering proxy that rejects private and metadata destinations at the network layer. 6. Place Chromium in an isolated network namespace or container that has no route to internal networks or metadata services. 7. Treat validation failures as fatal rather than continuing with best-effort pinning. 8. Restrict or remove `--allow-internal` from agent-facing execution; if retained for development, require an explicit trusted operator action. 9. Add regression tests for public-to-private redirects, DNS rebinding, nested frames, JavaScript fetches, and private subresource URLs.

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/url_fetch.py:228
Finding
Proxy mode skips DNS-level validation and pinning for redirect destinations## Vulnerability Details **File Location**: `scripts/url_fetch.py:228-274` **Vulnerability Type**: Proxy-dependent SSRF protection bypass **Risk Level**: Medium ### Vulnerable Code ```python class ValidatingRedirectHandler(urllib.request.HTTPRedirectHandler): """Re-check every redirect hop against the SSRF guard before following it. urllib follows 3xx automatically, which would otherwise let a public URL bounce the request to a loopback address, the cloud metadata endpoint, or other private/internal address space. """ def __init__(self, allow_internal=False): self.allow_internal = allow_internal def redirect_request(self, req, fp, code, msg, headers, newurl): try: from url_security import _is_blocked_target except ImportError: # package used standalone; default urllib behaviour return super().redirect_request(req, fp, code, msg, headers, newurl) blocked, reason = _is_blocked_target(newurl, self.allow_internal) if blocked: raise urllib.error.HTTPError( req.full_url, code, "redirect target refused by SSRF guard: %s" % reason, headers, fp) return super().redirect_request(req, fp, code, msg, headers, newurl) def safe_urlopen(req, timeout=40, allow_internal=False, strict_pin=False): """urlopen with redirect-target validation and (when possible) DNS pinning. Proxy policy — deliberate and documented, because it decides whether pinning can work at all: * default (strict_pin=False): honour the environment/system proxy. When a proxy is in effect the proxy performs the connection, so pinning is skipped (and a one-off notice is printed); otherwise the request is pinned to the validated IP. * strict_pin=True: bypass the proxy (``ProxyHandler({})``) and always pin. Only use where direct egress is availab ...[truncated 2815 chars]
Remediation
## Remediation Suggestions 1. Apply DNS-level destination validation to every redirect hostname even when a proxy is configured. 2. Fail closed if the application cannot establish that the proxy enforces an equivalent public-destination-only policy. 3. Require `--strict-pin` or an administrator-approved filtering proxy for untrusted URLs. 4. Where proxy use is mandatory, use a controlled proxy with explicit denial rules for loopback, private, link-local, reserved, multicast, CGNAT, and cloud metadata ranges. 5. Revalidate every redirect independently; do not assume that validation of the initial URL applies to later destinations. 6. Document whether DNS is resolved locally or remotely and ensure the validation point matches the actual connection point. 7. Add automated tests using proxy mode and a redirect hostname that resolves to each prohibited network category.

T09 · Insecure Skill Coding Practices

Error
Location
scripts/url_fetch.py:102
Finding
Automatic execution of untrusted web content with the Chromium sandbox disabled## Vulnerability Details **File Location**: `scripts/url_fetch.py:102-140` **Vulnerability Type**: Unsafe browser sandbox fallback **Risk Level**: High ### Vulnerable Code ```python # Sandbox-first strategy: keep Chromium's sandbox enabled whenever possible. # --no-sandbox is only used when it is actually required (running as root) or # when the sandboxed launch crashed with the typical namespace error seen in # restricted containerized environments. The SSRF guard already refuses # internal/loopback targets, so the browser is only pointed at public URLs. base = [browser, "--headless=new", "--disable-gpu", f"--virtual-time-budget={virtual_time}", "--dump-dom", url] if pin_rule: base.insert(1, "--host-resolver-rules=%s" % pin_rule) is_root = hasattr(os, "getuid") and os.getuid() == 0 if is_root: attempts = [base + ["--no-sandbox"]] else: attempts = [base, base + ["--no-sandbox"]] last_err = None try: for i, cmd in enumerate(attempts): try: with open(html_path, "w", encoding="utf-8", errors="ignore") as fh: subprocess.run( cmd, stdout=fh, stderr=subprocess.DEVNULL, timeout=90, check=True, ) if i > 0: print("[spa-fallback] sandboxed launch failed; retried with " "--no-sandbox (browser sandbox disabled)", file=sys.stderr) return html_path except FileNotFoundError: raise # browser binary missing; retrying cannot help except subprocess.TimeoutExpired: raise # page too slow; retrying with --no-sandbox cannot help except subprocess.CalledProcessError as e: last_err = e # typical root/namespace crash -> retry sandboxless ...[truncated 2583 chars]
Remediation
## Remediation Suggestions 1. Remove the automatic `--no-sandbox` retry. 2. Fail closed when Chromium cannot run with its sandbox enabled. 3. Do not run the browser as root. Create a dedicated unprivileged account with no access to user secrets or unrelated files. 4. If an exceptional sandboxless development mode is retained, require an explicit command-line option and interactive or policy-level authorization; never select it automatically for attacker-controlled URLs. 5. Run the renderer in a disposable, hardened container or virtual machine with a read-only root filesystem, minimal mounted data, dropped Linux capabilities, `no-new-privileges`, seccomp/AppArmor/SELinux restrictions, and strict resource limits. 6. Deny access to cloud metadata and private networks at the network layer. 7. Use a fresh temporary browser profile for each conversion and delete it afterward. 8. Keep Chromium patched and use a known supported version. 9. Distinguish verified sandbox initialization errors from general browser failures, while still failing rather than silently weakening isolation. 10. Emit a clear error explaining how the runtime must be reconfigured to support sandboxed Chromium.
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Supply ChainUnpinned Dependencies, External Script Fetching, Obfuscated Code
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
Findings (23)

Tp4

High
Category
MCP Tool Poisoning
Confidence
99% confidence
Finding
The declared description centers on converting rich-format documents and web pages into Markdown, including URL handling and format-specific ingestion via MarkItDown. The actual code does none of that: it only opens text files or stdin, counts characters, estimates tokens, and optionally compares two files. This is a materially different primary purpose. While token-cost reduction is mentioned in the description as a rationale for converting to Markdown, this code is not performing conversion and instead implements a separate token-audit utility. Therefore the description does not accurately represent this code chunk.

Tp4

High
Category
MCP Tool Poisoning
Confidence
97% confidence
Finding
The description presents a broad skill for converting both documents and public web pages to Markdown for analysis, with explicit guidance to use a URL conversion script and references to URL safety controls. The actual code chunk is a different utility: it accepts a local file path, runs MarkItDown on that file, and reports approximate Markdown token cost plus optional savings estimates based on heuristics. While it does perform document-to-Markdown conversion for local files, that is only part of the declared behavior and the code omits major advertised capabilities around URL handling and safety checks. Additionally, the code’s primary added purpose—token-cost estimation—is materially different from the declared skill description. Therefore this is a description/behavior mismatch.

Lp1

High
Category
MCP Least Privilege
Confidence
75% confidence
Finding
The skill uses 'network' capability that is not listed in its permissions. This may indicate deceptive intent or missing permission declarations.

Lp1

High
Category
MCP Least Privilege
Confidence
83% confidence
Finding
This file reads the MARKITDOWN_BIN environment variable to decide which executable to run. In environments where untrusted users, jobs, or wrappers can influence process environment variables, this can redirect execution to an attacker-controlled program, resulting in arbitrary code execution under the skill's privileges.

Cloud Metadata Access

High
Category
Server-Side Request Forgery
Content
* redirect-target validation (each hop re-checked before following), and
      * the in-function re-checks in url_fetch / spa_extract.
    Together they close the common "public hostname -> internal IP" and
    "public URL redirects to 169.254.169.254" paths.
    """
    if allow_internal or not host:
        return False, ""
Confidence
90% confidence
Finding
Code accesses a cloud instance metadata endpoint (e.g. 169.254.169.254). A single request can return temporary IAM credentials, making this a high-value SSRF target for credential theft.

Cloud Metadata Access

High
Category
Server-Side Request Forgery
Content
* redirect-target validation (each hop re-checked before following), and
      * the in-function re-checks in url_fetch / spa_extract.
    Together they close the common "public hostname -> internal IP" and
    "public URL redirects to 169.254.169.254" paths.
    """
    if allow_internal or not host:
        return False, ""
Confidence
90% confidence
Finding
Code accesses a cloud instance metadata endpoint (e.g. 169.254.169.254). A single request can return temporary IAM credentials, making this a high-value SSRF target for credential theft.

Natural-Language Policy Violations

Medium
Confidence
89% confidence
Finding
The manifest uses Chinese as the primary description, category, summary, and prominent recommended guidance, while English is secondary. This creates a locale bias in natural-language instructions without stating that language should follow user preference or offering an explicit opt-in/choice.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
|---|---|---|
| 核心(PDF/Word/PPT/Excel/HTML/文本) | `markitdown` | `pip install 'markitdown[pdf,docx,pptx,xlsx]'` |
| 全量(含音频 / YouTube 转写) | `markitdown[all]` | `pip install 'markitdown[all]'` |
| 音频 / 视频转写 | 上者 + **ffmpeg** 系统二进制 | Ubuntu/Debian: `sudo apt-get install -y ffmpeg`;CentOS/RHEL: `sudo yum install -y ffmpeg`;macOS: `brew install ffmpeg` |
| SPA / JS 渲染回退 | 本机 Chrome / Edge(Windows、macOS 免装),Linux 需 chromium | Ubuntu/Debian: `sudo apt-get install -y chromium` 或 `playwright install chromium`;CentOS/RHEL: `sudo yum install -y chromium` |
| 图片 EXIF(可选) | `exiftool` | 系统包管理器安装,缺失则静默跳过元数据 |
| LLM 图像描述 / 文档分析 | `openai` 包 + API Key | `pip install openai`;**默认关闭且需明确同意** |
Confidence
70% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
|---|---|---|
| 核心(PDF/Word/PPT/Excel/HTML/文本) | `markitdown` | `pip install 'markitdown[pdf,docx,pptx,xlsx]'` |
| 全量(含音频 / YouTube 转写) | `markitdown[all]` | `pip install 'markitdown[all]'` |
| 音频 / 视频转写 | 上者 + **ffmpeg** 系统二进制 | Ubuntu/Debian: `sudo apt-get install -y ffmpeg`;CentOS/RHEL: `sudo yum install -y ffmpeg`;macOS: `brew install ffmpeg` |
| SPA / JS 渲染回退 | 本机 Chrome / Edge(Windows、macOS 免装),Linux 需 chromium | Ubuntu/Debian: `sudo apt-get install -y chromium` 或 `playwright install chromium`;CentOS/RHEL: `sudo yum install -y chromium` |
| 图片 EXIF(可选) | `exiftool` | 系统包管理器安装,缺失则静默跳过元数据 |
| LLM 图像描述 / 文档分析 | `openai` 包 + API Key | `pip install openai`;**默认关闭且需明确同意** |
Confidence
70% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Natural-Language Policy Violations

Medium
Confidence
96% confidence
Finding
The entire skill document is written in Chinese, including headings and operational guidance, with no indication that users may choose another language or that the locale is intentionally limited to a specific region. Under the policy, forcing a specific language without user opt-in is a natural-language locale violation.

Natural-Language Policy Violations

Medium
Confidence
95% confidence
Finding
This markdown file switches into Chinese for a substantial instructional section and user-facing notes, but does not indicate that the skill is Chinese-only or offer an alternative language/locale option. That can violate language/locale policy because it effectively forces a specific language for understanding operational guidance without user opt-in.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
**`exiftool`** (optional, for image EXIF metadata):
```bash
# Ubuntu/Debian
sudo apt-get install libimage-exiftool-perl

# macOS
brew install exiftool
Confidence
70% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
**`exiftool`** (optional, for image EXIF metadata):
```bash
# Ubuntu/Debian
sudo apt-get install libimage-exiftool-perl

# macOS
brew install exiftool
Confidence
70% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Natural-Language Policy Violations

Medium
Confidence
90% confidence
Finding
The file is primarily written in English, but the data-safety warning section at L380-L390 switches to Chinese and presents important privacy and data-flow guidance only in that language. This imposes a language constraint on readers without opt-in or explanation, which matches the language/locale policy violation criteria.

Natural-Language Policy Violations

Medium
Confidence
91% confidence
Finding
The natural-language comments state that 'real Chinese prose' and 'English pages' are the detection targets, and the implementation below uses English- and Chinese-specific heuristics only. That creates a language/locale restriction without any opt-in, fallback, or stated business justification, which matches the policy's language/locale concern.

Natural-Language Policy Violations

Medium
Confidence
95% confidence
Finding
The user-visible dependency message is written only in Chinese, which imposes a specific language on users without opt-in. The file does not document that this skill is intentionally limited to Chinese-speaking users or offer any locale selection.

Natural-Language Policy Violations

Medium
Confidence
97% confidence
Finding
The warning printed to stderr is entirely in Chinese and will be shown whenever media backends are missing for a detected media URL. This is a language policy concern because the skill forces one locale for operational messaging without offering a language choice or documenting a justified regional constraint.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
def run_markitdown_on_file(html_path):
    return subprocess.run(markitdown_cmd() + [html_path], capture_output=True, text=True)


def find_browser():
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
for i, cmd in enumerate(attempts):
            try:
                with open(html_path, "w", encoding="utf-8", errors="ignore") as fh:
                    subprocess.run(
                        cmd, stdout=fh, stderr=subprocess.DEVNULL, timeout=90,
                        check=True,
                    )
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Description-Behavior Mismatch

Medium
Confidence
91% confidence
Finding
The script explicitly exposes `--allow-internal`, which disables the private/loopback SSRF guard and permits fetching internal targets. In an agent skill whose advertised purpose is converting user-supplied public URLs, this creates a real SSRF bypass path if an agent, wrapper, or prompt can be induced to pass that flag, enabling access to localhost, cloud metadata, or intranet services.

Natural-Language Policy Violations

Low
Confidence
93% confidence
Finding
The feedback section switches to Chinese-only instructions for bug reports and suggestions, with no English alternative or user opt-in. This is a natural-language locale policy issue because it imposes a specific language on users in an otherwise English README.

Natural-Language Policy Violations

Low
Confidence
95% confidence
Finding
Lines L171-L177 switch to Chinese for important usage guidance about image handling and OCR limitations. Because the file is otherwise primarily in English and does not offer a language/locale option or justify the constraint, this creates a natural-language policy inconsistency under the language-choice rule.

Natural-Language Policy Violations

Low
Confidence
96% confidence
Finding
The code sets a fixed `Accept-Language` header of `zh-CN,zh;q=0.9,en;q=0.8`, which imposes a specific language/locale preference on all fetched requests. This is a natural-language locale policy issue because the user is not offered a choice or opt-in, and the file does not document a justified region-specific requirement.

Static analysis

No suspicious patterns detected.