Back to skill

Security audit

Paragraph

Security checks for vulnerabilities and agentic risk

Overview

The skill is a real Paragraph publishing integration, but it gives an agent broad authority to publish, manage subscribers, upload local files, and send credentials to a configurable API host.

Install only if you are comfortable giving the skill authority to publish immediately to your Paragraph account, manage subscribers, and transmit subscriber data to Paragraph. Do not set PARAGRAPH_API_BASE_URL unless you fully trust the destination, and do not allow agents to choose csvPath values from untrusted prompts or documents. Review all post content and subscriber CSV files yourself before invoking write tools, and disable welcome emails unless you have consent to contact recipients.

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
skill.js:9
Finding
Bearer Credential and Sensitive Request Data Can Be Sent to an Arbitrary Configured Host<![CDATA[ ## Vulnerability Details **File Location**: `skill.js`, lines 9–76 **Vulnerability Type**: Unrestricted authenticated API destination **Risk Level**: High ### Vulnerable Code ```javascript const API_BASE = process.env.PARAGRAPH_API_BASE_URL || "https://public.api.paragraph.com/api" ``` ```javascript async function request(method, endpoint, body = null, params = {}, options = {}) { // Read API_KEY from env at call time to respect per-skill injection const apiKey = process.env.PARAGRAPH_API_KEY if (!apiKey) { throw new Error("PARAGRAPH_API_KEY environment variable not set") } const url = new URL(`${API_BASE}${endpoint}`) Object.keys(params).forEach(key => { if (params[key] !== undefined && params[key] !== null) { url.searchParams.append(key, String(params[key])) } }) const headers = { "Authorization": `Bearer ${apiKey}` } let fetchBody = null if (body) { headers["Content-Type"] = "application/json" fetchBody = JSON.stringify(body) } if (options.rawBody) { fetchBody = options.rawBody Object.assign(headers, options.headers) } else if (options.formData) { fetchBody = options.formData // Don't set Content-Type; fetch will set boundary } // Set up abort controller for timeout const controller = new AbortController() const timeoutMs = options.timeout || 30000 // default 30 seconds (POSTs can be slow) const timeoutId = setTimeout(() => controller.abort(), timeoutMs) try { const response = await fetch(url.toString(), { method, headers, body: fetchBody, signal: controller.signal }) ``` The CSV import implementation also constructs its upload destination from the same unrestricted base URL and attaches the API credential: ```javascript const url = new URL(`${API_BASE}/v1/subscribers/import`) url.searchParams.append('sendWelcomeEmail', sendWelcomeEmail) const formData = new FormData() formData.append('file', csvBuffer, 'subscribers.csv') ...[truncated 3064 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. **Allowlist the production API origin** - In normal operation, accept only `https://public.api.paragraph.com`. - Parse the configured value with `new URL()` and compare the normalized protocol, hostname, and port against an explicit allowlist. 2. **Reject insecure protocols** - Reject `http:` and all non-HTTPS schemes. - Reject URLs containing embedded credentials or unexpected ports. 3. **Separate testing from production** - Require an explicit test-mode setting before honoring a custom API base. - Require a separate environment variable such as `PARAGRAPH_TEST_API_KEY` for custom endpoints. - Never forward the production credential to a custom test host. 4. **Control redirects** - Use `redirect: "manual"` or verify every redirect destination before resending an authenticated request. - Do not forward `Authorization` headers across origins. 5. **Minimize credential privileges** - Use server-side API keys scoped only to the operations required by this Skill. - Provide separate read-only and publishing/subscriber-management credentials where supported. 6. **Improve documentation** - Clearly state that changing the API base changes where credentials and request content are transmitted. - Remove the contradictory characterization of the setting as both configurable and “internal, don't change.” Example validation: ```javascript const OFFICIAL_API_ORIGIN = "https://public.api.paragraph.com" const configuredBase = process.env.PARAGRAPH_API_BASE_URL || `${OFFICIAL_API_ORIGIN}/api` const parsedBase = new URL(configuredBase) if ( parsedBase.protocol !== "https:" || parsedBase.origin !== OFFICIAL_API_ORIGIN ) { throw new Error("Untrusted PARAGRAPH_API_BASE_URL") } ``` ]]>

T09 · Insecure Skill Coding Practices

Error
Location
skill.js:440
Finding
Caller-Controlled CSV Path Allows Arbitrary Local Files to Be Read and Uploaded<![CDATA[ ## Vulnerability Details **File Location**: `skill.js`, lines 440–459 **Vulnerability Type**: Arbitrary local-file read and network upload **Risk Level**: High ### Vulnerable Code ```javascript paragraph_importSubscribers: wrapTool(async ({ csvPath, sendWelcomeEmail = true }) => { if (!csvPath) throw new Error("csvPath is required") const fs = await import('fs') const csvBuffer = fs.readFileSync(csvPath) // Build URL with query param const url = new URL(`${API_BASE}/v1/subscribers/import`) url.searchParams.append('sendWelcomeEmail', sendWelcomeEmail) const formData = new FormData() formData.append('file', csvBuffer, 'subscribers.csv') const response = await fetch(url.toString(), { method: "POST", headers: { "Authorization": `Bearer ${process.env.PARAGRAPH_API_KEY}` // Content-type (with boundary) set automatically by fetch when using FormData }, body: formData }) ``` ### Technical Analysis The `paragraph_importSubscribers` tool accepts a raw filesystem path from its caller and passes it directly to `fs.readFileSync`. It performs no path restriction, canonicalization, symbolic-link check, file-type validation, size limit, or CSV parsing before uploading the resulting bytes. Consequently, the operation is not limited to subscriber CSV files. Any file readable by the Node.js process can be selected and transmitted while being labeled `subscribers.csv`. In an Agent environment, tool arguments may be influenced by untrusted prompt content, imported documents, or indirect prompt injection. The issue is amplified by the unrestricted `PARAGRAPH_API_BASE_URL`: an attacker who can influence both the path argument and API base can cause a selected local file to be delivered directly to an attacker-controlled endpoint. Even with the official endpoint, the Skill unnecessarily reads and transmits unvalidated local files. The declared CSV import feature needs access to a user-selected CSV file, but it does no ...[truncated 1615 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. **Avoid arbitrary path input** - Prefer accepting CSV text, a byte array, or a platform-issued file handle from an approved upload mechanism. - Do not expose a general filesystem path as an Agent-controlled tool parameter. 2. **Restrict imports to an approved directory** - Resolve the requested path with `path.resolve`. - Resolve the approved import root with `fs.realpath`. - Verify that the canonical file path remains inside that root. - Reject absolute paths when only workspace-relative paths are needed. 3. **Prevent symbolic-link escapes** - Use `lstat` to reject symbolic links. - Open files with protections such as `O_NOFOLLOW` where supported. - Revalidate the opened file to reduce time-of-check/time-of-use risks. 4. **Validate the file before transmission** - Require an appropriate `.csv` extension as a supplementary check. - Enforce a strict maximum size before reading the whole file. - Parse the content and require documented subscriber headers. - Reject binary data, malformed rows, and unexpected columns. - Validate each email address and wallet address. 5. **Reduce sensitive-data exposure** - Construct a new CSV from validated subscriber fields rather than uploading the original file bytes. - Ensure errors do not include file contents or sensitive local paths. 6. **Combine with destination hardening** - Apply the official-host allowlist and HTTPS controls described in the first finding. - Never permit local-file uploads to arbitrary custom API hosts. A safer design would accept structured subscriber records: ```javascript paragraph_importSubscribers: wrapTool(async ({ subscribers, sendWelcomeEmail = true }) => { if (!Array.isArray(subscribers) || subscribers.length === 0) { throw new Error("subscribers must be a non-empty array") } const validatedCsv = buildValidatedSubscriberCsv(subscribers) // Upload only the newly constructed and validated ...[truncated 15 chars]
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Rogue AgentSelf-Modification, Session Persistence
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
Findings (11)

Session Persistence

Medium
Category
Rogue Agent
Content
## Prerequisites

1. **Paragraph Account**: Create an account at [paragraph.com](https://paragraph.com)
2. **API Key**: Generate one in Account Settings → Integrations
3. **Node.js 19+**: Required for native fetch (OpenClaw uses Node 24+)
Confidence
60% confidence
Finding
Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Session Persistence

Medium
Category
Rogue Agent
Content
### Agent Prompt Examples

**Create a blog post from research findings**
```
You are a content creator. Use the paragraph skill to publish a new blog post
based on today's research notes. Title: "AI Trends 2026", markdown content
Confidence
60% confidence
Finding
Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Missing User Warnings

Medium
Confidence
96% confidence
Finding
The example prompt instructs the agent to publish a blog post from research notes without clearly emphasizing that `paragraph_createPost` publishes immediately and may create permanent public/onchain content. That omission is dangerous because users may interpret the action as a drafting step and unintentionally disclose sensitive or unreviewed material.

Missing User Warnings

Medium
Confidence
91% confidence
Finding
The subscriber add/import sections describe bulk ingestion of emails and wallets and optional welcome emails, but do not prominently warn about consent, privacy obligations, or the risk of contacting users without authorization. In an autonomous agent context, this can lead to unauthorized processing of personal data or unintended outreach at scale.

Intent-Code Divergence

Medium
Confidence
98% confidence
Finding
The README documents a workflow using `published: false` and `paragraph_updatePost`, while elsewhere it clearly states drafts and post updates are unsupported. In an agent setting, contradictory docs can cause the agent or operator to rely on nonexistent safety/review steps and accidentally publish content immediately, including irreversible public/onchain content.

Session Persistence

Medium
Category
Rogue Agent
Content
1. Writer generates markdown draft
2. Publisher Agent calls `paragraph_createPost` with `published: false`
3. Review/approval step
4. Publish: `paragraph_updatePost` with `published: true` OR create with `published: true`
5. If tokenizing: capture coin ID from response, track via `paragraph_getCoin` and `paragraph_listCoinHolders`
6. Analytics: correlate engagement (views, holders) with content performance
Confidence
60% confidence
Finding
Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Missing User Warnings

Medium
Confidence
89% confidence
Finding
The skill enables creation of posts that are published onchain immediately, but the user-facing description does not prominently warn that this action can be effectively irreversible and permanently public. In an agent context, a user may authorize routine publishing without understanding that mistakes, sensitive data, or draft content could be immutably exposed and difficult or impossible to retract.

Missing User Warnings

Medium
Confidence
91% confidence
Finding
The subscriber-management section describes collecting and importing emails and wallet addresses, but does not present a clear privacy and data-handling warning. Because these identifiers are personal data and can link identity, contact information, and onchain activity, an agent could process or migrate subscriber data without adequate user awareness, consent, retention limits, or regulatory safeguards.

Context-Inappropriate Capability

Medium
Confidence
95% confidence
Finding
The subscriber import tool accepts an arbitrary local filesystem path and reads it directly with fs.readFileSync before uploading the contents to the remote Paragraph API. In an agent context, this creates a file-exfiltration primitive: a prompt or indirect instruction could cause the agent to read sensitive host files and transmit them off-box under the guise of CSV import, which is broader than expected for a normal API wrapper.

Missing User Warnings

Medium
Confidence
97% confidence
Finding
The import flow reads a local file and uploads subscriber data to an external service without any built-in consent gate, warning, or confirmation step. In an LLM/agent environment, this makes sensitive-file access and external transmission easier to trigger accidentally or through prompt injection, especially because the tool's purpose appears to be ordinary subscriber management rather than host file handling.

Intent-Code Divergence

Low
Confidence
97% confidence
Finding
The doc comment explicitly states the feed endpoint is public and requires no authentication, yet paragraph_getFeed calls request(), which unconditionally requires PARAGRAPH_API_KEY and sends an Authorization header. This is an active contradiction between documentation and behavior, not merely an omitted detail.

Static analysis

Detected: suspicious.env_credential_access, suspicious.exposed_secret_literal

Environment variable access combined with network send.

Critical
Code
suspicious.env_credential_access
Location
skill.js:9

File appears to expose a hardcoded API secret or token.

Critical
Code
suspicious.exposed_secret_literal
Location
SKILL.md:222