Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ tsconfig.lsif.json
*.vsix
.env
.DS_Store
secret.txt
secret.txt
.vscode-test-web
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,20 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how
## [Unreleased]

- Initial release

### Security

- Gradle prewarm's execution of the cloned repository's `./gradlew` wrapper is now configurable
via the `GRADLE_PREWARM_AUTO_CONFIRM` environment variable. It defaults to `true` (prewarm runs
automatically, so the long first build is still skipped where exercise templates are provisioned
by a trusted platform). Set it to `false` to require the student to explicitly confirm before any
repository-controlled Gradle build is executed, removing the silent remote-code-execution path
for deployments that do not trust templates. `GRADLE_PREWARM=off` still disables prewarm entirely.
- The VCS/Git access token is no longer embedded in the clone URL or passed on the git command
line. It is injected through the git process environment (`GIT_CONFIG_*`) during clone and
persisted only into `.git/config` for later in-IDE fetch/push.
- STOMP/WebSocket frame logging is disabled so the Artemis JWT in the CONNECT frame is never
written to the debug console.
- The `ARTEMIS_URL` environment override is validated against the configured Artemis origin
(scheme + host + port) before it is used as the API base, preventing the Bearer token from
being sent to an untrusted host.
2 changes: 1 addition & 1 deletion README_DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The webview can NOT communicate with the Artemis API directly due to CORS. There

### Environment variables

At activation the core loads the session environment via a strategy (see [src/theia/env-strategy.ts](src/theia/env-strategy.ts)): from the process environment (lazy sessions) or by polling the data-bridge extension's `dataBridge.getEnvState` command until it reports `injected` (eager / prewarmed sessions). A fixed set of known keys (`ARTEMIS_TOKEN`, `ARTEMIS_URL`, `GIT_URI`, `GIT_USER`, `GIT_MAIL`, `GIT_TOKEN`, `THEIA`, `GRADLE_PREWARM`) drives the bespoke authentication, clone, git-identity, and prewarm logic. Any **other** variable an external system provides is applied to the integrated terminal environment via `context.environmentVariableCollection`, so terminals and terminal-spawned tasks inherit it. Known keys are deliberately kept out of the terminal environment (this also keeps `ARTEMIS_TOKEN` out of shells). Note that `environmentVariableCollection` affects only terminals started after activation, not already-running backend or language-server processes.
At activation the core loads the session environment via a strategy (see [src/theia/env-strategy.ts](src/theia/env-strategy.ts)): from the process environment (lazy sessions) or by polling the data-bridge extension's `dataBridge.getEnvState` command until it reports `injected` (eager / prewarmed sessions). A fixed set of known keys (`ARTEMIS_TOKEN`, `ARTEMIS_URL`, `GIT_URI`, `GIT_USER`, `GIT_MAIL`, `GIT_TOKEN`, `THEIA`, `GRADLE_PREWARM`, `GRADLE_PREWARM_AUTO_CONFIRM`) drives the bespoke authentication, clone, git-identity, and prewarm logic. `GRADLE_PREWARM` selects the prewarm depth (`off`/`daemon`/`deps`/`full`) and `GRADLE_PREWARM_AUTO_CONFIRM` (default `true`) decides whether the prewarm may run automatically or must be confirmed by the student first. Any **other** variable an external system provides is applied to the integrated terminal environment via `context.environmentVariableCollection`, so terminals and terminal-spawned tasks inherit it. Known keys are deliberately kept out of the terminal environment (this also keeps `ARTEMIS_TOKEN` out of shells). Note that `environmentVariableCollection` affects only terminals started after activation, not already-running backend or language-server processes.

### Webview

Expand Down
11 changes: 0 additions & 11 deletions shared/models/participation.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,3 @@ export function getProjectKeyFromRepoUrl(repoUrl: string): string {
const projectKey = parts[4];
return projectKey;
}

export function addVcsTokenToUrl(url: string, username: string, vsctoken: string): string {
const credentials = `://${username}:${vsctoken}@`;
if (!url.includes("@")) {
// the url has the format https://vcs-server.com
return url.replace("://", credentials);
} else {
// the url has the format https://[email protected] -> replace ://username@
return url.replace(/:\/\/.*@/, credentials);
}
}
70 changes: 53 additions & 17 deletions src/participation/cloning.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import { settings } from "../shared/settings";
import { NotAuthenticatedError } from "../authentication/not_authenticated.error";
import { AUTH_ID } from "../authentication/authentication_provider";
import { getState } from "../shared/state";
import simpleGit from "simple-git";
import simpleGit, { GitConfigScope } from "simple-git";
import { tmpdir } from "os";
import * as path from "path";
import * as fs from "fs/promises";
import { retrieveVcsAccessToken } from "../artemis/authentication.client";
import { getWorkspaceFolder, theiaEnv } from "../theia/theia";
import { addVcsTokenToUrl } from "@shared/models/participation.model";
import { ProgrammingExercise, ProgrammingLanguage } from "@shared/models/exercise.model";
import { warmupGradleDaemon } from "./gradle.service";

