GLips/Figma-Context-MCP

MCP server

MCP server to provide Figma layout information to AI coding agents like Cursor

GLipsTypeScript, JavaScript16,180 starsScanned Oct. 22nd, 2026at c083d65v0.13.2View source

Trust score

27/100Failing
Likely 14–40 · medium confidence

Scanned Oct. 22nd, 2026 at c083d65 against 26 rules · How scoring works

For developersOpen rawGET /c/GLips/Figma-Context-MCP/report.json
At a glance

At a glance

Contains
1 MCP server · 2 tools
Its tools can
Reach any network address 1Returns untrusted content 1Create or change data 1Read data 1
Issues flagged
3 critical3 high3 medium9 of 26 rule checks, severity as assessed for this repo
Where issues are
  • Repository-wide4 of 15
  • mcp-server (MCP server)5 of 11
Trust signals
Publisher not verifiedSigned build88 known CVEs in dependenciesNot actively maintained

What to fix first

9 issues, most severe first. Each one points to the exact code, with the recommended fix and a prompt you can paste into your coding agent.

  1. 1CriticalOAuth 2.1 + PKCE Correctness (MCP Authorization)
    mcp-server (MCP server) · ATR-M0005
    Remote StreamableHTTP transport is registered and reachable; the server's default listener host is 127.0.0.1 (loopback), so it is not publicly exposed by default, but the host is operator-configurable (FRAMELINK_HOST/--host) and a non-loopback bind remains a fully supported deployment mode. No OAuth 2.1/PKCE/authorization-server-metadata exists anywhere in the codebase; the HTTP transport instead accepts a raw Figma Personal Access Token or OAuth bearer token as the caller's credential and forwards it to the upstream Figma API -- this is upstream-credential forwarding, not MCP resource-server token validation, and does not satisfy the rule's PKCE/authorization-server requirements for a remote transport. Per the rule's own applicability guidance, a remote-capable transport lacking OAuth 2.1/PKCE support is the VULNERABLE condition regardless of the operator's chosen default bind address.
    export async function startHttpServer(
      host: string,
      port: number,
      baseAuth: FigmaAuthOptions,
      serverOptions: Omit<CreateServerOptions, "transport">,
    ): Promise<Server> {
      ...
      const handlePost = async (req: Request, res: Response) => {
        Logger.log("Received StreamableHTTP request");
        const requestKey = getRequestApiKey(req);
        const requestBearerToken = getRequestBearerToken(req);
        const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken);
        ...
          const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    …
    Show 2 more locations
    function getRequestApiKey(req: Request): string | undefined {
      const value = req.headers["x-figma-token"];
      if (Array.isArray(value)) return value[0]?.trim() || undefined;
      return value?.trim() || undefined;
    }
    
    function getRequestBearerToken(req: Request): string | undefined {
      const value = req.headers.authorization;
      const header = Array.isArray(value) ? value[0] : value;
      const match = header?.match(/^Bearer\s+(.+)$/i);
      return match?.[1]?.trim() || undefined;
    }
    stdio: {
          type: Boolean,
          description: "Run in stdio transport mode for MCP clients",
        },
        noTelemetry: {
          type: Boolean,
          description: "Disable usage telemetry (telemetry is on by default)",
        },
      },
      commands: [fetchCommand],
    });

    +1 more location in the finding JSON

    Recommended fix

    Implement OAuth 2.1 with PKCE for the HTTP/StreamableHTTP transport per the MCP Authorization spec: publish protected-resource metadata, require PKCE + exact redirect-URI match, scope tokens narrowly, and avoid treating a raw Figma PAT/bearer token as the MCP session credential.

  2. 2CriticalNo Authentication or Authorization Checks (General)
    mcp-server (MCP server) · ATR-M0010
    No authn/authz gate sits between the HTTP listener and MCP tool dispatch. requireGlobalCredentials (the only credential-presence check in the codebase) is invoked only for stdio mode; HTTP mode explicitly skips it. In HTTP mode, any caller that reaches the listener and omits X-Figma-Token/Authorization is silently served using the server's own globally-configured Figma credential (shared ambient identity) and can invoke both tools -- one of which writes files to the host disk and both of which spend the operator's Figma API credential -- with no credential of its own.
    const handlePost = async (req: Request, res: Response) => {
        Logger.log("Received StreamableHTTP request");
        const requestKey = getRequestApiKey(req);
        const requestBearerToken = getRequestBearerToken(req);
        const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken);
        const requestSecrets = [requestKey, requestBearerToken].filter(
          (secret): secret is string => !!secret,
        );
        await telemetry.withRequestSecrets(requestSecrets, async () => {
          const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
          const mcpServer = createServer(auth, { ...serverOptions, transport: "http" });
    Show 2 more locations
    function resolveRequestAuth(
      baseAuth: FigmaAuthOptions,
      requestKey: string | undefined,
      requestBearerToken: string | undefined,
    ): FigmaAuthOptions {
      if (requestKey) {
        return { figmaApiKey: requestKey, figmaOAuthToken: "", useOAuth: false };
      }
      if (requestBearerToken) {
        return { figmaApiKey: "", figmaOAuthToken: requestBearerToken, useOAuth: true };
      }
      return baseAuth;
    }
    /**
     * Fail fast when global credentials are required but missing. HTTP mode skips
     * this check so it can accept per-request `X-Figma-Token` headers; ...
     */
    export function requireGlobalCredentials(auth: FigmaAuthOptions): void {
      if (auth.figmaApiKey || auth.figmaOAuthToken) return;
      throw new UsageError(
        "Either FIGMA_API_KEY or FIGMA_OAUTH_TOKEN is required (via CLI argument or .env file)",
      );
    }

    +1 more location in the finding JSON

    Recommended fix

    Require every HTTP request to present its own MCP-level credential before dispatching to any tool; do not fall back to a shared, globally-configured Figma credential for callers who present nothing.

  3. 3CriticalOAuth Proxy Per-Client Consent (Confused Deputy Defense)
    mcp-server (MCP server) · ATR-M0011
    This server is not an OAuth proxy: it does not forward an MCP-client's authorization request to a distinct upstream IdP on that client's behalf, has no dynamic MCP-client-registration endpoint, and mints no 'bridge' authorization codes. However, in HTTP mode, a request that omits the X-Figma-Token/Authorization headers falls back to the server's own globally-configured Figma credential (resolveRequestAuth returns baseAuth, src/server.ts) rather than being rejected. get_figma_data takes a caller-supplied fileKey, fetches that file under the server's ambient Figma credential via FigmaService, and returns the resulting design content to the caller -- so any caller that can reach the HTTP listener without presenting its own credential can read Figma files authorized to the server's own token, selecting which file to read via the fileKey parameter, without the caller having been authorized against that target itself. This is a general confused-deputy condition (ambient-credential use for a caller-selected target without caller-specific authorization) rather than the OAuth-proxy bridge-code scenario the rule's title emphasizes, but the rule's broader clause covering ambient-credential misuse for caller-selected targets applies. Exposure is conditional on global Figma credentials being configured and the HTTP listener being reachable by an untrusted caller; the default loopback bind and the option to supply a caller-specific token both reduce, but do not eliminate, this exposure.
      const handlePost = async (req: Request, res: Response) => {
        Logger.log("Received StreamableHTTP request");
        const requestKey = getRequestApiKey(req);
        const requestBearerToken = getRequestBearerToken(req);
        const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken);
    Show 2 more locations
      if (requestBearerToken) {
        return {
          figmaApiKey: "",
          figmaOAuthToken: requestBearerToken,
          useOAuth: true,
        };
      }
    
      return baseAuth;
    }
        (params: GetFigmaDataParams, extra: ToolExtra) =>
          getFigmaDataTool.handler(
            params,
            figmaService,
            options.outputFormat,
            options.transport,

    +6 more locations in the finding JSON

    Recommended fix

    Require every HTTP request to present and use its own caller-supplied Figma credential for tool calls that access caller-selected targets; do not fall back to a shared, globally-configured Figma credential when a caller presents no credential of its own, or otherwise gate such a fallback behind an explicit operator opt-in with reduced trust applied to the resulting ambient-credential session.

  4. 4HighMissing or Inadequate Input Validation
    Repository-wide · ATR-C0008
    Both MCP tools define zod schemas that validate types and filename syntax, but the download_figma_images nodes array parameter has no cardinality maximum (.max()/length cap). A caller can submit many entries referencing the same gifRef under distinct valid filenames, bypassing fill-URL deduplication since deduplication keys on the fill reference while each filename still starts an independent, concurrently-dispatched download. This inadequately bounded input allows a single tool call to drive an unbounded number of concurrent downloads, risking file-descriptor, connection, and disk exhaustion; schema-level type/filename validation and path containment do not bound this fan-out, and Figma's own API throttling does not cap the number of distinct resolved fill URLs a single call can request.
        })
        .array()
        .describe("The nodes to fetch as images"),
      pngScale: z
        .number()
        .positive()
        .optional()
        .default(2)
        .describe(
          "Export scale for PNG images. Optional, defaults to 2 if not specified. Affects PNG images only.",
        ),
    Show 2 more locations
      if (!options.skipImageDownloads) {
        server.registerTool(
          downloadFigmaImagesTool.name,
          {
            title: "Download Figma Images",
            description: downloadFigmaImagesTool.getDescription(options.imageDir),
            inputSchema: downloadFigmaImagesTool.parametersSchema,
            annotations: { openWorldHint: true },
          },
          (params: DownloadImagesParams, extra: ToolExtra) =>
            downloadFigmaImagesTool.handler(
              params,
              figmaService,
          if (node.gifRef) {
            // GIF fills are always unique downloads (animated, no dedup needed).
            const downloadIndex = downloadItems.length;
            downloadItems.push({ ...downloadItem, gifRef: node.gifRef });
            downloadToRequests.set(downloadIndex, [finalFileName]);
          } else if (node.imageRef) {

    +3 more locations in the finding JSON

    Recommended fix

    Add a maximum array-length (.max()) to the nodes schema in download_figma_images, deduplicate requested downloads by resolved fill/render URL (not only by the stored imageRef/nodeId key) before dispatching, and bound overall concurrent download count independent of the number of distinct requested filenames.

  5. 5HighCORS / Origin / Host Validation (DNS Rebinding Defense)
    mcp-server (MCP server) · ATR-M0003
    The HTTP app is built via the MCP SDK's createMcpExpressApp({ host }), which for the server's default host value (127.0.0.1) automatically installs Host-header validation middleware restricting accepted Host headers to 'localhost'/'127.0.0.1'/'[::1]' -- a real code-level DNS-rebinding defense for that default configuration. Host is operator-configurable via FRAMELINK_HOST/--host, and when set to a non-loopback value, createMcpExpressApp receives no explicit allowedHosts option and the SDK does not apply any Host allowlist for that configuration (it only logs a console warning); this codebase adds no compensating Origin-header validation of its own before dispatch. In that supported non-loopback configuration, neither tool is a pure function of caller input: a request that omits the X-Figma-Token/Authorization headers inherits the server's own configured Figma credential, and the tools fetch and return that credential's design data. A same-origin DNS-rebound request reaching the listener in this configuration can therefore reach tool dispatch and obtain ambient-credentialed data without the SDK's loopback-only Host defense applying. The default loopback configuration remains mitigated by the SDK's automatic Host validation; the supported non-loopback configuration is not.
      const port = resolve(flags.port, envInt("FRAMELINK_PORT", "PORT"), 3333);
      const host = resolve(flags.host, envStr("FRAMELINK_HOST"), "127.0.0.1");
    Show 2 more locations
      const app = createMcpExpressApp({ host });
    
      const handlePost = async (req: Request, res: Response) => {
        Logger.log("Received StreamableHTTP request");
        const requestKey = getRequestApiKey(req);
        const requestBearerToken = getRequestBearerToken(req);
        const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken);
      // Serving StreamableHTTP at /sse lets existing client configs keep working —
      // modern MCP clients probe with a POST before falling back to SSE.
      for (const path of ["/mcp", "/sse"]) {
        app.post(path, handlePost);
        app.get(path, handleMethodNotAllowed);
        app.delete(path, handleMethodNotAllowed);
      }
    

    +3 more locations in the finding JSON

    Recommended fix

    When FRAMELINK_HOST/--host is configured to a non-loopback value, explicitly pass an allowedHosts option (or implement Origin-header validation) to createMcpExpressApp so the DNS-rebinding defense continues to apply outside the SDK's automatic loopback-only default, rather than relying on the operator to self-restrict or on the console warning alone.

Show the other 4 issues
  1. 6HighTransport Security: TLS Everywhere (Remote)
    mcp-server (MCP server) · ATR-M0009
    startHttpServer() listens with plain http via Express's app.listen; there is no https.createServer, no cert/key configuration, and no HSTS anywhere in the repository. Credentials (Figma PAT / OAuth bearer token) travel in request headers to this endpoint on every call, so any deployment reachable beyond loopback (host/port are operator-configurable) transmits them in cleartext absent an externally-added TLS layer that this codebase neither provides nor documents as mandatory.
    return new Promise((resolve, reject) => {
        const server = app.listen(port, host, () => {
          Logger.log(`HTTP server listening on port ${port}`);
          Logger.log(`StreamableHTTP endpoint available at http://${host}:${port}/mcp`);
          Logger.log(
            `StreamableHTTP endpoint available at http://${host}:${port}/sse (backward compat)`,
          );
          resolve(server);
        });
        server.once("error", (err) => {
          httpServer = null;
          reject(err);
        });
        httpServer = server;
    …
    Show 2 more locations
    const port = resolve(flags.port, envInt("FRAMELINK_PORT", "PORT"), 3333);
      const host = resolve(flags.host, envStr("FRAMELINK_HOST"), "127.0.0.1");
    function getRequestApiKey(req: Request): string | undefined {
      const value = req.headers["x-figma-token"];
      if (Array.isArray(value)) return value[0]?.trim() || undefined;
      return value?.trim() || undefined;
    }
    
    function getRequestBearerToken(req: Request): string | undefined {
      const value = req.headers.authorization;
      const header = Array.isArray(value) ? value[0] : value;
      const match = header?.match(/^Bearer\s+(.+)$/i);
      return match?.[1]?.trim() || undefined;
    }
    Recommended fix

    Document and/or enforce running the HTTP transport only behind a TLS-terminating reverse proxy for any non-loopback deployment; consider refusing non-loopback binds without an explicit '--insecure-http-ok' style acknowledgement.

  2. 7MediumDoS Protections: Timeouts, Concurrency, Streaming Limits
    Repository-wide · ATR-C0004
    Outbound fetches to the Figma API and to per-node/per-fill image URLs (src/utils/fetch-json.ts fetchJSON() and the raw fetch(imageUrl) inside src/utils/common.ts downloadFigmaImage()) carry no AbortController or application-level deadline; whether the underlying HTTP stack applies its own default timeout or body-size limits was not independently verified, so the absence of an explicit repo-level limit alone does not establish that a request can hang indefinitely, but no application-controlled deadline exists either way. The download_figma_images tool's nodes array parameter has no .max()/length cap in its zod schema, allowing a single call to request an unbounded number of concurrent image downloads via Promise.all; streamed image writes proceed without writer backpressure or byte-count budgets, and fill-URL deduplication does not bound the number of distinct rendered-node requests or overall download concurrency.
    export async function fetchJSON<T extends { status?: number }>(
      url: string,
      options: RequestOptions = {},
    ): Promise<{ data: T; rawSize: number }> {
      const { redactFromResponseBody = [], ...fetchOptions } = options;
      try {
        const response = await fetch(url, fetchOptions);
    Show 2 more locations
        })
        .array()
        .describe("The nodes to fetch as images"),
    if (fillDownloads.length > 0) {
            downloadPromises.push(Promise.all(fillDownloads));
          }
        }
    
        // Download rendered nodes with processing

    +2 more locations in the finding JSON

    Recommended fix

    Add an AbortSignal-based timeout to both fetchJSON() and the image-download fetch, cap the nodes array length in the download_figma_images schema, and add a request body-size limit / concurrency cap in the HTTP server startup path.

  3. 8MediumDependency Risk & Supply Chain
    Repository-wide · ATR-C0007
    Trivy fs (v0.75.0) found 88 vulnerabilities across pnpm-lock.yaml's resolved dependency graph, including 1 CRITICAL (CVE-2026-90711, proxy-addr 2.0.7 IP-spoofing via IPv4-mapped IPv6 trust-subnet bypass) and numerous HIGH-severity CVEs in undici, js-yaml, hono, fast-uri, and path-to-regexp. undici is a direct runtime dependency of this package (per package.json), while proxy-addr and hono are transitive runtime dependencies of @modelcontextprotocol/sdk (pinned exact 1.29.0), which this server imports directly to run its HTTP transport (src/server.ts); these packages therefore sit on the server's live runtime dependency path rather than dev-only tooling, though the SDK import alone does not prove that each individual transitive CVE's vulnerable code path is exercised. Separately, .github/workflows/release.yml installs a third-party binary (mcp-publisher) via curl | tar xz from a 'latest' GitHub release URL with no checksum or signature verification, and then executes it with GitHub OIDC credentials and registry-publishing access; the owner/release-created gating conditions restrict when this step runs, not the integrity of the downloaded archive. All third-party GitHub Actions are pinned by a mutable major-version tag rather than an immutable commit SHA, further weakening supply-chain integrity even though the project otherwise uses an exact-pinned lockfile for its own npm dependency tree.
    proxy-addr@2.0.7:
        resolution: {integrity: ...}
    Show 2 more locations
    '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
        dependencies:
          '@hono/node-server': 1.19.10(hono@4.12.5)
          ...
          express: 5.2.1
          express-rate-limit: 8.2.1(express@5.2.1)
          hono: 4.12.5
          ...
    import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
    import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
    import { Server } from "http";
    import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

    +37 more locations in the finding JSON

    Recommended fix

    Run pnpm update / check for newer @modelcontextprotocol/sdk and js-yaml releases that resolve the transitive CVEs (or override via pnpm.overrides); pin GitHub Actions to full commit SHAs instead of major-version tags; add checksum/signature verification to the mcp-publisher download step.

  4. 9MediumTool Output Sanitization & Injection Containment
    Repository-wide · ATR-C0013
    The get_figma_data tool returns third-party Figma content (text-layer characters, component/instance names, and other potentially attacker-influenceable strings from a Figma file the server's operator may not control) directly as MCP tool text content, with no structural envelope, no untrusted-content annotation on the tool registration, and no neutralization of prompt-injection markers (fake role/system tags, zero-width/BiDi Unicode, control characters). The only transformation applied (escapeMarkdown in src/transformers/text.ts) escapes CommonMark syntax characters and newline characters purely to keep the YAML/tree serialization well-formed; together with the JSON/YAML/tree serialization and MCP content typing, this is structural data-format normalization, not host-enforced instruction containment. src/mcp/index.ts registers both tools with only readOnlyHint/openWorldHint annotations, surfacing no provenance/untrusted-content signal to the host. This establishes a genuine output-boundary weakness; downstream consequences such as host prompt promotion, secret leakage, or further attacker-directed tool chaining are potential impacts of this gap rather than behaviors reproduced here, since this repository does not itself include a downstream LLM host to exercise against.
    Logger.log(`Successfully extracted data: ${result.metrics.simplifiedNodeCount} nodes`);
        Logger.log("Sending result to client");
    
        return {
          content: [{ type: "text" as const, text: result.formatted }],
        };
      } catch (error) {
    Show 2 more locations
    const MARKDOWN_ESCAPE_CHARS = /[\\*_~[\](){}]/g;
    
    function escapeMarkdown(text: string): string {
      return text.replace(MARKDOWN_ESCAPE_CHARS, "\\$&").replace(/[\n\u2029]/g, "\\n");
    }
    server.registerTool(
        getFigmaDataTool.name,
        {
          title: "Get Figma Data",
          description: getFigmaDataTool.description,
          inputSchema: getFigmaDataTool.parametersSchema,
          annotations: { readOnlyHint: true },
        },
        (params: GetFigmaDataParams, extra: ToolExtra) =>
          getFigmaDataTool.handler(...)
    Recommended fix

    Wrap tool text output in a host-enforced envelope (e.g. <tool_output name="get_figma_data" untrusted="true">...</tool_output>), strip/neutralize Unicode tag characters, zero-width characters, BiDi overrides, and fabricated role markers from Figma-sourced text (layer/component names, text-node characters) before returning it, and annotate the tool with an untrusted-content signal so hosts can apply stricter handling.

What’s inside

BlueRock found 1 component in this repository. Each is checked against the rules for its type.

MCP servermcp-server.28Failing3 critical2 high

Its tools can

Reach any network address 1Returns untrusted content 1Create or change data 1Read data 1

What stood out

  • The HTTP/StreamableHTTP transport (default port 3333) has no OAuth 2.1 authorization layer and no application-level authentication/authorization gate: a caller omitting its own X-Figma-Token/Authorization header is silently served using the operator's globally-configured Figma credential, and both exposed tools have host-side effects (file write, outbound network request carrying that credential) (ATR-M0005, ATR-M0010, HIGH).
  • Transitive runtime dependencies carry 88 known CVEs per Trivy, including 1 CRITICAL (proxy-addr IP-spoofing) and 24 HIGH severity issues across proxy-addr, undici, fast-uri, js-yaml, express-rate-limit, hono, path-to-regexp, @modelcontextprotocol/sdk, and ip-address (ATR-C0007, CRITICAL/HIGH).
  • Tool output from get_figma_data returns Figma file content (text-layer characters, layer/component names) directly into the calling LLM's context with no structural envelope, no untrusted-content annotation, and no sanitization of prompt-injection markers (fake role tags, zero-width/BiDi Unicode); only cosmetic markdown-escaping is applied (ATR-C0013, HIGH).
  • The HTTP transport is plaintext-only with no TLS/HTTPS support in-code; credentials traverse the wire in cleartext unless an operator adds an external TLS-terminating proxy, which is not documented as mandatory (ATR-M0009, HIGH).
  • GitHub Actions are pinned by mutable tags and the release workflow installs a third-party binary via unauthenticated curl|tar with no checksum verification (ATR-C0007, HIGH).
  • No timeouts on outbound fetches to the Figma API or image hosts, and no upper bound on the number of images a single download_figma_images call can request, creating a resource-exhaustion/hang risk (ATR-C0004, MEDIUM).
  • Gitleaks flagged a hardcoded PostHog API key in src/telemetry/client.ts; code comments document it as an intentional write-only public ingest key, a common low-risk SDK pattern, but it represents a hardcoded credential nonetheless.

MCP server rules 5 of 11 flagged

6 rules passedShow
PassedTool Definition Integrity & Versioning (ETDI-style)ATR-M0001Details

Why it got this result

The two tools (get_figma_data, download_figma_images) are statically registered in src/mcp/index.ts at server-start time from code compiled into the published npm package; there is no runtime fetch/hot-reload of tool manifests from a remote source, no dynamic tool-definition update path, and no mechanism for a server operator to push a tool-schema change without a new package version. Tool definitions are therefore immutable per released version, which satisfies the rule's core update-integrity concern: npm OIDC 'trusted publishing' in .github/workflows/release.yml provides registry-verifiable provenance of a given release artifact, but this is a publish-time attestation, not a runtime tool-definition signature-verification or client-side version-pinning mechanism, and should not be described as such.

Why it matters

Prevents rug-pull tool poisoning.

Evidence

function registerTools(
  server: McpServer,
  figmaService: FigmaService,
  options: RegisterToolsOptions,
): void {
  server.registerTool(
    getFigmaDataTool.name,
    {
      title: "Get Figma Data",
      description: getFigmaDataTool.description,
      inputSchema: getFigmaDataTool.parametersSchema,
      annotations: { readOnlyHint: true },
    },
    (params: GetFigmaDataParams, extra: ToolExtra) =>
…
Show 1 more location
permissions:
  contents: write
  pull-requests: write
  id-token: write # Required for npm OIDC trusted publishing

jobs:
  release-please:
    if: ${{ github.repository_owner == 'GLips' }}
    runs-on: ubuntu-latest
PassedUnrestricted Tool Permissions (Overprivileged Tools)ATR-M0002Details

Why it got this result

Only two tools are exposed. get_figma_data is read-only (annotations.readOnlyHint=true) and only calls the Figma REST API -- it has no filesystem or exec capability. download_figma_images can write files, with writes confined by resolveLocalPath()/isWithin() (src/utils/local-path.ts) to lexically resolve within a single configured base directory (imageDir), and filenames constrained by a strict regex to end in .png/.svg/.gif. This containment is lexical only: local-path.ts does not resolve symlinks or junctions, and common.ts's createWriteStream follows an existing destination symlink if one is present. No capability in these tools to create such a symlink was identified, so this is a hardening caveat on an otherwise appropriately-scoped tool set rather than a demonstrated overprivileged-tool exposure; neither tool can execute OS commands, access secrets beyond the Figma credential it already needs for its stated purpose, or perform admin/IAM actions.

Why it matters

Limits blast radius and privilege escalation.

Evidence

export function resolveLocalPath(
  rawPath: string,
  baseDir: string,
  pathImpl: PathImpl = path,
): ResolveLocalPathResult {
  ...
  const base = pathImpl.resolve(baseDir);
  const candidate = pathImpl.isAbsolute(normalized)
    ? pathImpl.resolve(normalized)
    : pathImpl.resolve(base, normalized);

  if (!isWithin(base, candidate, pathImpl)) {
    return { ok: false, reason: "outside_image_dir" };
  }
…
Show 1 more location
fileName: z
  .string()
  .regex(
    /^[a-zA-Z0-9_.-]+\.(png|svg|gif)$/,
    "File names must contain only letters, numbers, underscores, dots, or hyphens, and end with .png, .svg, or .gif.",
  )
PassedBinding to 0.0.0.0 Without Auth/TLSATR-M0004Details

Why it got this result

The server's resolved default bind address is 127.0.0.1 (loopback), set via resolve(flags.host, envStr('FRAMELINK_HOST'), '127.0.0.1') (src/config.ts:151) -- an operator must take a deliberate additional step (CLI flag or env var) to expose the HTTP listener on 0.0.0.0/all-interfaces. The underlying SDK helper (createMcpExpressApp) additionally emits an explicit console warning when host is '0.0.0.0'/'::' recommending allowedHosts or authentication. No auth/TLS is enforced if an operator does override to 0.0.0.0 (tracked separately under ATR-M0009/ATR-M0010), but the rule's own default-safe negative indicator ('bind 127.0.0.1 by default') is satisfied by this codebase's actual default.

Why it matters

LAN/Internet exposure enables remote exploitation.

Evidence

const port = resolve(flags.port, envInt("FRAMELINK_PORT", "PORT"), 3333);
  const host = resolve(flags.host, envStr("FRAMELINK_HOST"), "127.0.0.1");
PassedCORS Misuse: Wildcard with CredentialsATR-M0008Details

Why it got this result

No CORS middleware (no 'cors' package, no hand-rolled Access-Control-Allow-Origin/Access-Control-Allow-Credentials header-setting code) exists anywhere in the repository -- confirmed by exhaustive grep for 'cors'/'Access-Control'/'Origin' across src/, which returned zero hits on any header-emitting code (only unrelated prose comments using the English word 'original'). Because the server never sets Access-Control-Allow-Origin: * (nor any other CORS header), browsers apply the default same-origin policy and cannot read cross-origin responses regardless of credentials -- the specific wildcard+credentials misconfiguration this rule checks for is structurally absent.

Why it matters

Prevents credentialed cross-origin abuse.

Evidence

export async function startHttpServer(
  host: string,
  port: number,
  baseAuth: FigmaAuthOptions,
  serverOptions: Omit<CreateServerOptions, "transport">,
): Promise<Server> {
  ...
  const app = createMcpExpressApp({ host });
  const handlePost = async (req: Request, res: Response) => { ... };
  const handleMethodNotAllowed = (_req: Request, res: Response) => {
    res.status(405).set("Allow", "POST").send("Method Not Allowed");
  };
  for (const path of ["/mcp", "/sse"]) {
    app.post(path, handlePost);
…
PassedTool Name Collision / SpoofingATR-M0006Details

Why it got this result

The server exposes exactly two tools, get_figma_data and download_figma_images (src/mcp/index.ts:81,102). Neither name collides with, or is a homograph of, a well-known tool from a widely-deployed MCP server (e.g. filesystem read_file/write_file, shell execute_command) or common client built-ins. Names are domain-specific and self-descriptive, reducing shadowing risk even without an explicit vendor prefix.

Why it matters

Prevents malicious tool shadowing.

Evidence

server.registerTool(
    getFigmaDataTool.name,
    { ... },
    ...
  );

  if (!options.skipImageDownloads) {
    server.registerTool(
      downloadFigmaImagesTool.name,
      { ... },
PassedPrompt/Metadata Injection in Tool DescriptionsATR-M0007Details

Why it got this result

Both tool descriptions are static, developer-authored strings with no control characters, no Unicode bidi overrides, and no embedded directives aimed at the model (e.g. 'ignore previous instructions', data-exfiltration instructions). get_figma_data's description is a fixed literal; download_figma_images's getDescription() only interpolates the server's own configured imageDir path (a local filesystem path the operator configured, not attacker-controlled remote content) into an otherwise neutral sentence.

Why it matters

Prevents covert model manipulation.

Evidence

export const getFigmaDataTool = {
  name: "get_figma_data",
  description:
    "Get comprehensive Figma file data including layout, content, visuals, and component information",
Show 1 more location
function getDescription(imageDir?: string) {
  const baseDir = imageDir ?? process.cwd();
  return `Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes. Images will be saved relative to the server's image directory: ${baseDir}`;
}
Show its 2 tools
ToolIt can
download_figma_images
Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes; writes files under the configured image directory
Create or change dataReach any network address
get_figma_data
Get comprehensive Figma file data including layout, content, visuals, and component information
Read dataReturns untrusted content

Before you put it to work

  1. Implement OAuth 2.1 + PKCE for the StreamableHTTP transport per the MCP Authorization spec, and require every HTTP request to present and verify its own MCP-level credential before dispatching to any tool; remove the silent fallback to the server's globally-configured Figma credential for unauthenticated callers (ATR-M0005, ATR-M0010).
  2. Upgrade vulnerable third-party dependencies flagged by Trivy (undici, fast-uri, js-yaml, proxy-addr, express-rate-limit, hono, path-to-regexp, @modelcontextprotocol/sdk) to patched versions, and apply pnpm overrides where fixed versions are not yet available.
  3. Wrap get_figma_data/download_figma_images text output in a host-enforced envelope, strip Unicode tag/zero-width/BiDi characters and fabricated role markers from Figma-sourced text, and mark the tools' returned content as untrusted to prevent prompt injection.
  4. Document and/or enforce that the HTTP transport only be run behind a TLS-terminating reverse proxy for any non-loopback deployment (ATR-M0009).
  5. Pin GitHub Actions to full commit SHAs and add checksum/signature verification to the mcp-publisher binary download in release.yml.
  6. Add AbortSignal-based timeouts to all outbound fetch() calls (Figma API and image downloads) and cap the nodes array length in the download_figma_images schema to prevent resource exhaustion.
  7. When binding to a non-loopback host, explicitly pass allowedHosts or add Origin-header validation instead of relying on the SDK's loopback-only automatic DNS-rebinding protection.

Repository-wide rules

Checked once for the whole repository: dependencies, secrets, inputs, networking, CI and more. 4 of 15 flagged.

Could not determinePath Traversal & Symlink Bypass (Filesystem Tools)ATR-C0011Details

Why it got this result

The lexical containment checks in resolveLocalPath() (src/utils/local-path.ts) and the isWithin()-based check in downloadFigmaImage() (src/utils/common.ts) block '../' sequences and absolute-path escapes before any write occurs, and fileName is independently constrained by a zod regex preventing traversal characters in the filename component. config.ts's resolvePath calls operate on operator-supplied CLI flags/env vars at startup, not remote/tool-caller input. However, neither check resolves symlinks or junctions: a reproduction placing a symlink inside the configured imageDir that points to an outside directory caused fs.createWriteStream() to write a file outside the base directory, demonstrating a conditional bypass of the lexical containment. Whether an untrusted caller can plant or select such a symlink in a real deployment's imageDir -- i.e. directory ownership and symlink-creation capability -- is not established by the repository. Given that lexical path escapes are blocked but symlink-based containment is not enforced and the attacker-capability prerequisite for exploiting it is unproven, neither a categorical SECURE nor a fully substantiated VULNERABLE verdict is supported.

Why it matters

Traversal exposes arbitrary host files; can lead to code execution.

Evidence

export function resolveLocalPath(
  rawPath: string,
  baseDir: string,
  pathImpl: PathImpl = path,
): ResolveLocalPathResult {
  ...
  if (!isWithin(base, candidate, pathImpl)) {
    return { ok: false, reason: "outside_image_dir" };
  }
  return { ok: true, resolvedPath: candidate };
}
export function isWithin(base: string, candidate: string, pathImpl: PathImpl = path): boolean {
  const rel = pathImpl.relative(base, candidate);
  return rel === "" || (!rel.startsWith("..") && !pathImpl.isAbsolute(rel));
…
Show 2 more locations
const { fileKey, nodes, localPath, pngScale } = parametersSchema.parse(params);
const baseDir = imageDir ?? process.cwd();
const resolution = resolveLocalPath(localPath, baseDir);
if (!resolution.ok) {
  ...
  return { isError: true, content: [{ type: "text" as const, text: details.userMessage }] };
}
const resolvedPath = resolution.resolvedPath;
export async function downloadFigmaImage(
  fileName: string,
  localPath: string,
  imageUrl: string,
): Promise<string> {
  try {
    if (!fs.existsSync(localPath)) {
      fs.mkdirSync(localPath, { recursive: true });
    }
    const fullPath = path.resolve(path.join(localPath, fileName));
    if (!isWithin(localPath, fullPath)) {
      tagError(new Error(`File path escapes target directory: ${fileName}`), {
        category: "invalid_input",
      });
…
9 rules passed · 1 did not applyShow
PassedRuntime Command Injection via Process ExecutionATR-C0003Details

Why it got this result

No process-execution sinks (child_process.exec/execSync/spawn, eval, new Function) exist anywhere in shipped application runtime code (src/**, excluding tests and scripts). The only child_process usages in the repository are test-only (spawn("tsx", ...) in src/tests/server.test.ts) and a developer/CI tooling script (scripts/scan-hidden-chars.mjs), which imports execSync but only to run a constant git ls-files command with no interpolation of external or caller-controlled input. Both usages fall within excluded execution contexts (test_tooling / developer_tooling / ci_cd_workflow) and are not reachable from the published package's runtime entry points (dist/bin.js -> src/bin.ts -> src/server.ts).

Why it matters

Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.

Evidence

child = spawn("tsx", ["src/bin.ts", `--figma-api-key=test-key`, `--port=${TEST_PORT}`], {
PassedWeaponization: Malicious-Purpose Artifact (Offensive Capability by Design)ATR-R0001Details

Why it got this result

The repository is a legitimate, openly-maintained MCP server (figma-developer-mcp / Framelink) whose declared and actual purpose is to fetch Figma design data and expose it to AI coding agents for implementing UI designs. No offensive/malicious capability (malware, credential theft, exploit generation, evasion) is present in README, SKILL-equivalent descriptions, tool descriptions, or source.

Why it matters

An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.

Evidence

{
  "name": "figma-developer-mcp",
  "version": "0.13.2",
  "mcpName": "io.github.GLips/Figma-Context-MCP",
  "description": "Give your coding agent access to your Figma data. Implement designs in any framework in one-shot.",
PassedUnrestricted Network Fetch / SSRFATR-C0001Details

Why it got this result

All Figma API calls (src/services/figma.ts) target a hardcoded baseUrl = "https://api.figma.com/v1"; no tool parameter or client input can change the host. Image downloads (src/utils/common.ts -> downloadFigmaImage) fetch imageUrl values, but those URLs are not attacker/caller-supplied -- they come exclusively from Figma's own API response (getImageFillUrls/getNodeRenderUrls in figma.ts), keyed by imageRef/nodeId lookups against that response. A caller can only select WHICH of Figma's own returned URLs to fetch, not supply an arbitrary host. There is no tool parameter that accepts a raw destination URL.

Why it matters

Enables internal pivoting and data exfiltration.

Evidence

export class FigmaService {
  private readonly apiKey: string;
  private readonly oauthToken: string;
  private readonly useOAuth: boolean;
  private readonly baseUrl = "https://api.figma.com/v1";
Show 1 more location
const fillRef = gifRef ?? imageRef;
            const imageUrl = fillRef ? fillUrls[fillRef] : undefined;
            return imageUrl
              ? downloadAndProcessImage(
PassedStatic SecretsATR-C0002Details

Why it got this result

Gitleaks (v8.30.1) flagged one generic-api-key match: a hardcoded PostHog project key in src/telemetry/client.ts:10. Manual review confirms this is a write-only PostHog ingest key for anonymous telemetry, explicitly documented in an adjacent comment as 'intentionally embedded in the published package -- it's a public ingest key that cannot read data, only send events.' This matches the standard client-side analytics-key pattern (comparable to a Sentry DSN or PostHog public API key), which is designed to be publicly embedded and provides no read access to any account data; it is not a credential whose exposure grants access to a protected resource. Figma credentials (FIGMA_API_KEY/FIGMA_OAUTH_TOKEN) are only ever read from process.env/CLI flags/an uncommitted .env file (src/config.ts resolveAuth), with .env.example shipping only placeholder values ('your_figma_api_key_here').

Why it matters

Plaintext secrets are easily exfiltrated.

Evidence

Code not shown: this line may hold a secret.

Show 2 more locations

Code not shown: this line may hold a secret.

Code not shown: this line may hold a secret.

PassedInsecure DeserializationATR-C0009Details

Why it got this result

No unsafe deserializers are reachable at runtime. js-yaml is used in runtime code only via yaml.dump() (src/utils/yaml-dump.ts) to serialize outgoing data for LLM consumption with the restrictive JSON_SCHEMA; yaml.load() is called only within the test suite (e.g. integration.test.ts, serialization.test.ts) against the project's own fixture/test output, not against untrusted runtime input, so these test-only call sites do not constitute an exploitable unsafe-deserialization path. The only other parse site reachable at runtime is JSON.parse() on the Figma API's own HTTP response body in src/utils/fetch-json.ts, standard JSON parsing of data from the fixed https://api.figma.com host.

Why it matters

Can lead to arbitrary code execution.

Evidence

import yaml from "js-yaml";
export function dumpYaml(value: unknown): string {
  return yaml.dump(value, {
    noRefs: true,
    lineWidth: -1,
    noCompatMode: true,
    schema: yaml.JSON_SCHEMA,
  });
}
Show 1 more location
const text = await response.text();
const rawSize = Buffer.byteLength(text, "utf8");
const data = JSON.parse(text) as T;
return { data, rawSize };
PassedCI/CD Workflow Script InjectionATR-C0014Details

Why it got this result

Only two workflows exist (ci.yml, release.yml) plus a local composite action (.github/actions/setup). ci.yml triggers on pull_request to main and runs fixed lint/test/typecheck commands with no interpolation of any github.event.*/github.head_ref/inputs.* expression into a shell run block. release.yml triggers only on push to main, additionally gated by github.repository_owner == 'GLips', and the only interpolated values are release-please action outputs (steps.release.outputs.major/minor/patch) -- numeric semantic-version components produced by a constrained, trusted action rather than attacker-controlled PR titles, branch names, or issue content. The resulting shell variable is consumed via jq --arg (safe parameter binding); the safety of this step rests on the narrow, action-controlled source of the interpolated values and the trusted push-only execution context, not merely on the VERSION assignment being quoted. No pull_request_target, issue_comment, workflow_run, or repository_dispatch triggers exist anywhere in the repo.

Why it matters

CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.

Evidence

- name: Update server.json version
  run: |
    VERSION="${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}"
    jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp && mv server.tmp server.json
  if: ${{ steps.release.outputs.release_created }}
Show 1 more location
on:
  pull_request:
    branches:
      - main
jobs:
  ci:
    ...
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup
      - name: Scan for hidden characters
        run: node scripts/scan-hidden-chars.mjs
      - name: Check formatting
        run: pnpm prettier --check "src/**/*.ts"
PassedLogging Hygiene (No Secrets/PII/Tokens) & Audit TrailATR-C0005Details

Why it got this result

Logger.log/Logger.error calls throughout src/services/figma.ts and src/server.ts never include the raw API key/OAuth token/Authorization header value -- auth-related logs state only the auth MODE ('Using OAuth Bearer token...', 'Using Personal Access Token...'). A dedicated redaction system exists: telemetry/client.ts maintains a global redactionSecrets list plus a per-request AsyncLocalStorage-based withRequestSecrets() context so that per-request HTTP credentials (X-Figma-Token / Authorization: Bearer, which arrive only at request time and aren't known at startup) are scrubbed from error messages before they reach telemetry capture. fetch-json.ts additionally redacts configured secrets from any captured HTTP response body before attaching it to a thrown error. A dedicated test file (src/tests/telemetry-redaction.test.ts) specifically exercises that a per-request secret embedded in an upstream error message is redacted end-to-end before being captured.

Why it matters

Prevents sensitive data leakage; enables forensics.

Evidence

let redactionSecrets: string[] = [];
const requestSecrets = new AsyncLocalStorage<readonly string[]>();
export function withRequestSecrets<T>(
  secrets: readonly string[],
  fn: () => Promise<T>,
): Promise<T> {
  const filtered = secrets.filter(Boolean);
  if (filtered.length === 0) return fn();
  return requestSecrets.run(filtered, fn);
}
Show 1 more location
private getAuthHeaders(): Record<string, string> {
    if (this.useOAuth) {
      Logger.log("Using OAuth Bearer token for authentication");
      return { Authorization: `Bearer ${this.oauthToken}` };
    }
    ...
    Logger.log("Using Personal Access Token for authentication");
    return { "X-Figma-Token": this.apiKey };
PassedLack of Output Encoding/SanitizationATR-C0010Details

Why it got this result

Tool output is plain text/YAML/tree-format content primarily consumed by an LLM client, not rendered into an HTML UI; however, the repository's own fetch CLI command (src/commands/fetch.ts) prints this serialized output to stdout, so the output is not exclusively machine-only. Text content embedded into the server's structured (markdown-flavored) output format is escaped via escapeMarkdown() in src/transformers/text.ts, and JSON.stringify-based serialization (src/utils/serialize-tree.ts and the JSON output path) applies standard JSON-string encoding to tree name/text fields -- relevant, applicable encoding for those output formats, though not a general-purpose HTML or terminal-escape-sequence sanitizer. (Note: the broader concern of unsanitized third-party content reaching the LLM as a prompt-injection vector is separately captured under ATR-C0013, which is VULNERABLE.)

Why it matters

Prevents UI-level injection and operator compromise.

Evidence

const MARKDOWN_ESCAPE_CHARS = /[\\*_~[\](){}]/g;
function escapeMarkdown(text: string): string {
  return text.replace(MARKDOWN_ESCAPE_CHARS, "\\$&").replace(/[\n\u2029]/g, "\\n");
}
PassedDenial of Wallet / Cost ControlsATR-C0012Details

Why it got this result

The only external, reachable API dependency is the Figma REST API, accessed with the operator's own Figma Personal Access Token or OAuth token; this server does not integrate a separate metered/paid compute or token service billed to the server operator, and no server-side cost quota or billing configuration is present in the code. The 429-handling code (src/services/errors/rate-limit.ts, src/utils/fetch-json.ts RETRYABLE_STATUSES) reports rate-limit errors back to the caller; it is error-surfacing, not a cost-control or quota mechanism in itself. PostHog telemetry uses a write-only, free-tier ingestion key, but the code does not establish PostHog's pricing terms or otherwise guarantee no incremental telemetry cost. The unbounded image-download fan-out described in ATR-C0004 is a genuine availability/resource-exhaustion weakness, but no operator-billable per-call dependency beyond the user's own Figma account is established in this codebase, so denial-of-wallet exposure specifically is not demonstrated.

Why it matters

Prevents cost-based disruption.

Evidence

} catch (error) {
      const meta = getErrorMeta(error);
      if (meta.http_status === 429) {
        throw new Error(buildRateLimitMessage(error), { cause: error });
      }
Does not applyContainer/Runtime HardeningATR-C0006Details

Why it got this result

The repository ships no container or IaC deployment artifact of any kind -- no Dockerfile, docker-compose, Helm chart, Kubernetes manifest, or Terraform. Enumerating all tracked files and reviewing both GitHub workflows confirms package scripts run Node directly with no containerization/IaC artifact present in the repository; the .gitignore entries exclude dependency and build-output directories but do not themselves establish this absence beyond what the file-inventory review shows. This rule's threat model (Docker USER/capabilities, Helm securityContext, hostNetwork, etc.) does not apply to an architecture with no containerization artifact in the repo.

Why it matters

Reduces blast radius of exploits.

Evidence

(absence) -- `find . -iname "Dockerfile*" -o -iname "docker-compose*"` returns no results anywhere in the repository (excluding node_modules, which is also absent from this checkout).
From build to runtime

You know what it is. Now see what it does.

A scan helps you understand what you’re building with. When your agent runs, BlueRock shows you what actually happens across tools and systems. Follow actions in context, understand behavior as it unfolds and add controls when you need them.

BUILDRegistry + scan
RUNSee actual execution
OPERATEObserve, control, optimize

Results reflect the code at the commit and date shown.