Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
godronus marked this conversation as resolved.
- `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.
Expand Down
70 changes: 53 additions & 17 deletions STANDALONE-SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<uid>", "-e", "HOST_GID=<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

Expand Down
18 changes: 18 additions & 0 deletions context/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
godronus marked this conversation as resolved.

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.
Expand Down
33 changes: 21 additions & 12 deletions mcp-standalone.json
Original file line number Diff line number Diff line change
@@ -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}"
}
}
}
}
}
42 changes: 42 additions & 0 deletions scripts/tests/test-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
46 changes: 39 additions & 7 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
"https://api.gcore.com",
"https://api.preprod.world",
"https://api.cdb-staging.cdn.orange.com",
Comment thread
godronus marked this conversation as resolved.
"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;

Expand Down
Loading