Back to skill

Security audit

QGIS Agent

Security checks for vulnerabilities and agentic risk

Overview

The skill is purpose-aligned, but it opens a powerful unauthenticated local QGIS control server that can read and write files and optionally run Python code with weak default containment.

Install only for trusted local workflows. Run the server under a low-privilege account, keep it bound to localhost, stop it when not needed, set QGIS_ALLOWED_PATHS to dedicated input/output folders, and do not enable PYQGIS_ENABLE_EXEC unless you are in an isolated environment where arbitrary Python execution is acceptable. Confirm output paths before write or render operations because the server can create or overwrite files.

Vulnerability Patterns
  • Unauthorized Access and Privilege EscalationObtains permissions beyond the task's legitimate needs
  • 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
Findings (4)

T05 · Unauthorized Access and Privilege Escalation

Error
Location
scripts/server.py:85
Finding
Filesystem access controls are disabled by default<![CDATA[ ## Vulnerability Details **File Location**: `scripts/server.py:85-90`, `scripts/server.py:145-148` **Vulnerability Type**: Missing secure-by-default filesystem access control **Risk Level**: High ### Vulnerable Code ```python # Path whitelist (empty = unrestricted) ALLOWED_PATHS = [ os.path.normpath(p.strip().rstrip("/\\")) for p in os.environ.get("QGIS_ALLOWED_PATHS", "").split(";") if p.strip() ] PYQGIS_EXEC_ENABLED = os.environ.get("PYQGIS_ENABLE_EXEC", "0") == "1" ``` ```python def check_paths(args): """Validate that file paths in arguments are inside the whitelist.""" if not ALLOWED_PATHS: return None ``` The same unsafe default is explicitly documented in `SKILL.md:119-121` and `SKILL.md:239-241`. ### Technical Analysis The `QGIS_ALLOWED_PATHS` environment variable is optional. When it is absent or empty, `check_paths()` immediately permits every path. The unauthenticated HTTP API can subsequently pass attacker-controlled paths to QGIS Processing algorithms and to project and rendering operations. Relevant operations include: - `/call`, which passes input and output paths to any registered QGIS Processing algorithm. - `/project/open`, which reads a caller-selected QGIS project. - `/project/save`, which writes a project to a caller-selected path. - `/render`, which writes a PNG to a caller-selected path. Binding the service to `127.0.0.1` reduces network exposure but is not an authorization mechanism. Any local process that can connect to the port can submit requests, including compromised applications, malware operating with the same user privileges, or potentially browser-mediated localhost requests where request construction is possible. The accessible scope is determined by the operating-system privileges of the QGIS server process rather than by the task's legitimate data directory. ### Attack Path 1. The user starts the server without setting `QGIS_ALLOWED_PATHS`, as permitted by the default configura ...[truncated 1476 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Fail closed at startup when no whitelist is configured: ```python raw_allowed_paths = os.environ.get("QGIS_ALLOWED_PATHS", "") if not raw_allowed_paths.strip(): raise RuntimeError("QGIS_ALLOWED_PATHS must be configured") ``` 2. Separate readable input directories from writable output directories. 3. Run the service under a dedicated, low-privilege operating-system account that cannot access unrelated user files. 4. Add authentication, such as a randomly generated bearer token, to every non-health endpoint. 5. Restrict processing providers or algorithms to an explicit allowlist where possible. 6. Prevent accidental replacement of existing files unless the caller explicitly requests overwrite and is authorized. 7. Consider requiring user confirmation outside the server for destructive operations, but do not rely on agent instructions as the primary enforcement mechanism. 8. Log authenticated caller identity, algorithm ID, input paths, output paths, and operation results for security review. ]]>

T05 · Unauthorized Access and Privilege Escalation

Error
Location
scripts/server.py:145
Finding
The configured path whitelist can be bypassed using nested or relative paths<![CDATA[ ## Vulnerability Details **File Location**: `scripts/server.py:145-157` **Vulnerability Type**: Incomplete path validation and directory-containment check **Risk Level**: High ### Vulnerable Code ```python def check_paths(args): """Validate that file paths in arguments are inside the whitelist.""" if not ALLOWED_PATHS: return None for v in args.values() if isinstance(args, dict) else []: if not isinstance(v, str): continue if re.match(r"^[a-zA-Z]:[/\\]", v) or v.startswith("/") or "/" in v and "." in os.path.basename(v): norm = os.path.normpath(v) if not any(norm.lower().startswith(root.lower() + os.sep) or norm.lower().startswith(root.lower() + "/") for root in ALLOWED_PATHS): return v return None ``` ### Technical Analysis The whitelist implementation validates only direct string values in the top-level argument dictionary. It does not recursively inspect: - Lists or tuples. - Nested dictionaries. - Other structured QGIS parameter values containing paths. This is significant because QGIS parameters can legitimately accept lists of files or layers. For example, the supplied documentation shows a raster calculator request using a list-valued `LAYERS` argument. Relative filenames are also skipped unless they contain a forward slash and a dot in the basename. Values such as `secret.gpkg` do not match the drive-letter check, do not start with `/`, and contain no `/`, so they are never compared with the whitelist. The containment check operates on `os.path.normpath()` rather than a canonical resolved path. It does not explicitly resolve symbolic links, junctions, or other filesystem aliases before deciding whether the target is inside an allowed root. Consequently, setting `QGIS_ALLOWED_PATHS` does not reliably enforce the advertised directory boundary. ### Attack Path #### Nested-list bypass 1. An administrator configures `QGIS_A ...[truncated 1806 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Recursively traverse all supported argument structures: ```python def iter_strings(value): if isinstance(value, str): yield value elif isinstance(value, dict): for child in value.values(): yield from iter_strings(child) elif isinstance(value, (list, tuple, set)): for child in value: yield from iter_strings(child) ``` 2. Do not infer whether a value is a path merely from punctuation. Validate path-bearing parameters based on the selected algorithm's parameter definitions. 3. Reject relative filesystem paths, or resolve them against a fixed approved base directory. 4. Canonicalize both roots and requested paths before comparison: ```python from pathlib import Path import os root = Path(configured_root).resolve() candidate = Path(requested_path).resolve(strict=False) if os.path.commonpath([str(root), str(candidate)]) != str(root): raise PermissionError("Path is outside the allowed root") ``` 5. Account for Windows case-insensitivity and drive boundaries without relying on string prefixes. 6. Define an explicit policy for URI-based data sources, network paths, virtual filesystem paths, database connection strings, and QGIS provider strings. 7. Resolve and validate symbolic links and junctions. Where possible, open files using operating-system mechanisms that prevent link traversal or race-condition replacement. 8. Revalidate output paths immediately before creation and avoid following links for security-sensitive writes. 9. Add automated tests for nested lists, nested dictionaries, relative paths, sibling-prefix paths, `..` traversal, mixed separators, symbolic links, junctions, UNC paths, and alternate data-source URI formats. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/server.py:242
Finding
Unbounded HTTP request bodies can exhaust memory or block the service<![CDATA[ ## Vulnerability Details **File Location**: `scripts/server.py:242-249` **Vulnerability Type**: Missing request-size limit **Risk Level**: Medium ### Vulnerable Code ```python def _body(self): length = int(self.headers.get("Content-Length") or 0) if length == 0: return {} try: return json.loads(self.rfile.read(length).decode("utf-8")) except Exception: return {} ``` The server is instantiated as a single-threaded `HTTPServer` at `scripts/server.py:516`: ```python server = HTTPServer(("127.0.0.1", PORT), Handler) ``` ### Technical Analysis The server trusts the client-provided `Content-Length` and attempts to read that number of bytes without enforcing a maximum. This creates two related denial-of-service conditions: - A caller can send a very large body, causing excessive memory allocation during byte storage, UTF-8 decoding, and JSON parsing. - A caller can advertise a large `Content-Length` and transmit the body very slowly or incompletely, causing the single-threaded server to wait while other requests remain unprocessed. No socket read timeout is configured. Because `HTTPServer` processes requests serially, one abusive or stalled connection can make the entire API unavailable. Invalid JSON is silently converted to an empty object, which also prevents callers and operators from clearly distinguishing malformed requests from genuinely empty requests. ### Attack Path 1. A local attacker connects to `127.0.0.1:8767`. 2. The attacker sends a POST request with an extremely large `Content-Length`. 3. The attacker either: - Sends a correspondingly large JSON body to consume memory and CPU, or - Sends the body very slowly or stops before completing it. 4. `_body()` calls `self.rfile.read(length)` with the untrusted size. 5. The single request consumes substantial resources or blocks the only request-handling thread. 6. Health checks and legitimate QGIS requests become unavailable; sufficiently large ...[truncated 411 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Define a strict maximum JSON request size, such as 1 MiB, and reject larger requests with HTTP 413 before reading them: ```python MAX_BODY_BYTES = 1024 * 1024 raw_length = self.headers.get("Content-Length") if raw_length is None: return {} try: length = int(raw_length) except ValueError: raise ValueError("Invalid Content-Length") if length < 0 or length > MAX_BODY_BYTES: self._send(err("Request body too large"), 413) return None ``` 2. Configure socket read and processing timeouts. 3. Use a server implementation with bounded concurrency and robust request limits rather than the development-oriented standard-library `HTTPServer`. 4. Return HTTP 400 for malformed JSON instead of silently treating it as an empty object. 5. Apply rate limits per caller and limit the number of concurrent expensive QGIS jobs. 6. Where appropriate, place the service behind a local authenticated reverse proxy that enforces body-size, connection, and timeout limits. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/server.py:395
Finding
Unbounded rendering dimensions permit memory and CPU exhaustion<![CDATA[ ## Vulnerability Details **File Location**: `scripts/server.py:395-421` **Vulnerability Type**: Missing validation of resource-intensive rendering parameters **Risk Level**: Medium ### Vulnerable Code ```python w = int(body.get("width") or 1024) h = int(body.get("height") or 768) if body.get("extent"): rect = QgsRectangle(*[float(x) for x in body["extent"]]) else: rect = QgsRectangle() for lid in ids: if lid in layers: rect.combineExtentWith(layers[lid].extent()) if rect.isEmpty(): return self._send(err("empty extent")) settings = QgsMapSettings() settings.setLayers([layers[lid] for lid in ids if lid in layers]) settings.setBackgroundColor(QColor(255, 255, 255)) settings.setOutputSize(QSize(w, h)) settings.setExtent(rect) settings.setFlag(settings.Flag.Antialiasing, True) image = QImage(settings.outputSize(), QImage.Format.Format_ARGB32_Premultiplied) image.fill(0xFFFFFFFF) painter = QPainter(image) job = QgsMapRendererCustomPainterJob(settings, painter) ``` ### Technical Analysis The `/render` endpoint converts caller-controlled `width` and `height` values directly to integers and uses them to allocate a `QImage`. No maximum width, maximum height, total-pixel limit, positivity check, render timeout, or job quota is enforced. An ARGB image generally requires multiple bytes per pixel before accounting for renderer working memory, layer data, antialiasing, and output encoding. Very large dimensions can therefore trigger enormous allocations and prolonged rendering work. The server handles requests synchronously, and `job.waitForFinished()` blocks until completion. A single expensive render can make all other endpoints unavailable even if the process does not run out of memory. ### Attack Path 1. A local unauthenticated caller ensures that a project with at least one layer is loaded, or uses an already loaded project. 2. The caller sends a POST request to `/render` with extremely large positive `widt ...[truncated 809 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Require integer dimensions within explicit bounds: ```python MAX_WIDTH = 8192 MAX_HEIGHT = 8192 MAX_PIXELS = 32_000_000 w = int(body.get("width", 1024)) h = int(body.get("height", 768)) if w <= 0 or h <= 0: return self._send(err("Width and height must be positive"), 400) if w > MAX_WIDTH or h > MAX_HEIGHT or w * h > MAX_PIXELS: return self._send(err("Requested render exceeds configured limits"), 413) ``` 2. Set lower limits appropriate for the available memory and expected use cases. 3. Validate `extent` length and require finite numeric coordinates. 4. Limit the number and complexity of layers rendered per request. 5. Add a render timeout and cancel jobs that exceed it. 6. Serialize or queue expensive processing with per-caller quotas rather than allowing unrestricted synchronous work. 7. Monitor render duration, dimensions, pixel count, and memory failures. 8. Combine these controls with API authentication and local rate limiting. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
Findings (19)

Intent-Code Divergence

High
Confidence
99% confidence
Finding
The API advertises /pyqgis as executing arbitrary PyQGIS code, but the implementation uses unrestricted Python exec() with a globals dict that still allows access to Python builtins and imports. This mismatch understates the attack surface and can lead users to enable a full arbitrary-code-execution endpoint under the mistaken belief that it is limited to PyQGIS scripting.

Intent-Code Divergence

High
Confidence
98% confidence
Finding
The documentation claims path allowlisting applies to /call and /pyqgis, but /pyqgis executes arbitrary Python code, which can trivially bypass any string-based path check by constructing paths at runtime, importing os/pathlib, or performing non-file actions entirely outside the validator. This creates a dangerous false sense of protection for operators who may rely on the documented whitelist as a security boundary.

exec() call detected

High
Category
Dangerous Code Execution
Content
t0 = time.time()
        try:
            with redirect_stdout(buf):
                exec(code, g)  # noqa: S102 - 显式开启的逃生舱口
        except Exception as e:
            return self._send(err(e, traceback=traceback.format_exc()[-1500:]))
        return self._send(ok({"stdout": buf.getvalue(),
Confidence
99% confidence
Finding
The /pyqgis endpoint executes user-supplied code with Python exec(), which permits arbitrary Python execution, not just limited PyQGIS operations. Any local process able to reach 127.0.0.1 can read/write files, run OS commands, access network resources through Python, and fully compromise the QGIS process and its data.

Natural-Language Policy Violations

Medium
Confidence
91% confidence
Finding
The entire skill description and user-facing instructions are written in Chinese, with no indication that users may choose another language or that the skill is intended only for a Chinese-language audience. Under the stated policy, forcing a specific language without opt-in is a natural-language policy violation.

External Transmission

Medium
Category
Data Exfiltration
Content
**任意能发 HTTP 请求的智能体**:无需任何客户端,直接 curl:

```bash
curl -s http://127.0.0.1:8767/health
curl -s "http://127.0.0.1:8767/search?q=buffer"
curl -s -X POST http://127.0.0.1:8767/call -H "Content-Type: application/json" \
  -d '{"name":"native:buffer","arguments":{"INPUT":"C:/data/roads.shp","DISTANCE":500,"OUTPUT":"C:/out/roads_buf.gpkg"}}'
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Missing User Warnings

Medium
Confidence
92% confidence
Finding
The README explicitly advertises a `/pyqgis` endpoint that can execute arbitrary PyQGIS code, and the safety warning is brief and easy to overlook relative to the powerful capability being exposed. In an agent-integrated context, even localhost-only execution can let a prompt-driven client run arbitrary local code, access files, or modify projects if the endpoint is enabled, so the documentation should make the risk much more prominent.

External Transmission

Medium
Category
Data Exfiltration
Content
Probe whether the service is up:

```bash
curl -s http://127.0.0.1:8767/health
# {"status":"ok","server":"qgis-agent-server","version":"1.0","algorithms":1000,"providers":6,...}
```
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

External Transmission

Medium
Category
Data Exfiltration
Content
```bash
# Inspect the current project and layers (field list, feature count, CRS)
curl -s http://127.0.0.1:8767/project

# Open / save a project
curl -s -X POST http://127.0.0.1:8767/project/open -H "Content-Type: application/json" -d '{"path":"C:/data/demo.qgz"}'
Confidence
87% confidence
Finding
Although the endpoint is localhost, the skill instructs the agent to send project metadata and filesystem paths over HTTP to a local service and to open/save project files on request. In this context, localhost acts as an inter-process trust boundary; a compromised or overly permissive local server could expose sensitive geospatial data, layer metadata, and enable unauthorized file access or modification.

External Transmission

Medium
Category
Data Exfiltration
Content
```bash
# 查看当前工程与图层(含字段列表、要素数、CRS)
curl -s http://127.0.0.1:8767/project

# 打开 / 保存工程
curl -s -X POST http://127.0.0.1:8767/project/open -H "Content-Type: application/json" -d '{"path":"C:/data/demo.qgz"}'
Confidence
87% confidence
Finding
This is the Chinese-language duplicate of the same localhost project-inspection and open/save workflow. The risk is not classic external transmission, but disclosure of project structure, CRS, field lists, and file paths to a separate local HTTP service plus potential unauthorized file operations if the service is misconfigured or abused.

Natural-Language Policy Violations

Medium
Confidence
92% confidence
Finding
The entire API documentation is written in Chinese, with no indication that language selection is optional or that the skill is intentionally limited to a Chinese-speaking audience. This can constitute a language/locale policy violation when a specific language is imposed without user opt-in or documented justification.

Missing User Warnings

Medium
Confidence
96% confidence
Finding
The documentation describes a `/pyqgis` endpoint that can be enabled to run PyQGIS code, but it does not explicitly warn that this is effectively arbitrary code execution inside the QGIS host process. Even though it is disabled by default and bound to `127.0.0.1`, local exposure still creates substantial risk because any local user, malware, browser-assisted localhost attack, or chained agent/tool misuse could execute code once the flag is enabled.

Natural-Language Policy Violations

Medium
Confidence
92% confidence
Finding
Natural-language policy issues apply to all file types, including markdown. This file forces a specific language for all headings, instructions, and examples, with no opt-in, alternate locale, or justification that the skill is region- or language-specific.

External Transmission

Medium
Category
Data Exfiltration
Content
### 缓冲区
```bash
curl -s -X POST http://127.0.0.1:8767/call -H "Content-Type: application/json" -d '{
  "name": "native:buffer",
  "arguments": {"INPUT":"D:/data/roads.shp","DISTANCE":500,"SEGMENTS":5,
                "END_CAP_STYLE":0,"JOIN_STYLE":0,"OUTPUT":"D:/out/roads_buf_500m.gpkg"}
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Natural-Language Policy Violations

Medium
Confidence
95% confidence
Finding
The module docstring presents the service description, API overview, and safety notes entirely in Chinese, and the runtime startup messages are also emitted in Chinese. This imposes a specific language on users and operators without any documented opt-in, fallback, or locale selection, which matches the language/locale policy violation criteria.

Dynamic import via __import__()

Medium
Category
Dangerous Code Execution
Content
g = {"QgsApplication": QgsApplication, "QgsProject": QgsProject,
             "QgsVectorLayer": QgsVectorLayer, "QgsRectangle": QgsRectangle,
             "QgsWkbTypes": QgsWkbTypes, "Processing": Processing, "registry": REGISTRY,
             "processing": __import__("processing")}
        t0 = time.time()
        try:
            with redirect_stdout(buf):
Confidence
75% confidence
Finding
Dynamic __import__() can load arbitrary modules at runtime, bypassing static analysis and potentially importing malicious code.

Natural-Language Policy Violations

Medium
Confidence
91% confidence
Finding
The response returned by `api_docs()` contains user-facing endpoint descriptions entirely in Chinese. Because these strings are surfaced to clients programmatically and there is no mechanism to request another language or opt in to Chinese, the server enforces a locale choice.

Missing User Warnings

Low
Confidence
76% confidence
Finding
The example instructs the agent to save results as a GeoPackage and render a preview image, which can modify or create user files. The nearby example text does not warn that the workflow may write output files or encourage confirmation before choosing output paths, which is relevant for markdown-based skill descriptions.

Missing User Warnings

Low
Confidence
87% confidence
Finding
The `/call` endpoint allows caller-controlled output paths, which means the service can write files to locations selected by the requester. Without a clear warning and path restrictions, users may underestimate the risk of overwriting files, planting data in sensitive directories, or abusing the service as a filesystem write primitive through agent-driven or local requests.

Missing User Warnings

Low
Confidence
84% confidence
Finding
This markdown file contains many example commands that create output files such as .gpkg, .tif, and .png, which can overwrite existing files or alter user data locations. The document provides usage examples but does not include a user-facing warning about file creation/overwrite effects or the need to verify output paths before running them.

Static analysis

No suspicious patterns detected.