Expand All @@ -19,8 +18,10 @@ type CloneOptions = {
mode?: CloneMode;
preservedPaths?: string[];
// Full HTTP header line (e.g. "Authorization: Bearer <token>") to authenticate the clone.
// When set, it is passed as `git -c http.extraHeader=...` and persisted into the cloned
// repo's .git/config so subsequent in-IDE fetch/push stay authenticated.
// When set, it is injected into the git process via the GIT_CONFIG_* environment variables
// (never on the command line, so the token does not appear in process arguments) and then
// persisted into the cloned repo's .git/config so subsequent in-IDE fetch/push stay
// authenticated.
httpExtraHeader?: string;
};

Expand Down Expand Up @@ -79,15 +80,26 @@ export async function cloneUserRepo(repoUrl: string, username: string) {
session.accessToken,
getState().displayedExercise?.studentParticipations![0].id!,
);
// Clone the repository
const cloneUrlWithToken = new URL(addVcsTokenToUrl(repoUrl, username, vcsToken));
const clonePath = await cloneByGivenURL(cloneUrlWithToken, destinationPath, cloneOptions);
// Authenticate the clone with an HTTP Basic header instead of embedding `user:token@host`
// in the URL. This keeps the short-lived VCS token out of the clone URL, out of the stored
// remote in .git/config and out of the git command line (process arguments).
const basicAuth = Buffer.from(`${username}:${vcsToken}`).toString("base64");
const clonePath = await cloneByGivenURL(new URL(repoUrl), destinationPath, {
...cloneOptions,
httpExtraHeader: `Authorization: Basic ${basicAuth}`,
});

// Pre-warm Gradle in the background so the student's first build is faster.
// The depth (off/daemon/deps/full) is controlled by the GRADLE_PREWARM env var.
// By default this runs automatically; set GRADLE_PREWARM_AUTO_CONFIRM=false to
// require the student to confirm first (see warmupGradleDaemon).
const exercise = getState().displayedExercise;
if ((exercise as ProgrammingExercise)?.programmingLanguage === ProgrammingLanguage.JAVA) {
warmupGradleDaemon(clonePath, theiaEnv.GRADLE_PREWARM);
void warmupGradleDaemon(
clonePath,
theiaEnv.GRADLE_PREWARM,
theiaEnv.GRADLE_PREWARM_AUTO_CONFIRM,
);
}

