diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 21ec1dd..bfbfcd8 100755 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -47,7 +47,7 @@ docker pull ghcr.io/g-core/fastedge-mcp-server:latest | Variable | Required | Default | Purpose | | ----------------------- | -------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `GCORE_API_KEY` | Yes | — | API authentication (legacy `FASTEDGE_API_KEY` also accepted) | -| `GCORE_API_BASE` | No | Baked at build time (prod: `api.gcore.com`) | Runtime override for the Gcore API base URL. In-house devs set this to `https://api.preprod.world` to hit preprod with prod schemas. | +| `GCORE_API_BASE` | No | Baked at build time (prod: `api.gcore.com`) | Runtime override for the Gcore API base URL. In-house devs set this to `https://api.preprod.world` to hit preprod with prod schemas. Must be an allowlisted origin (`ALLOWED_API_ORIGINS` in `src/api-client.ts`), otherwise the server exits at startup. | | `BATCH_MAX_CALLS` | No | `5` | Max calls per `batch_execute` invocation. Bump for batches that exceed 5 steps (total runtime still capped at 3 min). | | `WORKSPACE_ROOT` | No | `/workspace` (Docker) / cwd (local) | Where the server looks for user files for build/upload operations. Usually left at the Docker default. | diff --git a/README.md b/README.md index dd04651..453807f 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Up-to-date SDK, platform, and example documentation is served through the `faste Make sure to set the following environment variables: - `GCORE_API_KEY` (required) - Your Gcore API key for authentication (legacy `FASTEDGE_API_KEY` also accepted). -- `GCORE_API_BASE` (optional) - Runtime override for the Gcore API base URL. Defaults to `https://api.gcore.com` (baked at build time). In-house devs can set this to `https://api.preprod.world` to test against preprod endpoints using prod schemas. +- `GCORE_API_BASE` (optional) - Runtime override for the Gcore API base URL. Defaults to `https://api.gcore.com` (baked at build time). In-house devs can set this to `https://api.preprod.world` to test against preprod endpoints using prod schemas. Only `https://api.gcore.com` and `https://api.preprod.world` are accepted; any other value stops the server at startup so the API key can't be sent elsewhere. - `BATCH_MAX_CALLS` (optional) - Override the default max calls per `batch_execute` (default: 5). See [DEVELOPMENT.md](./DEVELOPMENT.md) for the full env var table and the preprod build recipe. diff --git a/STANDALONE-SETUP.md b/STANDALONE-SETUP.md index 255e380..4566477 100644 --- a/STANDALONE-SETUP.md +++ b/STANDALONE-SETUP.md @@ -14,28 +14,63 @@ Create a file called `.vscode/mcp.json` in your workspace with the following con ```json { - "servers": { - "fastedge-assistant": { - "type": "stdio", - "command": "bash", - "args": [ - "-c", - "docker run --rm -i --pull=always -v ${workspaceFolder}:/workspace -e WORKSPACE_ROOT=/workspace -e HOST_UID=$(id -u) -e HOST_GID=$(id -g) -e \"GCORE_API_KEY=$GCORE_API_KEY\" ghcr.io/g-core/fastedge-mcp-server:latest" - ], - "env": { - "GCORE_API_KEY": "your_api_key_here" - } + "servers": { + "fastedge-assistant": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "--rm", + "-i", + "--pull=always", + "-v", + "${workspaceFolder}:/workspace", + "-e", + "WORKSPACE_ROOT=/workspace", + "-e", + "GCORE_API_KEY", + "ghcr.io/g-core/fastedge-mcp-server:latest" + ], + "env": { + "GCORE_API_KEY": "${env:GCORE_API_KEY}" + } + } } - } } ``` -### Step 2: Start VS Code +This shows the direct-Docker `args` shape. The VS Code extension generates the same `args`, but differs in how it stores the key (it prompts for it and writes the value into `env`; `${env:GCORE_API_KEY}` is only used for its Codespaces-secret flow) and it pins a versioned image tag instead of `latest`: + +- `docker` is called directly (no `bash -c` wrapper), so it works unchanged on Linux, macOS and Windows. +- `-e GCORE_API_KEY` with no value makes Docker forward the variable from the environment the client starts it with. The `env` block reads it from your own environment, so the key is never written into a file you might commit. + +### Step 2: Provide your API key + +Set `GCORE_API_KEY` in the environment you start VS Code from, then start VS Code from that shell: + +```bash +export GCORE_API_KEY="your_api_key" +code . +``` + +Create a key in the Gcore Customer Portal under **API tokens**. + +### Step 3: Start VS Code 1. Open VS Code in your workspace 2. The MCP server will automatically pull the Docker image and start 3. No repository cloning required! +## Other MCP Clients + +The `args` array is the same for every client. What differs is the surrounding shape and how variables are written: + +- **Claude Desktop, Cursor and most others** use `"mcpServers"` instead of `"servers"`. Cursor keeps `"type": "stdio"`; most others have no `"type"` field. +- **The workspace path** uses the client's own variable syntax (Cursor: `${workspaceFolder}`; elsewhere an absolute path). +- **The key**: drop the `env` block. Cursor passes its host environment through, so Docker's `-e GCORE_API_KEY` forwards it by name, as it does for any client that inherits the environment. A Cursor launched from the Dock/Finder doesn't see shell exports; on macOS set it at the GUI-session level with `launchctl setenv GCORE_API_KEY "your_api_key"` and restart Cursor. Add `-e GCORE_API_BASE` to `args` only if you override the API base. + +Claude Code and Codex users should install the [`gcore-fastedge` plugin](https://github.com/G-Core/fastedge-plugin) instead, which configures this server for you. + ## What This Does - Pulls `ghcr.io/g-core/fastedge-mcp-server:latest` from GitHub Container Registry @@ -50,15 +85,16 @@ You can test the Docker image manually: ```bash docker run --rm -i --pull=always \ -v "$(pwd):/workspace" \ - -e "WORKSPACE_ROOT=/workspace" \ - -e HOST_UID=$(id -u) -e HOST_GID=$(id -g) \ - -e "GCORE_API_KEY=your_api_key" \ + -e WORKSPACE_ROOT=/workspace \ + -e GCORE_API_KEY \ ghcr.io/g-core/fastedge-mcp-server:latest ``` ## Permissions -The container entrypoint automatically detects the owner of the `/workspace` mount and drops privileges to that UID/GID so generated files are not root-owned. If builds fail with "Permission denied", pass `-e HOST_UID=$(id -u) -e HOST_GID=$(id -g)` to `docker run` (both `docker run` examples above already include these flags). +The container entrypoint automatically detects the owner of the `/workspace` mount and drops privileges to that UID/GID, so generated files are not root-owned. When the mount appears as root-owned (for example on Docker Desktop, where mounts are virtualized), it falls back to UID/GID 10001 rather than running as root. + +If builds fail with "Permission denied", or generated files end up with the wrong owner, add `"-e", "HOST_UID=", "-e", "HOST_GID="` to `args` (on Linux/macOS, `id -u` and `id -g` print them). In a shell, that is `-e HOST_UID=$(id -u) -e HOST_GID=$(id -g)`. ## Requirements diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md index 275edd4..b4f3f4d 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -14,6 +14,24 @@ See `SEARCH_GUIDE.md` for more search patterns. --- +## [2026-10-01] - docs: standalone config matches the VS Code extension + +`STANDALONE-SETUP.md` and `mcp-standalone.json` showed the old `bash -c "docker run … -e \"GCORE_API_KEY=$GCORE_API_KEY\" …"` form with an inline `your_api_key_here` placeholder (and `HOST_UID=$(id -u)` flags in the doc). They now show the same shape the VS Code extension's "FastEdge (Generate mcp.json)" writes and the portal's agent-onboarding "Other agents" tab shows: `docker` called directly with an argv array (works on Windows, no shell splicing), bare `-e GCORE_API_KEY` forwarded from the client, and `"GCORE_API_KEY": "${env:GCORE_API_KEY}"` so the key is read from the user's environment instead of being written into a file that is often committed. `HOST_UID`/`HOST_GID` moved to the Permissions section as an override only — the entrypoint detects the `/workspace` owner and falls back to 10001 (SA-004). Added an "Other MCP Clients" section (`mcpServers` shape, per-client variable syntax). + +--- + +## [2026-10-01] - security: GCORE_API_BASE allowlist + +`GCORE_API_BASE` was read from the environment and only checked to be a valid URL, so any MCP config that sets it (e.g. a cloned repo's `.vscode/mcp.json` running our image) could point every authenticated request — and the operator's `GCORE_API_KEY` — at an arbitrary host. The existing origin guard in `callGcoreApi()` only stopped *paths* escaping the configured base; it trusted the base itself. + +**Fix** — `src/api-client.ts`: `ALLOWED_API_ORIGINS` (`https://api.gcore.com`, `https://api.preprod.world`) and `allowedApiOrigin()`. Exact origin match (scheme + host + port, no suffix matching), userinfo rejected. A non-allowlisted base exits at startup, before any request carries the key. The list is deliberately not runtime-configurable: adding a host is an image release. The baked-in base (from `SPEC_BASE_URL` at schema generation) is validated too rather than trusted — an earlier draft auto-allowed it; Codex (MoM) review flagged that a build with any other `SPEC_BASE_URL` would then send the key anywhere. `upload-binary` (`src/tools/api/binaries/api.ts`) uses `GCORE_API_BASE` directly and is covered by the same startup check. + +Tests: `allowedApiOrigin` case in `scripts/tests/test-api.ts`. Docs: DEVELOPMENT.md env table, README. + +Not covered: a workspace MCP config that swaps the image or command entirely already runs arbitrary code; this closes the "looks like ours" case only. + +--- + ## [2026-08-26] - security: OS command injection via shell:true build/scaffold sinks (ICM-50570) External report (two confirmed PoCs, commit 30f5967): `normalizePath()` (`src/utils/index.ts`) blocks `..` traversal and absolute/Windows-drive paths but never sanitized shell metacharacters (`;`, `"`, `&`, `|`, `$`, backticks). Its output reached shell-executing sinks unescaped — `scaffold-fastedge-project` (`src/tools/local/scaffolding/scaffolds.ts`) built an `npx` command string for `child_process.exec` (always shell-backed) by interpolating the normalized `outputDir`; `build-wasm`'s JS/TS path (`src/tools/local/workspace/compiler/jsBuild.ts`) called `child_process.spawn(..., { shell: true })`. Either let an attacker-controlled `outputDir`/`entryFile` (from a malicious repo an agent scaffolds/builds, or a direct HTTP/SSE tool call) run arbitrary commands with the operator's `GCORE_API_KEY` in the process env. diff --git a/mcp-standalone.json b/mcp-standalone.json index d7b3cc8..e742bb7 100644 --- a/mcp-standalone.json +++ b/mcp-standalone.json @@ -1,15 +1,24 @@ { - "servers": { - "fastedge-assistant": { - "type": "stdio", - "command": "bash", - "args": [ - "-c", - "docker run --rm -i --pull=always -v ${workspaceFolder}:/workspace -e WORKSPACE_ROOT=/workspace -e \"GCORE_API_KEY=$GCORE_API_KEY\" ghcr.io/g-core/fastedge-mcp-server:latest" - ], - "env": { - "GCORE_API_KEY": "your_api_key_here" - } + "servers": { + "fastedge-assistant": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "--rm", + "-i", + "--pull=always", + "-v", + "${workspaceFolder}:/workspace", + "-e", + "WORKSPACE_ROOT=/workspace", + "-e", + "GCORE_API_KEY", + "ghcr.io/g-core/fastedge-mcp-server:latest" + ], + "env": { + "GCORE_API_KEY": "${env:GCORE_API_KEY}" + } + } } - } } diff --git a/scripts/tests/test-api.ts b/scripts/tests/test-api.ts index 00c588d..5a83bbb 100644 --- a/scripts/tests/test-api.ts +++ b/scripts/tests/test-api.ts @@ -5,12 +5,14 @@ */ import { test } from "node:test"; +import { spawnSync } from "node:child_process"; import assert from "node:assert/strict"; import { createServer, type Server } from "node:http"; import { AddressInfo } from "node:net"; import { DEFAULT_TIMEOUT_MS, + allowedApiOrigin, callGcoreApi, resolveTimeoutMs, serializeBody, @@ -318,6 +320,46 @@ test("callGcoreApi: refuses to send the API key off the configured origin", asyn ); }); +test("allowedApiOrigin: only exact Gcore API origins may receive the key", () => { + for (const base of [ + "https://api.gcore.com", + "https://api.gcore.com/", + "https://API.gcore.com", // hosts are case-insensitive + "https://api.preprod.world", + "https://api.cdb-staging.cdn.orange.com", + "https://api.controlcenter.internationalcarriers.orange.com", + ]) { + assert.ok(allowedApiOrigin(base), `expected ${base} to be allowed`); + } + for (const base of [ + "http://api.gcore.com", // plaintext + "https://api.gcore.com:8443", // other port + "https://api.gcore.com.attacker.example", // look-alike suffix + "https://attacker.example/api.gcore.com", + "https://user:pass@api.gcore.com", // userinfo + "https://attacker.example", + "http://localhost:8080", + "blob:https://api.gcore.com", // inherits an https origin + "not a url", + "", + ]) { + assert.equal(allowedApiOrigin(base), null, `expected ${JSON.stringify(base)} to be rejected`); + } +}); + +test("startup: GCORE_API_BASE is enforced when api-client loads", () => { + const run = (base: string) => + spawnSync( + process.execPath, + ["--import", "tsx", "-e", 'import("./src/api-client.ts")'], + { env: { ...process.env, GCORE_API_BASE: base }, encoding: "utf8" }, + ); + const bad = run("https://attacker.example"); + assert.equal(bad.status, 1); + assert.match(bad.stderr, /not an allowed Gcore API URL/); + assert.equal(run("https://api.gcore.com").status, 0); +}); + test("checkAllowed: denies paths that manipulate the request authority", () => { // Each of these normalizes onto an allowlisted path, but concatenating it // onto GCORE_API_BASE changes the host or the requested path. diff --git a/src/api-client.ts b/src/api-client.ts index 510b832..0528e28 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -9,15 +9,47 @@ import { GCORE_API_BASE as BAKED_GCORE_API_BASE } from "./generated/config.js"; export const GCORE_API_BASE = process.env.GCORE_API_BASE || BAKED_GCORE_API_BASE; -// Validate the base URL at startup so a misconfigured GCORE_API_BASE fails -// fast with a readable message rather than throwing inside a request handler. -let GCORE_API_ORIGIN: string; -try { - GCORE_API_ORIGIN = new URL(GCORE_API_BASE).origin; -} catch { - console.error(`Fatal: GCORE_API_BASE "${GCORE_API_BASE}" is not a valid URL. Set a correct URL (e.g. https://api.gcore.com) and restart.`); +/** + * Origins the API key may be sent to. GCORE_API_BASE comes from the + * environment, which any MCP config can set (e.g. a cloned repo's + * `.vscode/mcp.json`) — without this list, a config that looks like ours + * could point the key at any host. Exact origins only (scheme + host + port), + * no suffix matching, so `http://`, look-alike hosts and odd ports are all + * rejected. The baked-in base is checked too, not trusted: a build with any + * other SPEC_BASE_URL fails at startup. Adding a host is a code change and an + * image release by design — never make this list configurable at runtime. + */ +export const ALLOWED_API_ORIGINS: ReadonlySet = new Set([ + "https://api.gcore.com", + "https://api.preprod.world", + "https://api.cdb-staging.cdn.orange.com", + "https://api.controlcenter.internationalcarriers.orange.com", +]); + +/** Origin of `base` if it parses and is on the allowlist, otherwise null. */ +export function allowedApiOrigin(base: string): string | null { + try { + const url = new URL(base); + // blob:https://host inherits that origin, so origin alone isn't enough. + if (url.protocol !== "https:") return null; + // userinfo would ride along with every request, next to the key. + if (url.username || url.password) return null; + return ALLOWED_API_ORIGINS.has(url.origin) ? url.origin : null; + } catch { + return null; + } +} + +// Validate the base URL at startup so a misconfigured or hostile +// GCORE_API_BASE fails fast, before any request can carry the key. +const resolvedOrigin = allowedApiOrigin(GCORE_API_BASE); +if (!resolvedOrigin) { + console.error( + `Fatal: GCORE_API_BASE "${GCORE_API_BASE}" is not an allowed Gcore API URL (allowed: ${[...ALLOWED_API_ORIGINS].join(", ")}). Unset it to use the default.`, + ); process.exit(1); } +const GCORE_API_ORIGIN: string = resolvedOrigin; export const DEFAULT_TIMEOUT_MS = 60_000;