Back to skill

Security audit

OpenClaw LinkedIn Poster Skill

Security checks for vulnerabilities and agentic risk

Overview

This LinkedIn posting skill is mostly purpose-aligned, but it handles posting authority and OAuth credentials in ways that need careful review before installation.

Install only if you are comfortable granting this skill authority to publish public LinkedIn posts, including organization posts when those scopes are granted. Prefer a version that uses a local or verifiably controlled OAuth callback, cryptographically random state, secure token storage, narrow scopes, and an explicit preview/confirmation step before every post.

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

T09 · Insecure Skill Coding Practices

Error
Location
runner.cjs:92
Finding
OAuth Authorization Code Routed Through an Unverifiable Third-Party Callback Service<![CDATA[ ## Vulnerability Details **File Location**: `runner.cjs:6-7, 92-124` **Vulnerability Type**: Untrusted intermediary in OAuth authorization flow **Risk Level**: High ### Vulnerable Code ```js const CALLBACK_SERVER = 'https://linkedin-oauth-server-production.up.railway.app'; const REDIRECT_URI = `${CALLBACK_SERVER}/callback`; ``` ```js async function startOAuthFlow() { const state = Date.now().toString(); const authUrl = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${process.env.LINKEDIN_CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(SCOPE)}&state=${state}`; console.log("\nPlease authorize the application by visiting this URL:\n"); console.log(authUrl); console.log("\nOpening browser..."); const startCmd = process.platform == 'darwin' ? 'open' : process.platform == 'win32' ? 'start' : 'xdg-open'; exec(`${startCmd} "${authUrl}"`); console.log("\nWaiting for authorization (this may take a few seconds)..."); let code = null; for (let i = 0; i < 60; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); try { const response = await fetch(`${CALLBACK_SERVER}/api/token/${state}`); if (response.ok) { const data = await response.json(); code = data.code; break; } } catch (e) { // Continue polling } } if (!code) { throw new Error("Authorization timeout. Please try again."); } ``` ### Technical Analysis LinkedIn sends the OAuth authorization code to a hosted Railway service rather than directly to the local client. The client then retrieves the code by polling an endpoint keyed only by the OAuth state value. The callback server's implementation is not included in the audited project. Consequently, the audit cannot verify its authentication, authorization, code retenti ...[truncated 2261 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Replace the hosted callback relay with a loopback redirect such as `http://127.0.0.1:<random-port>/callback`. 2. Use PKCE with a cryptographically random `code_verifier` and corresponding `code_challenge` where supported. 3. If LinkedIn offers a device authorization flow appropriate to this client, prefer it over a shared callback relay. 4. If the relay must remain: - Publish and independently review its source code. - Authenticate callback retrieval rather than treating knowledge of state as authorization. - Bind each record to a separate high-entropy client secret or public-key proof. - Encrypt temporary authorization records. - Enforce short expiration and atomic one-time consumption. - Never log authorization codes or tokens. - Document ownership, hosting controls, retention policy, and incident response procedures. 5. Revoke existing authorizations and reauthorize users after replacing or hardening the callback infrastructure. ]]>

T09 · Insecure Skill Coding Practices

Error
Location
runner.cjs:92
Finding
Predictable OAuth State Enables Authorization Session Confusion<![CDATA[ ## Vulnerability Details **File Location**: `runner.cjs:92-93, 108-116` **Vulnerability Type**: Predictable OAuth CSRF/session-binding value **Risk Level**: High ### Vulnerable Code ```js async function startOAuthFlow() { const state = Date.now().toString(); const authUrl = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${process.env.LINKEDIN_CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(SCOPE)}&state=${state}`; ``` ```js let code = null; for (let i = 0; i < 60; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); try { const response = await fetch(`${CALLBACK_SERVER}/api/token/${state}`); if (response.ok) { const data = await response.json(); code = data.code; break; } } catch (e) { // Continue polling } } ``` ### Technical Analysis OAuth state is intended to be an unguessable value that binds an authorization response to the client session that initiated it. Here, state is only the current Unix time in milliseconds. A timestamp has low effective entropy when an attacker can estimate when the Skill was invoked. The client also uses knowledge of this value as the only visible means of retrieving a code from the shared callback service. No independent nonce, authenticated retrieval credential, local session binding, or explicit validation metadata is present in the audited client. This weakness is amplified by the use of a shared remote callback server. An attacker who can predict candidate timestamps may attempt to race or pre-populate callback records, enumerate nearby state values if the service permits it, or create an authorization response associated with the same state. ### Attack Path 1. The attacker estimates when the victim will invoke the LinkedIn Skill. 2. The attacker generates candidate state values from timestamps near that time. 3. The attacker initiat ...[truncated 1227 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Generate state with a cryptographically secure random number generator: ```js const crypto = require('crypto'); const state = crypto.randomBytes(32).toString('base64url'); ``` 2. Use at least 128 bits of unpredictable entropy. 3. Bind state to the local authorization attempt and validate it exactly before accepting a code. 4. Store state only for the duration of the flow and delete it after successful use or timeout. 5. Enforce short expiry and atomic one-time consumption on the callback side. 6. Add PKCE so possession or substitution of an authorization code alone is insufficient to complete the exchange. 7. Rate-limit callback polling and reject attempts to retrieve records using invalid or expired state values. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
runner.cjs:57
Finding
LinkedIn Bearer Token Stored in Plaintext Without Explicit Restrictive Permissions<![CDATA[ ## Vulnerability Details **File Location**: `runner.cjs:57-75` **Vulnerability Type**: Insecure storage of a sensitive bearer credential **Risk Level**: Medium ### Vulnerable Code ```js function loadToken() { if (fs.existsSync(TOKEN_FILE)) { try { const data = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8')); if (data.expires_at && Date.now() > data.expires_at) { console.log("Token expired."); return null; } return data.access_token; } catch (e) { console.error("Error reading token file:", e); return null; } } return null; } function saveToken(tokenData) { const data = { access_token: tokenData.access_token, expires_at: Date.now() + (tokenData.expires_in * 1000) }; fs.writeFileSync(TOKEN_FILE, JSON.stringify(data)); console.log(`\nToken saved to ${TOKEN_FILE}`); } ``` ### Technical Analysis The LinkedIn bearer token is serialized in plaintext to `.linkedin_token` inside the Skill directory. The write operation does not specify a restrictive file mode, and the load operation does not verify ownership, file type, or permissions. For newly created files, actual permissions depend on the process umask. On shared systems, permissive configuration may allow other users to read the token. If the file already exists, writing it does not correct pre-existing insecure permissions. Keeping a bearer credential in a project or Skill directory also increases the risk of accidental backup, packaging, synchronization, or source-control inclusion. A bearer token grants access based on possession; an attacker does not need the LinkedIn client secret to reuse a valid token against permitted API endpoints. ### Attack Path 1. The legitimate user completes OAuth. 2. `saveToken()` writes the access token in plaintext to `.linkedin_token`. 3. A local user, compromised process, backup service, synchr ...[truncated 978 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Store tokens in an operating-system credential manager or encrypted application secret store. 2. If file storage is unavoidable, create an application-private directory and write the file with owner-only permissions: ```js fs.writeFileSync(TOKEN_FILE, JSON.stringify(data), { mode: 0o600, flag: 'w' }); fs.chmodSync(TOKEN_FILE, 0o600); ``` 3. Verify that the file is a regular file owned by the current user before reading it. 4. Reject symlinks and use safe file-opening flags where available to reduce link-based attacks. 5. Add `.linkedin_token` to `.gitignore` and packaging exclusion rules. 6. Avoid printing sensitive token contents in errors or logs. 7. Request only the scopes needed for the selected operation. Do not request organization scopes for personal-only posting. 8. Provide explicit token revocation and deletion functionality. 9. Advise existing users to revoke tokens if the file may have been exposed. ]]>

T09 · Insecure Skill Coding Practices

Error
Location
runner.cjs:92
Finding
Shell Command Injection Through Unsanitized LinkedIn Client ID<![CDATA[ ## Vulnerability Details **File Location**: `runner.cjs:92-102` **Vulnerability Type**: OS command injection **Risk Level**: High ### Vulnerable Code ```js async function startOAuthFlow() { const state = Date.now().toString(); const authUrl = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${process.env.LINKEDIN_CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(SCOPE)}&state=${state}`; console.log("\nPlease authorize the application by visiting this URL:\n"); console.log(authUrl); console.log("\nOpening browser..."); const startCmd = process.platform == 'darwin' ? 'open' : process.platform == 'win32' ? 'start' : 'xdg-open'; exec(`${startCmd} "${authUrl}"`); ``` ### Technical Analysis `LINKEDIN_CLIENT_ID` is interpolated into `authUrl` without URL encoding or format validation. The resulting URL is then inserted into a command string passed to `child_process.exec()`. `exec()` invokes a command shell. Quoting the URL with double quotes does not make the operation safe because an attacker-controlled client ID can contain a double quote and shell metacharacters. On Unix-like systems, command substitution syntax may also be interpreted within double quotes. Windows command parsing has different but similarly dangerous metacharacter and quoting behavior. An attacker must be able to influence the Skill's environment or configuration. This boundary is relevant because the documented setup reads the client ID from `openclaw.json`; malicious configuration, a compromised deployment process, or another component capable of modifying the environment can convert configuration control into arbitrary OS command execution. ### Attack Path 1. The attacker gains the ability to modify `LINKEDIN_CLIENT_ID` in the Agent's environment or `openclaw.json`. 2. The attacker supplies a value containing shell syntax that closes or abuses the quoted URL argument. 3. The ...[truncated 1095 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Do not use `exec()` to open the browser. 2. Construct the authorization URL with `URL` and `URLSearchParams`, ensuring every parameter is encoded: ```js const authUrl = new URL('https://www.linkedin.com/oauth/v2/authorization'); authUrl.search = new URLSearchParams({ response_type: 'code', client_id: process.env.LINKEDIN_CLIENT_ID, redirect_uri: REDIRECT_URI, scope: SCOPE, state }).toString(); ``` 3. Invoke the platform program without a shell: ```js const { spawn } = require('child_process'); const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; const openerArgs = process.platform === 'win32' ? ['/c', 'start', '', authUrl.toString()] : [authUrl.toString()]; spawn(opener, openerArgs, { shell: false, detached: true, stdio: 'ignore' }).unref(); ``` 4. On Windows, prefer a reviewed browser-opening library or a shell-free platform API because `start` is a shell built-in and requires special handling. 5. Validate `LINKEDIN_CLIENT_ID` against LinkedIn's documented identifier format before use. 6. Treat environment and Agent configuration as security-sensitive, restricting modification to trusted administrators. 7. Add tests containing quotes, spaces, command substitutions, and platform-specific shell metacharacters to confirm that they are passed only as data. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Trigger AbuseOverly Broad Trigger, Shadow Command Trigger, Keyword Baiting Trigger
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
Findings (17)

Credential Access

High
Category
Privilege Escalation
Content
## How It Works
1. When triggered, the script checks for a valid local token.
2. If no token exists (or it's expired), it starts the OAuth flow and prompts you to log in.
3. Once authorized, it exchanges the code for an access token via the secure callback server.
4. The token is saved locally (`.linkedin_token`) for future requests.
5. Your update is posted immediately to your profile.
Confidence
91% confidence
Finding
The README states that OAuth tokens are exchanged via a pre-configured third-party callback server and then saved locally in `.linkedin_token`, which introduces credential-handling risk. A remote callback service can observe authorization data, and local token storage without documented protections may allow token theft and unauthorized posting to personal or company pages.

Credential Access

High
Category
Privilege Escalation
Content
The first time you use the skill, it will:
1. Open a browser for LinkedIn authorization
2. Save the access token locally
3. Use the saved token for future posts

## Usage
Confidence
90% confidence
Finding
The skill states that it saves the access token locally for future use but provides no details about encryption, storage location, file permissions, or lifecycle controls. A locally stored bearer token can be stolen by malware, other local users, or backup/sync systems and then reused to post as the victim or associated organization.

Credential Access

High
Category
Privilege Escalation
Content
throw new Error("Authorization timeout. Please try again.");
    }

    console.log("\n✅ Authorization received! Exchanging for access token...");
    
    const tokenData = await exchangeCodeForToken(code);
    saveToken(tokenData);
Confidence
90% confidence
Finding
The skill obtains and persists a LinkedIn access token with broad social and organization scopes, creating a valuable credential that can be abused if the local token file is exposed. In this skill context, the token enables posting as the user and potentially to administered organizations, making compromise more impactful than a narrow read-only token.

Vague Triggers

Medium
Confidence
93% confidence
Finding
The trigger phrase 'Post to LinkedIn' is broad and resembles a natural-language request an agent could match too eagerly, increasing the chance of unintended social media posting. Because this skill performs an external side effect on a real account, ambiguous activation raises the risk of accidental or prompt-influenced execution.

Description-Behavior Mismatch

Medium
Confidence
94% confidence
Finding
The manifest says the skill posts to the user's LinkedIn profile, but the documentation also enables posting to organization/company pages. This scope mismatch can cause users or orchestrators to grant or invoke broader publishing capabilities than expected, increasing the chance of unintended posting to business-owned accounts.

Missing User Warnings

Medium
Confidence
92% confidence
Finding
The skill describes posting functionality but does not clearly warn that invocation will publish content publicly to a user's profile or company page. Without an explicit disclosure and confirmation, users may trigger irreversible public posts through natural-language prompts without understanding the consequence.

Missing User Warnings

Medium
Confidence
95% confidence
Finding
The documentation normalizes use of a shared hosted OAuth callback server without warning users that OAuth authorization data will transit through third-party infrastructure. Even if implemented correctly, this introduces trust, interception, logging, and multi-tenant exposure risks that are especially sensitive because the skill also stores reusable access tokens.

Description-Behavior Mismatch

Medium
Confidence
96% confidence
Finding
The skill metadata says it posts to the user's LinkedIn profile, but the requested scopes and code also support posting to organizations/pages. That scope expansion can mislead users and reviewers about what authority is being granted, increasing the chance of unintended posts to managed organizations.

External Transmission

Medium
Category
Data Exfiltration
Content
params.append('client_id', process.env.LINKEDIN_CLIENT_ID);
    params.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET);

    const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: params
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Context-Inappropriate Capability

Medium
Confidence
95% confidence
Finding
The organization lookup enumerates organizations where the user is an administrator, which is broader than a profile-posting skill needs. This expands data access and enables actions against organization accounts that users may not expect from the declared purpose.

External Transmission

Medium
Category
Data Exfiltration
Content
console.log(`\nSearching for organization: "${orgName}"...`);
    
    // Fetch organizations where user is an admin
    const url = 'https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED&projection=(elements*(organizationalTarget~(name)))';
    
    const response = await fetch(url, {
        headers: {
Confidence
88% confidence
Finding
This call transmits the user's bearer token to LinkedIn to enumerate organizations they administer, a capability beyond the stated profile-posting purpose. In context, the danger is not the HTTPS request itself but the unnecessary collection and use of broader account/organization data.

External Transmission

Medium
Category
Data Exfiltration
Content
urn = await findOrganizationUrn(accessToken, orgName);
    } else {
        // Fallback to personal profile
        const profileResponse = await fetch('https://api.linkedin.com/v2/userinfo', {
            headers: { 'Authorization': `Bearer ${accessToken}` }
        });
Confidence
50% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

External Transmission

Medium
Category
Data Exfiltration
Content
urn = await findOrganizationUrn(accessToken, orgName);
    } else {
        // Fallback to personal profile
        const profileResponse = await fetch('https://api.linkedin.com/v2/userinfo', {
            headers: { 'Authorization': `Bearer ${accessToken}` }
        });
Confidence
50% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

External Transmission

Medium
Category
Data Exfiltration
Content
urn = await findOrganizationUrn(accessToken, orgName);
    } else {
        // Fallback to personal profile
        const profileResponse = await fetch('https://api.linkedin.com/v2/userinfo', {
            headers: { 'Authorization': `Bearer ${accessToken}` }
        });
Confidence
50% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

External Transmission

Medium
Category
Data Exfiltration
Content
}
    };

    const postResponse = await fetch('https://api.linkedin.com/v2/ugcPosts', {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${accessToken}`,
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Description-Behavior Mismatch

Low
Confidence
93% confidence
Finding
The manifest context says the skill posts updates to the user's LinkedIn profile via OAuth, which implies a personal-profile scope. However, the README explicitly advertises posting to company pages and organization pages as well, expanding behavior beyond the stated manifest description.

Context-Inappropriate Capability

Low
Confidence
78% confidence
Finding
The skill invokes a platform shell opener through `child_process.exec` to open the OAuth URL in the user's browser. While OAuth itself is expected for LinkedIn posting, spawning a shell command is a stronger local-execution capability than the manifest description communicates.