T09 · Insecure Skill Coding Practices
- 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") } ``` ]]>
