Back to skill

Security audit

Ngrok Preview

Security checks for vulnerabilities and agentic risk

Overview

The skill is generally aligned with making ngrok previews, but it needs review because public links may outlive their advertised expiry and unsafe session handling can delete files outside the intended cache area.

Install only if you are comfortable with selected files being copied into a local cache and exposed through a public ngrok URL. Use only narrow, non-sensitive files, avoid directory sources, choose safe session IDs, and manually run down or cleanup immediately after use; do not rely on the displayed expiry to stop access automatically.

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 (2)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/ngrok_preview.py:341
Finding
Path Traversal Through Unsanitized Session Identifiers Enables Out-of-Scope Recursive Deletion<![CDATA[ ## Vulnerability Details **File Location**: `scripts/ngrok_preview.py:341-342`, `scripts/ngrok_preview.py:422`, `scripts/ngrok_preview.py:438-441`, `scripts/ngrok_preview.py:465-467`, and `scripts/ngrok_preview.py:528-530` **Vulnerability Type**: Path traversal and unsafe recursive deletion **Risk Level**: High ### Vulnerable Code ```python session_dir = SESSIONS_DIR / session_id if session_dir.exists(): raise RuntimeError(f"Session already exists: {session_id}") ``` ```python write_json(STATE_DIR / f"{session_id}.json", state) ``` ```python def load_state_by_id(session_id: str) -> tuple[Path, dict[str, Any]]: path = STATE_DIR / f"{session_id}.json" if not path.exists(): raise FileNotFoundError(f"No session state found for: {session_id}") return path, read_json(path) ``` ```python if args.delete_session_dir: session_dir = Path(state.get("workspace_dir", "")) if session_dir.exists() and str(session_dir).startswith(str(SESSIONS_DIR)): shutil.rmtree(session_dir) ``` The cleanup command uses the same unsafe deletion check: ```python session_dir = Path(state.get("workspace_dir", "")) if session_dir.exists() and str(session_dir).startswith(str(SESSIONS_DIR)): shutil.rmtree(session_dir) ``` ### Technical Analysis The user-controlled `--session-id` value is directly incorporated into session-directory and state-file paths without validation. Components such as `..`, path separators, and absolute-path syntax are not rejected. The deletion guard compares unresolved path strings: ```python str(session_dir).startswith(str(SESSIONS_DIR)) ``` String-prefix comparison does not establish filesystem containment. A path such as: ```text /home/user/.cache/openclaw-ngrok-preview/sessions/../sessions-victim ``` starts with the textual sessions-directory prefix but resolves to: ```text /home/user/.cache/openclaw-ngrok-preview/sessions-victim ``` Consequently, the check can approve a directory outside `SESSIONS_DIR` ...[truncated 2138 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Validate every supplied session ID before using it: - Permit only a strict allowlist such as ASCII letters, digits, underscores, and hyphens. - Enforce a reasonable length, such as 1–64 characters. - Reject path separators, `.` and `..` path components, absolute paths, and platform-specific drive syntax. Example: ```python import re SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") def validate_session_id(session_id: str) -> str: if not SESSION_ID_RE.fullmatch(session_id): raise ValueError("Invalid session ID") return session_id ``` 2. Resolve paths and verify semantic containment before every creation, read, write, or deletion: ```python def contained_path(root: Path, child: str) -> Path: resolved_root = root.resolve() candidate = (resolved_root / child).resolve() if not candidate.is_relative_to(resolved_root): raise ValueError("Path escapes the permitted root") return candidate ``` 3. Do not use display identifiers as directory names. Generate an internal random identifier, such as a UUID, and store the user-facing session label only as metadata. 4. Before recursive deletion: - Resolve the candidate and root. - Require `candidate.is_relative_to(root)`. - Reject deletion of the root itself. - Consider opening and tracking session directories through trusted internal state rather than accepting reconstructed paths. 5. Treat state files as untrusted input. Validate the schema, normalize paths, and independently re-check containment instead of trusting `workspace_dir`. 6. Add regression tests covering `../`, nested traversal, absolute paths, symbolic links, prefix-collision directories such as `sessions-victim`, and platform-specific path separators. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/ngrok_preview.py:346
Finding
Advertised TTL Does Not Automatically Terminate the Public Tunnel<![CDATA[ ## Vulnerability Details **File Location**: `scripts/ngrok_preview.py:346`, `scripts/ngrok_preview.py:367-393`, and `scripts/ngrok_preview.py:491-535`; related documentation at `SKILL.md:27-29` and `SKILL.md:45-48` **Vulnerability Type**: Unenforced expiration of publicly exposed artifacts **Risk Level**: Medium ### Vulnerable Code The expiration is calculated only as metadata: ```python created_at = utc_now() expires_at = created_at + timedelta(minutes=args.ttl_minutes) ``` The HTTP server and ngrok tunnel are started without a timer or tunnel-duration restriction: ```python http_proc = subprocess.Popen( [ sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1", "--directory", str(session_dir), ], stdout=http_log, stderr=subprocess.STDOUT, ) ngrok_proc = subprocess.Popen( [ "ngrok", "http", f"127.0.0.1:{port}", "--log=stdout", "--log-format=json", ], stdout=ngrok_log, stderr=subprocess.STDOUT, ) ``` Expiration is enforced only if a caller later runs the cleanup command: ```python def cmd_cleanup(args: argparse.Namespace) -> int: ensure_dirs() now = utc_now() removed: list[str] = [] for path in sorted(STATE_DIR.glob("*.json")): state = read_json(path) session_id = state.get("session_id") try: expires_at = datetime.fromisoformat(state.get("expires_at", "").replace("Z", "+00:00")) except ValueError: expires_at = now should_remove = expires_at <= now if args.force: should_remove = True if not should_remove: continue stop_pid(state.get("ngrok_pid")) stop_pid(state.get("http_pid")) session_dir = Path(state.get("workspace_dir", "")) if session_dir.exists() and str(session_dir).startswith(str(SESSIONS_DIR)): shutil.rmtree(session_dir) `` ...[truncated 2118 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Enforce TTL automatically rather than storing it only as metadata. Use a trusted supervisor that sleeps until the expiration timestamp and then terminates both child processes. 2. Prefer a process architecture in which one session supervisor owns the HTTP and ngrok child processes, monitors their health, and reliably stops them at expiry. 3. If supported by the selected ngrok configuration, also configure a provider-side maximum tunnel duration. Use this as defense in depth rather than relying exclusively on local cleanup. 4. Validate `--ttl-minutes`: - Require a positive value. - Set a conservative maximum. - Reject zero, negative, and excessively large values. 5. Delete copied session content immediately after automatic shutdown unless retention is explicitly required. 6. On startup and before reporting status, reconcile stored states: - Detect expired sessions. - Stop any surviving processes. - Remove copied files. - Mark stale state accurately. 7. Until automatic expiration is implemented, revise the documentation and user-facing output so it does not claim that the link automatically expires. Clearly state that manual `down` or `cleanup` is required. 8. Add integration tests that create a short-lived session, wait beyond the TTL, and verify that the local server, ngrok process, public endpoint, state, and copied artifacts are no longer available. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
Findings (8)

Tp4

High
Category
MCP Tool Poisoning
Confidence
96% confidence
Finding
The declared description says the skill only generates temporary preview links and shares them in Telegram, but the behavior indicates broader functionality such as copying files into persistent workspace/cache areas and maintaining session/process state. This mismatch is dangerous because users and policy systems may trust the narrow description while the implementation actually retains artifacts and manages longer-lived local state, increasing the risk of unintended data exposure through the ngrok-served content or residual cached files.

Lp3

Medium
Category
MCP Least Privilege
Confidence
88% confidence
Finding
The skill requests or implies use of powerful capabilities including shell, filesystem, environment access, and network tunneling, but does not declare any explicit tool scope or permission boundaries. In a skill that exposes local artifacts through ngrok, missing scope constraints increases the chance of overbroad access, accidental publication of sensitive files, or unsafe operator assumptions about what the skill is allowed to do.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
def run(cmd: list[str], *, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        cmd,
        check=check,
        capture_output=capture,
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Internal Network Request

Medium
Category
Server-Side Request Forgery
Content
while time.time() < deadline:
        try:
            with urllib.request.urlopen("http://127.0.0.1:4040/api/tunnels", timeout=1.2) as resp:
                payload = json.loads(resp.read().decode("utf-8"))
        except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
            time.sleep(0.35)
Confidence
70% confidence
Finding
Code issues a request to a loopback, link-local, or private-range host. This can reach internal services not meant to be exposed and is a common SSRF pivot.

Missing User Warnings

Medium
Confidence
96% confidence
Finding
The tool automatically copies arbitrary local files or directories and publishes them through a public ngrok URL without any explicit confirmation, sensitivity check, or access control. In an agent context, this materially increases the risk of unintentional exfiltration of local artifacts, including entire directories, to anyone possessing the link or to third-party infrastructure handling the tunnel.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
http_log = (LOG_DIR / f"{session_id}-http.log").open("a", encoding="utf-8")
    ngrok_log = (LOG_DIR / f"{session_id}-ngrok.log").open("a", encoding="utf-8")

    http_proc = subprocess.Popen(
        [
            sys.executable,
            "-m",
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
stderr=subprocess.STDOUT,
    )

    ngrok_proc = subprocess.Popen(
        [
            "ngrok",
            "http",
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Missing User Warnings

Medium
Confidence
89% confidence
Finding
The `cleanup` command deletes session directories, log files, and state files, and `--force` removes all sessions, but there is no explicit confirmation prompt or prominent warning beyond terse help text. This is a destructive operation affecting stored preview data and metadata, so users may not receive sufficient disclosure before irreversible removal.

Static analysis

No suspicious patterns detected.