if (!theiaEnv.THEIA_FLAG) {
Expand Down Expand Up @@ -134,25 +146,46 @@ export async function cloneByGivenURL(
const clonePath = path.join(destinationPath, repoName);

const gitForClone = gitClientFactory.simpleGit(destinationPath);
applyAuthHeaderEnv(gitForClone, options?.httpExtraHeader);

try {
await gitForClone.clone(cloneUrl.toString(), clonePath, buildCloneOptions(options?.httpExtraHeader));
await gitForClone.clone(cloneUrl.toString(), clonePath);
} catch (e: any) {
throw new Error(`Error cloning repository: ${e.message}`);
}

await persistAuthHeader(clonePath, options?.httpExtraHeader);

return clonePath;
}

// Builds the extra simple-git clone options. When an HTTP header is provided, it is passed as
// `git clone -c http.extraHeader=...`, which authenticates the initial fetch AND persists the
// header into the new repo's .git/config so later in-IDE fetch/push are authenticated too.
// The token is therefore stored in .git/config; this is acceptable since it is short-lived.
function buildCloneOptions(httpExtraHeader?: string): string[] {
// Injects the auth header into the git process via the GIT_CONFIG_* environment variables
// (git >= 2.31). This authenticates the clone WITHOUT placing the token on the command line,
// so it never appears in process arguments (`ps`). The env-provided config is not written to
// the new repo's .git/config, so persistAuthHeader stores it afterwards for later fetch/push.
function applyAuthHeaderEnv(git: ReturnType<typeof simpleGit>, httpExtraHeader?: string): void {
if (!httpExtraHeader) {
return [];
return;
}
return ["-c", `http.extraHeader=${httpExtraHeader}`];
// `.env` replaces the child process environment, so carry over the current environment
// (PATH etc.) and add the single-entry git config override.
git.env({
...process.env,
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "http.extraHeader",
GIT_CONFIG_VALUE_0: httpExtraHeader,
});
}

// Persists the auth header into the cloned repo so later in-IDE fetch/push stay authenticated.
// Residual risk: the short-lived token is written to .git/config here. It is, however, no longer
// embedded in the clone URL / stored remote nor exposed in process arguments.
async function persistAuthHeader(repoPath: string, httpExtraHeader?: string): Promise<void> {
if (!httpExtraHeader) {
return;
}
const git = gitClientFactory.simpleGit(repoPath);
await git.addConfig("http.extraHeader", httpExtraHeader, false, GitConfigScope.local);
}

async function cloneIntoWorkspaceRoot(
Expand All @@ -179,10 +212,13 @@ async function cloneIntoWorkspaceRoot(
await clearDirectory(workspacePath);

const gitForClone = gitClientFactory.simpleGit(workspacePath);
applyAuthHeaderEnv(gitForClone, httpExtraHeader);
// Clone into "." so the exercise repository becomes the workspace root in Theia.
await gitForClone.clone(cloneUrl.toString(), ".", buildCloneOptions(httpExtraHeader));
await gitForClone.clone(cloneUrl.toString(), ".");
cloneSucceeded = true;

await persistAuthHeader(workspacePath, httpExtraHeader);

return workspacePath;
} catch (e: any) {
throw new Error(`Error cloning repository into workspace root: ${e.message}`);
Expand Down
90 changes: 71 additions & 19 deletions src/participation/gradle.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import { GradlePrewarmLevel } from "../theia/env-strategy";

/**
Expand All @@ -18,41 +19,92 @@ const PREWARM_ARGS: Record<Exclude<GradlePrewarmLevel, "off">, string[]> = {
};

/**
* Pre-warms the Gradle build in the background after cloning a repository, so the
* student's first build can skip the phases warmed here. Runs detached and never
* blocks the caller.
* Side effects the prewarm performs, grouped behind a seam so tests can assert the confirmation
* gate (when it is active, no build starts without the student's confirmation) without touching
* the real filesystem, child_process or VS Code.
*/
export type GradlePrewarmEffects = {
gradlewExists: (gradlewPath: string) => boolean;
confirm: (projectPath: string) => Promise<boolean>;
makeExecutable: (gradlewPath: string) => void;
run: (projectPath: string, args: string[], level: GradlePrewarmLevel) => void;
};

/**
* Asks the student whether the repository's Gradle wrapper may be executed to prewarm the
* build. Used only when GRADLE_PREWARM_AUTO_CONFIRM is false. The prompt is non-modal and
* defaults to "no": if the student ignores it nothing runs, removing the silent code-execution
* property for deployments that do not trust exercise templates.
*/
async function promptPrewarmConfirmation(_projectPath: string): Promise<boolean> {
const choice = await vscode.window.showWarningMessage(
"Scorpio can pre-build this exercise with its Gradle wrapper to speed up your first build. " +
"This runs code from the cloned repository. Only continue for exercises you trust.",
"Pre-build now",
);
return choice === "Pre-build now";
}

function spawnGradle(projectPath: string, args: string[], level: GradlePrewarmLevel): void {
const child = spawn("./gradlew", args, {
cwd: projectPath,
detached: true,
stdio: "ignore",
});
// Detached failures surface asynchronously via the 'error' event, not a throw.
child.on("error", (error) => {
console.warn(`Gradle prewarm (${level}) failed to start: ${error.message}`);
});
child.unref();
}

export const defaultGradlePrewarmEffects: GradlePrewarmEffects = {
gradlewExists: (gradlewPath) => fs.existsSync(gradlewPath),
confirm: promptPrewarmConfirmation,
makeExecutable: (gradlewPath) => fs.chmodSync(gradlewPath, 0o755),
run: spawnGradle,
};

/**
* Pre-warms the Gradle build in the background after cloning a repository, so the student's
* first build can skip the phases warmed here.
*
* Silently skips if:
* Security: `./gradlew` and the bundled Gradle wrapper jar are repository-controlled, so a
* tampered exercise template could run arbitrary code during prewarm. The behavior is governed
* by `autoConfirm` (the GRADLE_PREWARM_AUTO_CONFIRM env var):
* - `true` (default): the prewarm runs automatically. Appropriate when exercise templates are
* provisioned by a trusted platform, where the long first build matters more than the risk.
* - `false`: the student must explicitly confirm before the wrapper runs, and confirmation
* defaults to "no". The safe choice when templates are not trusted.
*
* Silently skips (no prompt, no build) if:
* - Prewarming is disabled (`level` is "off")
* - Running on Windows (not a supported environment)
* - The project does not contain a `gradlew` file (not a Gradle project)
*/
export function warmupGradleDaemon(
export async function warmupGradleDaemon(
projectPath: string,
level: GradlePrewarmLevel = "daemon",
): void {
autoConfirm = true,
effects: GradlePrewarmEffects = defaultGradlePrewarmEffects,
): Promise<void> {
if (level === "off" || process.platform === "win32") {
return;
}

const gradlewPath = path.join(projectPath, "gradlew");
if (!fs.existsSync(gradlewPath)) {
if (!effects.gradlewExists(gradlewPath)) {
return;
}

const confirmed = autoConfirm ? true : await effects.confirm(projectPath).catch(() => false);
if (!confirmed) {
return;
}

try {
fs.chmodSync(gradlewPath, 0o755);

const child = spawn("./gradlew", PREWARM_ARGS[level], {
cwd: projectPath,
detached: true,
stdio: "ignore",
});
// Detached failures surface asynchronously via the 'error' event, not the throw below.
child.on("error", (error) => {
console.warn(`Gradle prewarm (${level}) failed to start: ${error.message}`);
});
child.unref();
effects.makeExecutable(gradlewPath);
effects.run(projectPath, PREWARM_ARGS[level], level);
} catch (error: any) {
console.warn(`Gradle prewarm (${level}) failed: ${error.message}`);
}
Expand Down
45 changes: 45 additions & 0 deletions src/shared/artemis-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Decides whether an externally supplied Artemis base URL (for example the `ARTEMIS_URL`
* environment variable injected into a Theia session) may be trusted as the API base.
*
* The candidate is only accepted when it is a well-formed http(s) URL whose origin
* (scheme + host + port) matches the origin the extension is already configured for. This
* prevents an attacker-controlled environment from silently repointing the extension - and the
* Bearer token it attaches to every request - at a rogue host.
*
* Returns the trusted URL string when the candidate is allowed, otherwise `undefined`.
*/
export function resolveAllowedArtemisUrl(
candidate: string | undefined,
configuredBaseUrl: string | undefined,
): string | undefined {
if (!candidate) {
return undefined;
}

let candidateUrl: URL;
try {
candidateUrl = new URL(candidate);
} catch {
return undefined;
}

if (candidateUrl.protocol !== "http:" && candidateUrl.protocol !== "https:") {
return undefined;
}

// Without a configured origin to allowlist against we cannot establish trust, so the
// override is rejected rather than accepted blindly.
if (!configuredBaseUrl) {
return undefined;
}

let configuredUrl: URL;
try {
configuredUrl = new URL(configuredBaseUrl);
} catch {
return undefined;
}

return candidateUrl.origin === configuredUrl.origin ? candidate : undefined;
}
17 changes: 14 additions & 3 deletions src/shared/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from "vscode";
import { authenticationProvider } from "../extension";
import { theiaEnv } from "../theia/theia";
import { resolveAllowedArtemisUrl } from "./artemis-url";

export type Settings = {
base_url: string | undefined;
Expand All @@ -21,9 +22,19 @@ export function initSettings() {
function getSettings(): Settings {
let base_url = vscode.workspace.getConfiguration("scorpio").get<string>("artemis.apiBaseUrl");
if (theiaEnv.ARTEMIS_URL) {
const config = vscode.workspace.getConfiguration("scorpio");
config.update("artemis.apiBaseUrl", theiaEnv.ARTEMIS_URL, vscode.ConfigurationTarget.Global);
base_url = theiaEnv.ARTEMIS_URL;
// Only honor the environment override when it points at the same origin the extension is
// already configured for. An unvalidated ARTEMIS_URL could otherwise redirect every
// authenticated request (and the Bearer token) to an attacker-controlled host.
const trustedUrl = resolveAllowedArtemisUrl(theiaEnv.ARTEMIS_URL, base_url);
if (trustedUrl) {
const config = vscode.workspace.getConfiguration("scorpio");
config.update("artemis.apiBaseUrl", trustedUrl, vscode.ConfigurationTarget.Global);
base_url = trustedUrl;
} else {
vscode.window.showErrorMessage(
`Ignoring ARTEMIS_URL "${theiaEnv.ARTEMIS_URL}": it does not match the configured Artemis origin${base_url ? ` (${base_url})` : ""}.`,
);
}
}

if (!base_url) {
Expand Down
7 changes: 4 additions & 3 deletions src/shared/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ export class GenericWebSocket {
connectHeaders: {
Authorization: `Bearer ${session.accessToken}`,
},
debug: function (str: string) {
console.debug(str);
},
// Security: never forward STOMP frames to the log. The CONNECT frame carries the
// Authorization header (the Artemis JWT), so logging frames leaks the token. Keep this
// a no-op so the access token is never written to the debug console.
debug: () => {},
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
Expand Down
Loading
Loading