Back to skill

Security audit

Linz Public Transport

Security checks for vulnerabilities and agentic risk

Overview

The skill appears to perform Linz transit lookups, but it allows arbitrary API destinations and defaults to unencrypted HTTP, so it should be reviewed before installation.

Install only if you are comfortable with the skill making outbound transit API requests and can constrain it to the official Linz HTTPS endpoint. Prefer a version that defaults to HTTPS, rejects arbitrary base URLs in normal use, and avoids logging full query URLs containing stop searches.

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

Warning
Location
scripts/linz_transport.py:23
Finding
Unrestricted API Base URL Enables Server-Side Request Forgery<![CDATA[ ## Vulnerability Details **File Location**: `scripts/linz_transport.py:23-39, 43-48, 286-293` **Vulnerability Type**: Server-Side Request Forgery through an unrestricted network destination **Risk Level**: Medium ### Complete Code Snippet ```python def build_url(base_url: str, path: str) -> str: base = base_url.rstrip("/") return f"{base}/{path.lstrip('/')}" def http_get_json(url: str, timeout: int) -> Any: req = urllib.request.Request(url=url, method="GET") try: with urllib.request.urlopen(req, timeout=timeout) as resp: body = resp.read().decode("utf-8") return json.loads(body) except urllib.error.HTTPError as err: raise RuntimeError(f"HTTP {err.code} for {url}") from err except urllib.error.URLError as err: raise RuntimeError(f"Network error for {url}: {err.reason}") from err except json.JSONDecodeError as err: raise RuntimeError(f"Invalid JSON from {url}: {err}") from err def query_efa(base_url: str, endpoint: str, params: dict[str, Any], timeout: int) -> dict[str, Any]: query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) url = build_url(base_url, f"/efa/{endpoint}?{query}") payload = http_get_json(url, timeout) if not isinstance(payload, dict): raise RuntimeError(f"Expected JSON object from {endpoint}") return payload ``` ```python parser.add_argument( "--base-url", default=os.environ.get("LINZ_TRANSPORT_API_BASE_URL", DEFAULT_BASE_URL), help=( "API base URL. Defaults to LINZ_TRANSPORT_API_BASE_URL or " f"{DEFAULT_BASE_URL}." ), ) ``` The corresponding behavior is explicitly documented in `SKILL.md:26-29, 35-38`, which permits the base URL to be supplied through either a command-line argument or an environment variable. ### Technical Analysis The script accepts a caller-controlled base URL and passes the resulting URL directly to `urllib.request.urlopen`. It does not ...[truncated 2338 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Default to and allowlist the official origin: - Scheme: `https` - Host: `www.linzag.at` - Expected path prefix: `/linz2` - Expected port: `443` 2. If custom endpoints are operationally necessary, require an explicit trusted-development option rather than accepting arbitrary destinations during normal execution. 3. Parse custom URLs with `urllib.parse.urlsplit` and reject: - Schemes other than HTTPS - URLs containing usernames or passwords - Unexpected ports - Empty or malformed hostnames - Loopback, private, link-local, multicast, unspecified, and reserved IP addresses 4. Resolve the hostname and validate every returned address before connecting. Revalidate the actual connection destination to reduce DNS-rebinding risk. 5. Disable automatic redirects or validate every redirect target using the same origin and IP-address policy. 6. Apply an execution-level network policy that permits outbound access only to the official transit API host. Application validation should not be the sole SSRF control. 7. Document the security boundary clearly and remove general user-facing encouragement to supply arbitrary base URLs. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/linz_transport.py:20
Finding
Transit Search Data Is Transmitted over Plaintext HTTP<![CDATA[ ## Vulnerability Details **File Location**: `scripts/linz_transport.py:20, 93-108, 158-177` **Vulnerability Type**: Plaintext transmission of potentially sensitive travel-related input **Risk Level**: Medium ### Complete Code Snippet ```python DEFAULT_BASE_URL = "http://www.linzag.at/linz2" ``` ```python def get_stops(base_url: str, name: str, timeout: int) -> list[dict[str, Any]]: payload = query_efa( base_url=base_url, endpoint="XML_STOPFINDER_REQUEST", params={ "locationServerActive": 1, "outputFormat": "JSON", "type_sf": "any", "name_sf": name, }, timeout=timeout, ) ``` ```python def get_departures(base_url: str, stop_id: str, limit: int, timeout: int) -> list[dict[str, Any]]: payload = query_efa( base_url=base_url, endpoint="XML_DM_REQUEST", params={ "locationServerActive": 1, "stateless": 1, "outputFormat": "JSON", "type_dm": "any", "name_dm": stop_id, "mode": "direct", "limit": limit, }, timeout=timeout, ) ``` The insecure default is also declared in `SKILL.md:4, 26-29, 35-38`. In contrast, `references/endpoints.md:5` records successful endpoint probes using `https://www.linzag.at/linz2`, indicating that HTTPS is available. ### Technical Analysis The default API origin uses HTTP rather than HTTPS. Stop names and stop IDs are placed in URL query parameters and transmitted to this endpoint. Stop queries can reveal a user's intended destination, travel activity, or approximate location. Because no TLS protection is present, any network intermediary capable of observing the connection can inspect the complete request URL and transit query. An active intermediary can also modify the API response before it is parsed, allowing departure times, destinations, line information, and stop matches to be manipulated. Network a ...[truncated 1251 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Change the default endpoint to: ```python DEFAULT_BASE_URL = "https://www.linzag.at/linz2" ``` 2. Update `SKILL.md` so every documented default uses HTTPS. 3. Reject HTTP base URLs during normal operation. If plaintext HTTP is required for local development, place it behind a conspicuous development-only flag and emit a warning. 4. Validate TLS certificates using the standard trusted certificate store and do not add certificate-verification bypasses. 5. Combine HTTPS enforcement with a destination allowlist so an attacker cannot replace the secure official endpoint with an arbitrary HTTPS server. 6. Avoid placing more user information than necessary into request parameters and ensure transport-related logs do not retain complete query strings. ]]>

T09 · Insecure Skill Coding Practices

Note
Location
scripts/linz_transport.py:28
Finding
Full Request URLs Expose Stop Queries in Error Logs<![CDATA[ ## Vulnerability Details **File Location**: `scripts/linz_transport.py:28-39, 332-339` **Vulnerability Type**: Sensitive query-string disclosure through stderr and execution logs **Risk Level**: Low ### Complete Code Snippet ```python def http_get_json(url: str, timeout: int) -> Any: req = urllib.request.Request(url=url, method="GET") try: with urllib.request.urlopen(req, timeout=timeout) as resp: body = resp.read().decode("utf-8") return json.loads(body) except urllib.error.HTTPError as err: raise RuntimeError(f"HTTP {err.code} for {url}") from err except urllib.error.URLError as err: raise RuntimeError(f"Network error for {url}: {err.reason}") from err except json.JSONDecodeError as err: raise RuntimeError(f"Invalid JSON from {url}: {err}") from err ``` ```python def main(argv: list[str]) -> int: try: args = parse_args(argv) result = args.handler(args) print(json.dumps(result, ensure_ascii=True, indent=2)) return 0 except RuntimeError as err: print(f"Error: {err}", file=sys.stderr) return 2 ``` The URL passed to `http_get_json` contains either `name_sf=<stop-name>` or `name_dm=<stop-id>`. On an HTTP failure, network failure, or JSON parsing failure, the complete URL is inserted into a `RuntimeError` and printed to stderr. ### Technical Analysis The implementation logs the complete request URL instead of a sanitized endpoint identifier. Since user-provided stop names and stop IDs are encoded into the query string, failures cause these values to be written to stderr. In Agent and automation environments, stderr is commonly captured in command transcripts, centralized logs, telemetry, or debugging records. This creates unnecessary secondary storage of potentially location-related information and can extend its retention beyond the transit query itself. ### Attack Path 1. A user submits a stop name or stop ID. 2. Th ...[truncated 889 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Never include the complete request URL in routine error messages. 2. Parse and sanitize URLs before logging. Retain only the approved origin and endpoint path, excluding the query string and fragment. 3. Replace messages such as: ```python raise RuntimeError(f"HTTP {err.code} for {url}") from err ``` with a sanitized form such as: ```python parsed = urllib.parse.urlsplit(url) safe_endpoint = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" raise RuntimeError(f"HTTP {err.code} for {safe_endpoint}") from err ``` 4. Treat stop names and IDs as potentially sensitive location-related data in logging policies. 5. Review runtime log retention and access controls, and remove previously retained query-bearing error records where appropriate. 6. If detailed diagnostics are necessary, place them behind an explicit debug mode and redact all user-controlled query parameter values. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
Findings (4)

Lp3

Medium
Category
MCP Least Privilege
Confidence
95% confidence
Finding
The skill requires network access and reads an environment variable for its base URL, but it does not explicitly declare a restrictive tool scope such as permissions or allowed-tools. That creates an authorization gap where the runtime may grant broader capabilities than reviewers or policy expect, and the env-provided base URL can direct requests to arbitrary hosts if not tightly constrained.

External Transmission

Medium
Category
Data Exfiltration
Content
# Endpoint Reference

Sources:
- EFA XML API PDF: `https://data.linz.gv.at/katalog/linz_ag/linz_ag_linien/fahrplan/EFA_XML_Schnittstelle_20151217.pdf`
- Live endpoint probes against `https://www.linzag.at/linz2` on February 13, 2026
- Open data catalog RDF: `https://www.data.gv.at/api/hub/repo/datasets/linien-fahrwege-und-haltestellen-der-linz-ag-linien-2025.rdf?useNormalizedId=true&locale=de`
Confidence
50% 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
97% confidence
Finding
The default base URL uses plain HTTP, so queried stop names and stop IDs are transmitted without transport encryption and responses are unauthenticated. This allows network attackers to observe or modify requests and responses, which is especially problematic in an agent/tooling context because the agent may trust manipulated transit data or disclose user-provided queries over insecure networks.

Context-Inappropriate Capability

Medium
Confidence
90% confidence
Finding
The skill allows the API base URL to be overridden via a command-line flag or environment variable, so an operator or calling agent can direct requests to arbitrary hosts unrelated to Linz transport. In an agent setting this broadens the tool from a narrowly scoped public-transport lookup into a generic outbound HTTP client, which can enable policy bypass, unintended data disclosure to third parties, or access to internal services if the runtime environment has network reachability.

Static analysis

No suspicious patterns detected.