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
88 changes: 88 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Test

permissions:
contents: read

# Cross-platform verification. The extension launches build tools as child
# processes without a shell, so how a process starts differs per OS — that
# cannot be verified on Linux alone, and the unit tests mock spawn entirely.
#
# GitHub-hosted runners (not the self-hosted Ubuntu pool) so macOS and Windows
# are actually exercised.

on:
push:
branches: [main]
pull_request:
workflow_dispatch:
workflow_call:

permissions:
contents: read

jobs:
unit:
name: Unit (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- uses: actions/checkout@v6

- name: Setup Node Environment
uses: ./.github/setup-node

- name: Typecheck
run: pnpm run check-types

- name: Lint
run: pnpm run lint

- name: Unit tests
run: pnpm test

integration:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
name: Integration (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- uses: actions/checkout@v6

- name: Setup Node Environment
uses: ./.github/setup-node

# The fixtures are real FastEdge apps with the real toolchains as their
# own dependencies, installed the way a user's project would be — this is
# what proves `spawn(process.execPath, [<tool>.js, ...])` starts on
# Windows, where .cmd shims cannot be spawned without a shell.
- name: Install fixture dependencies
run: pnpm run fixtures:install

# wasip1 for fastedge-crate HTTP apps and CDN proxy-wasm apps; wasip2 for
# wstd apps, which rustConfigWasiTarget infers rather than reads.
- name: Install Rust wasm targets
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1, wasm32-wasip2

- name: Cache cargo registry and fixture target dirs
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
test/fixtures/rust-app/target
test/fixtures/rust-app-wasi-http/target
test/fixtures/rust-app-cdn/target
key: ${{ runner.os }}-cargo-${{ hashFiles('test/fixtures/*/Cargo.toml') }}
restore-keys: ${{ runner.os }}-cargo-

- name: Integration tests (real builds)
run: pnpm run test:integration
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,10 @@ js-extension/
# Dev tools
.nx


# Integration test fixtures build in place
test/fixtures/*/node_modules/
test/fixtures/*/.fastedge-debug/
test/fixtures/*/build/
test/fixtures/*/target/
test/fixtures/*/package-lock.json
95 changes: 95 additions & 0 deletions context/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,101 @@ See `SEARCH_GUIDE.md` for more search patterns.

---

## [2026-08-25] - Security: OS command injection (CWE-78) in the compilers

### Overview
An external report demonstrated arbitrary command execution from a malicious workspace. All three compilers spawned their build tool with a shell, so workspace-controlled values were parsed as shell syntax. A repo with `"main": "index.js; touch /tmp/PWNED #"` in package.json executed that command when the developer ran "Debug: FastEdge App (Package Entry)".

No child process in the extension is spawned through a shell any more.

### 🎯 What Was Completed

#### 1. Shell removed from every compiler
- `jsBuild.ts` / `asBuild.ts`: dropped `shell: true`; dropped `npx` entirely
- `rustBuild.ts`: dropped `shell = isWindows ? "cmd.exe" : "sh"`; `cargo` is a native executable and resolves from PATH on Windows without a command interpreter
- Injection vectors closed: `package.json` `main` (the reported PoC), `.cargo/config.toml` `[build] target` (returned verbatim by `rustConfig.ts`, second vector), and workspace directory names reaching `--outFile` in `asBuild.ts`

#### 2. `src/utils/resolveBin.ts` (new)
- Resolves a build tool's real JS entry from the project — `createRequire(buildRoot/package.json)` → package `bin` field — for launch via `spawn(process.execPath, [bin, ...args])`
- **Do not "fix" Windows with `npx.cmd`**: patched Node rejects `.bat`/`.cmd` without a shell with `EINVAL` (CVE-2024-27980); unpatched Node routes it via `cmd.exe` and re-opens argument injection
- Verified: esbuild leaves the runtime `createRequire(...).resolve()` intact in the bundled extension rather than binding it to the extension's own module graph

#### 3. Trust-boundary validation
- `package.json` `main` must resolve inside the build root (`path.resolve` + `path.relative`), rejecting absolute paths and `../` traversal
- **Not** added: a wasip1/wasip2 allowlist for the Rust target. Once argv is literal it buys nothing and would break the documented `.cargo/config.toml` custom-target support

#### 4. `error` handlers on all three spawns
- Without a shell a launch failure arrives as the child's `"error"` event, not exit code 127. Without a listener the build promise hangs forever. Every new spawn needs one.

#### 5. `src/autorun/triggerFileHandler.ts` — amplifier closed
- `ALLOWED_COMMANDS` trimmed to `fastedge.setup-codespace-secret`, the only command any producer writes (`fastedge-codespace/.devcontainer/start.sh`)
- Removed: `run-file` / `run-workspace` (reached the vulnerable build with no user click), `generate-mcp-json` (writes credentials), `reloadWindow` (no producer; reload loop = DoS), `generate-launch-json` (never a registered command — the real id is `fastedge.init-workspace`)
- Deleted dead `checkForTriggerFile()` — never called, so a committed trigger file has never executed on activation

#### 6. `src/commands/mcpJson.ts` — same class, weaker prerequisites
- `getPlatformDockerCommand()` → `getDockerCommand()`: docker invoked with an argv array, no `bash -c` / `cmd /c` wrapper, so the workspace path is no longer spliced into a shell string
- Env forwarding switched to bare `-e GCORE_API_KEY` / `-e GCORE_API_BASE` — docker reads them from its own environment, which the MCP client supplies via the config's `env` block. Removed all platform branching

#### 7. Builds that report success without producing a binary now fail
- `jsBuild.ts` / `asBuild.ts` reject when the tool exits 0 but no `.wasm` exists at the output path
- Found by the new cross-platform CI: **`fastedge-build` prints "Build success!!", exits 0, and writes nothing when `NODE_ENV=test`** — which is exactly what vitest sets. Reproduced outside vitest; unset and `production` both build normally
- This is an SDK bug in `@gcoredev/fastedge-sdk-js` (reported separately), but the extension previously believed it and went on to debug a stale or missing binary. The JS error message names `NODE_ENV` when it is the cause
- Related, out of scope: `fastedge-build` itself spawns with `shell: true` (emits Node's DEP0190), one layer below this extension

#### 8. Cross-platform CI (`.github/workflows/test.yml`)
- Before this, **no test job existed and every job ran on self-hosted Ubuntu**. The per-OS matrix in `create-release.yml` only packages VSIXs — it builds on Ubuntu and stamps `--target`, so no non-Linux machine had ever executed this code
- Two jobs, each on `ubuntu-latest` / `macos-latest` / `windows-latest`: **unit** (typecheck, lint, mocked tests) and **integration** (real builds)
- The compilers do not import `vscode`, so integration tests need plain vitest — no extension host, no xvfb
- Fixtures mirror the canonical SDK examples rather than minimal stubs; a stub without the wasi-shim `extends` or the `fastedge` proc macro compiles cleanly while a real app breaks:

| Fixture | Shape | Covers |
|---------|-------|--------|
| `js-app` | HTTP, `fastedge-build` | JS toolchain launch |
| `as-app` | CDN proxy-wasm, `asc` | AssemblyScript toolchain launch |
| `rust-app` | HTTP, `fastedge` crate | wasip1 via explicit `.cargo/config.toml` |
| `rust-app-wasi-http` | HTTP, `wstd` crate | wasip2 **inferred** — deliberately has no `.cargo/config.toml` |
| `rust-app-cdn` | CDN, `proxy-wasm` crate | `rustBuild`'s `filenames.length === 1` artifact selection |

- `src/compiler/rustConfig.test.ts` covers target selection for every app shape plus custom targets and malformed configs — the `wstd` → wasip2 inference had no test at all, despite feeding the `--target=` argument this patch changed
- **CI cannot cover**: Docker on Windows or macOS runners (no Linux containers), so `getDockerCommand()` is verified by argv-shape assertions only. "Does Docker Desktop for Windows forward a valueless `-e`" stays a manual pre-release check

**Files Modified:**
- `src/compiler/jsBuild.ts`, `asBuild.ts`, `rustBuild.ts` - no shell; `process.execPath` launch; entry-point containment; error handlers; output verification
- `src/autorun/triggerFileHandler.ts` - allowlist trimmed; dead code removed
- `src/commands/mcpJson.ts` - argv-array docker command
- `tsconfig.json` - dropped vestigial `rootDir` (tsc never emits; esbuild builds) so `test/integration` is typechecked; `test/fixtures` excluded
- `package.json` - added `test:integration` and `fixtures:install` scripts
- `.gitignore` - fixture build artifacts
- `context/features/CROSS_PLATFORM.md` - previously prescribed `shell: true` as the correct pattern
- `context/architecture/EXTENSION_LIFECYCLE.md` - autorun was described as rebuild-on-file-change
- `context/features/COMPILER_SYSTEM.md`, `COMMANDS.md` - documented the `npx` invocation

**Files Created:**
- `src/utils/resolveBin.ts` - project-local bin resolution
- `src/compiler/compilerSpawn.test.ts` - 6 regression tests
- `src/compiler/rustConfig.test.ts` - 8 target-selection tests
- `src/commands/mcpJson.test.ts` - 5 docker argv-shape tests
- `.github/workflows/test.yml` - cross-platform unit + integration matrix
- `test/integration/compilers.test.ts` - 5 real builds
- `test/fixtures/` - five FastEdge app fixtures

### 🧪 Testing
`pnpm test` — 40 unit tests. `pnpm run test:integration` — 5 real builds (~19s locally), after `pnpm run fixtures:install`. Both run on Linux, macOS and Windows in CI.

The spawn suite asserts, per compiler, that the command is `process.execPath` (or bare `cargo`), that argv[0] is the resolved bin, that `shell` is falsy, and that the payload survives as one literal argv element. Verified the guard bites: reinstating `shell: true` in `jsBuild.ts` fails the JS case.

Asserting only "no shell + literal argv" is insufficient — that passes for `spawn("npx.cmd", …, {shell:false})`, the exact implementation that breaks on Windows. The `process.execPath` assertion is what catches it.

### 📝 Notes
**Behaviour changes:**
- Build tools must be local devDependencies. `npx` previously downloaded a missing package from the registry and ran it; that is gone. A missing tool now fails with an install instruction.
- Yarn Plug'n'Play is unsupported — the dependency map lives in `.pnp.cjs`, which Node ignores unless preloaded. Supporting it means executing workspace JavaScript before the compiler starts.
- `getDockerCommand()` output is platform-independent; the "Generated mcp.json with <platform> configuration" message lost its platform name.

**Known issue, deliberately not fixed here:** `resolveAppRoot.ts` and `rustConfig.ts` walk to the filesystem root, so an ancestor `package.json` / `Cargo.toml` / `.cargo/config.toml` *outside* the VS Code workspace can become the build root. Plausibly intentional for nested monorepos — needs a product decision, tracked separately.

---

## [2026-05-21] - Unify on GCORE_API_KEY — remove GCORE_API_TOKEN

### Overview
Expand Down
17 changes: 13 additions & 4 deletions context/architecture/EXTENSION_LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,20 @@ vscode.workspace.getConfiguration('fastedge').update(

### Event Handling

**File watching** (autorun feature):
**Trigger file watching** (autorun feature):
- `src/autorun/triggerFileHandler.ts`
- Watches files for changes
- Can trigger rebuild/rerun automatically
- Registered if autorun is enabled
- Registered unconditionally at activation — there is no "autorun enabled" setting
- Watches one path only: `.vscode/.fastedge-run-command`
- Not a rebuild-on-file-change feature. It is a bootstrap hook so an external
process can drive a VS Code command that a shell script cannot: the
`fastedge-codespace` devcontainer writes the file from its `postAttachCommand`,
then polls for the resulting Codespace secret
- Fires on create/change only, so a file already committed in a repo does **not**
execute when the workspace is opened
- The file is workspace-controlled, so `ALLOWED_COMMANDS` is deliberately a
single entry (`fastedge.setup-codespace-secret`). Do not add build, config-
generating, or window-reloading commands to it — the rationale for each
removal is in the `ALLOWED_COMMANDS` comment in `triggerFileHandler.ts`

**Configuration changes**:
- Extension can react to settings changes via `vscode.workspace.onDidChangeConfiguration`
Expand Down
4 changes: 2 additions & 2 deletions context/features/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Builds the **active editor file** as the WASM entry point, starts a per-app debu

**Rust**: `cargo build --target wasm32-wasip1` from `buildRoot` (nearest `Cargo.toml`)

**JavaScript**: `npx fastedge-build <activeFile> <output.wasm>` from `buildRoot`
**JavaScript**: `<node> <resolved fastedge-build bin> <activeFile> <output.wasm>` from `buildRoot` (no shell, no `npx` — see `CROSS_PLATFORM.md`)

**AssemblyScript**: `asc assembly/index.ts` from `buildRoot`

Expand Down Expand Up @@ -87,7 +87,7 @@ Use this when you're editing a helper file (e.g. `src/utils/headers.js`) but wan

### Behavior by Language

**JavaScript**: `npx fastedge-build <package.json main> <output.wasm>` from `buildRoot`
**JavaScript**: `<node> <resolved fastedge-build bin> <package.json main> <output.wasm>` from `buildRoot`. `main` must resolve inside the build root or the build is rejected

**Rust / AssemblyScript**: Identical to `run-file` — `debugContext` is ignored.

Expand Down
10 changes: 5 additions & 5 deletions context/features/COMPILER_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ npm install --save-dev @gcoredev/fastedge-sdk-js
4. Determine entry point:
- **File mode**: active file path
- **Workspace mode**: `package.json` `main` field resolved relative to `buildRoot`
5. Spawn `npx fastedge-build <entryPoint> <configRoot>/.fastedge-debug/app.wasm` at `buildRoot`
5. Resolve the `fastedge-build` bin from the project (`utils/resolveBin.ts`) and spawn `process.execPath <bin> <entryPoint> <configRoot>/.fastedge-debug/app.wasm` at `buildRoot` — argv array, no shell

### Entrypoint Modes

Expand Down Expand Up @@ -160,7 +160,7 @@ Used for **CDN/Proxy-WASM applications** (HTTP request/response manipulation via
npm install --save-dev assemblyscript @assemblyscript/wasi-shim
```

The `asc` compiler is provided by the `assemblyscript` package — no global install needed; `npx asc` resolves it from `node_modules`.
The `asc` compiler is provided by the `assemblyscript` package — no global install needed. It must be a local devDependency: the extension resolves `bin.asc` from the project and runs it with the VS Code Node runtime. Nothing is downloaded on demand.

### Project Structure

Expand Down Expand Up @@ -202,7 +202,7 @@ my-app/
2. Verify `asconfig.json` exists at `buildRoot` — throws if missing
3. Resolve `configRoot` (falls back to `buildRoot`)
4. Create `<configRoot>/.fastedge-debug/` directory
5. Spawn: `npx asc assembly/index.ts --target release --outFile <configRoot>/.fastedge-debug/app.wasm` at `buildRoot`
5. Resolve the `asc` bin from the project and spawn `process.execPath <bin> assembly/index.ts --target release --outFile <configRoot>/.fastedge-debug/app.wasm` at `buildRoot` — argv array, no shell

The `--target release` flag picks up optimization settings from `asconfig.json` (shrink level, no-assert, etc.). `--outFile` overrides only the output path to the standard debugger location.

Expand Down Expand Up @@ -254,8 +254,8 @@ All three compilers write to the same path:
| Language | Build mode | Incremental |
|---|---|---|
| Rust | `cargo build` (debug) | Yes — Cargo caches in `target/` |
| JavaScript | `npx fastedge-build` | No — rebuilds from scratch |
| AssemblyScript | `npx asc --target release` | No — rebuilds from scratch |
| JavaScript | `fastedge-build` (resolved bin, run via `process.execPath`) | No — rebuilds from scratch |
| AssemblyScript | `asc --target release` (resolved bin, run via `process.execPath`) | No — rebuilds from scratch |

AssemblyScript always builds in release mode because the AS `--target release` settings in `asconfig.json` are what produce a valid proxy-wasm binary. Debug builds may produce larger output but are otherwise equivalent for local testing.

Expand Down
Loading