T09 · Insecure Skill Coding Practices
Error
- Location
- scripts/index.js:113
- Finding
- API Key Disclosure and Server-Side Request Forgery Through an Unvalidated Download URL<![CDATA[ ## Vulnerability Details **File Location**: `scripts/index.js`, lines 113-120 and 238-241 **Vulnerability Type**: Unvalidated remote URL with credential forwarding **Risk Level**: High ### Vulnerable Code ```js async function downloadZip(credentials, zipUrl) { const res = await fetch(zipUrl, { headers: { 'appian-api-key': credentials.apiKey } }); if (!res.ok) throw new Error(`Download failed [${res.status}]`); const cd = res.headers.get('content-disposition') ?? ''; const fnMatch = cd.match(/filename[^;=\n]*=(['"]?)([^\n"';]+)\1/); const rawName = fnMatch?.[2]?.trim() ?? null; const buf = Buffer.from(await res.arrayBuffer()); return { buf, rawName }; } ``` The remotely supplied URL reaches this function through the deployment polling response: ```js const pollData = await pollExportStatus(credentials, triggerData.uuid); if (!pollData.packageZip) throw new Error('No packageZip URL in response'); const { buf, rawName } = await downloadZip(credentials, pollData.packageZip); ``` ### Technical Analysis The `packageZip` value is obtained from an Appian deployment status response and passed directly to `fetch`. The code does not validate the URL's scheme, hostname, port, embedded credentials, or relationship to the configured `APPIAN_BASE_URL`. More importantly, the `APPIAN_API_KEY` is unconditionally added to the outbound request. If the API response is malicious or compromised, it can direct the Skill to an attacker-controlled host and cause the API key to be disclosed in the `appian-api-key` header. The same behavior creates a server-side request forgery primitive. The response can direct the process to request loopback, link-local, private-network, or other internal destinations accessible from the runtime. Because the entire response is buffered with `arrayBuffer()`, an attacker could also target a large response to consume process memory. ### Attack Path 1. An attacker compromises, impersonates, or otherwi ...[truncated 1417 chars]
- Remediation
- <![CDATA[ ## Remediation Suggestions 1. Parse `packageZip` using the standard `URL` class and reject malformed URLs. 2. Require HTTPS for every download destination. 3. Maintain an explicit allowlist of permitted download origins. Prefer requiring the same origin as `APPIAN_BASE_URL`; if Appian legitimately uses separate storage domains, allowlist only the documented exact hosts. 4. Reject URLs containing embedded usernames or passwords, unexpected ports, fragments, loopback addresses, link-local addresses, and private-network destinations unless explicitly required. 5. Do not attach `APPIAN_API_KEY` to arbitrary download URLs. Attach it only after confirming that the destination is a trusted origin that requires this credential. 6. Consider rejecting cross-origin redirects or manually validate every redirect target before following it. 7. Apply a download timeout and maximum response-size limit. Stream the response while enforcing that limit instead of buffering an unrestricted response with `arrayBuffer()`. 8. Use separate, least-privileged credentials for export operations and rotate the existing key if exploitation is suspected. A hardened design should resemble: ```js function validateDownloadUrl(baseUrl, candidate) { const base = new URL(baseUrl); const target = new URL(candidate); if (target.protocol !== 'https:') { throw new Error('ZIP download must use HTTPS'); } if (target.origin !== base.origin) { throw new Error('Untrusted ZIP download origin'); } if (target.username || target.password) { throw new Error('Embedded URL credentials are not permitted'); } return target; } async function downloadZip(credentials, zipUrl) { const target = validateDownloadUrl(credentials.baseUrl, zipUrl); const res = await fetch(target, { redirect: 'error', headers: { 'appian-api-key': credentials.apiKey } }); // Enforce a strict response-size limit before buffering or streaming to ...[truncated 15 chars]
