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
5 changes: 5 additions & 0 deletions .changeset/528-workspace-path-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gh-symphony/cli": patch
---

Harden workspace path validation against traversal and symlink escapes across platforms (hojinzs/github-symphony#528).
38 changes: 38 additions & 0 deletions packages/core/src/core-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
resolveIssueWorkspaceDirectory,
scheduleRetryAt,
} from "./index.js";
import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RunDispatchedEvent } from "./observability/structured-events.js";

describe("deriveWorkspaceKey", () => {
Expand Down Expand Up @@ -80,6 +83,12 @@ describe("resolveIssueWorkspaceDirectory", () => {
expect(result).toBe("/runtime/orchestrator/abc123");
});

it("accepts Windows-style separators in an issue workspace key", () => {
expect(() =>
resolveIssueWorkspaceDirectory("/runtime/orchestrator", "abc\\nested")
).not.toThrow();
});

it("rejects path traversal that escapes the root", () => {
expect(() =>
resolveIssueWorkspaceDirectory(
Expand All @@ -97,6 +106,35 @@ describe("resolveIssueWorkspaceDirectory", () => {
resolveIssueWorkspaceDirectory("/runtime/orchestrator", ".lock")
).toThrow("reserved");
});

it("rejects an issue workspace symlink that resolves outside the runtime root", () => {
const root = mkdtempSync(join(tmpdir(), "symphony-runtime-root-"));
const outside = mkdtempSync(join(tmpdir(), "symphony-runtime-outside-"));
symlinkSync(outside, join(root, "linked"));

try {
expect(() => resolveIssueWorkspaceDirectory(root, "linked")).toThrow(
"Issue workspace path escapes"
);
} finally {
rmSync(root, { force: true, recursive: true });
rmSync(outside, { force: true, recursive: true });
}
});

it("rejects a dangling issue workspace symlink that points outside the runtime root", () => {
const root = mkdtempSync(join(tmpdir(), "symphony-runtime-root-"));
const outside = join(tmpdir(), "symphony-runtime-missing-target");
symlinkSync(outside, join(root, "linked"));

try {
expect(() => resolveIssueWorkspaceDirectory(root, "linked")).toThrow(
"Issue workspace path escapes"
);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
});

describe("resolveIssueRepositoryPath", () => {
Expand Down
52 changes: 47 additions & 5 deletions packages/core/src/workspace-safety.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,67 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { assertRepositoryAllowed, resolveWorkspaceDirectory } from "./index.js";

const temporaryDirectories: string[] = [];

afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { force: true, recursive: true });
}
});

describe("resolveWorkspaceDirectory", () => {
it("keeps workspaces inside the configured root", () => {
expect(resolveWorkspaceDirectory("/tmp/github-symphony", "workspace-1")).toBe(
"/tmp/github-symphony/workspace-1"
);
expect(
resolveWorkspaceDirectory("/tmp/github-symphony", "workspace-1")
).toBe("/tmp/github-symphony/workspace-1");
});

it("rejects path traversal", () => {
expect(() =>
resolveWorkspaceDirectory("/tmp/github-symphony", "../outside")
).toThrow("Workspace path escapes");
});

it("rejects a symlink that resolves outside the workspace root", () => {
const root = mkdtempSync(join(tmpdir(), "symphony-workspace-root-"));
const outside = mkdtempSync(join(tmpdir(), "symphony-workspace-outside-"));
temporaryDirectories.push(root, outside);
symlinkSync(outside, join(root, "linked"));

expect(() => resolveWorkspaceDirectory(root, "linked")).toThrow(
"Workspace path escapes"
);
expect(() => resolveWorkspaceDirectory(root, "linked/new")).toThrow(
"Workspace path escapes"
);
});

it("rejects a dangling symlink that points outside the workspace root", () => {
const root = mkdtempSync(join(tmpdir(), "symphony-workspace-root-"));
const outside = join(tmpdir(), "symphony-workspace-missing-target");
temporaryDirectories.push(root);
symlinkSync(outside, join(root, "linked"));

expect(() => resolveWorkspaceDirectory(root, "linked")).toThrow(
"Workspace path escapes"
);
});

it("accepts paths containing Windows-style separators as workspace names", () => {
expect(() =>
resolveWorkspaceDirectory("/tmp/github-symphony", "workspace\\nested")
).not.toThrow();
});
});

describe("assertRepositoryAllowed", () => {
it("rejects repositories outside the workspace allowlist", () => {
expect(() =>
assertRepositoryAllowed("https://github.com/acme/other.git", [
"https://github.com/acme/platform.git"
"https://github.com/acme/platform.git",
])
).toThrow("Repository is not in the workspace allowlist");
});
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/workspace/identity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolve, join } from "node:path";
import { createHash } from "node:crypto";
import type { IssueSubjectIdentity } from "../domain/issue.js";
import { isPathWithinRoot } from "./path-safety.js";

const RESERVED_WORKSPACE_KEYS = new Set([
"cache",
Expand Down Expand Up @@ -72,7 +73,7 @@ export function resolveIssueWorkspaceDirectory(
const normalizedRuntimeRoot = resolve(runtimeRoot);
const candidate = resolve(normalizedRuntimeRoot, workspaceKey);

if (!candidate.startsWith(`${normalizedRuntimeRoot}/`)) {
if (!isPathWithinRoot(normalizedRuntimeRoot, candidate, false)) {
throw new Error(
"Issue workspace path escapes the configured runtime root."
);
Expand All @@ -87,8 +88,7 @@ export function resolveIssueWorkspaceDirectory(

function isReservedWorkspaceKey(workspaceKey: string): boolean {
return (
workspaceKey.startsWith(".") ||
RESERVED_WORKSPACE_KEYS.has(workspaceKey)
workspaceKey.startsWith(".") || RESERVED_WORKSPACE_KEYS.has(workspaceKey)
);
}

Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/workspace/path-safety.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { lstatSync, realpathSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative } from "node:path";

function realpathWithMissingTail(path: string): string | null {
let current = path;
const missingTail: string[] = [];

while (true) {
try {
return join(realpathSync(current), ...missingTail);
} catch (error) {
if (
!(error instanceof Error && "code" in error && error.code === "ENOENT")
) {
return null;
}
}

const parent = dirname(current);
if (parent === current) {
return null;
}

try {
if (lstatSync(current).isSymbolicLink()) {
return null;
}
} catch (error) {
if (
!(error instanceof Error && "code" in error && error.code === "ENOENT")
) {
return null;
}
}

missingTail.unshift(basename(current));
current = parent;
}
}

export function isPathWithinRoot(
root: string,
candidate: string,
allowRoot = true
): boolean {
const realRoot = realpathWithMissingTail(root);
const realCandidate = realpathWithMissingTail(candidate);

if (!realRoot || !realCandidate) {
return false;
}

const rel = relative(realRoot, realCandidate);

return (
(allowRoot && rel === "") ||
(rel !== "" && !rel.startsWith("..") && !isAbsolute(rel))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit · low severity] !rel.startsWith("..") 는 이탈이 아닌 정상 이름도 오탐(false positive)으로 거부합니다.

relative(root, root/"..config")"..config" 를 반환하는데, 이 값은 startsWith("..") 에 걸려 이탈로 판정됩니다. 실제로 ..config 는 root 하위의 정상 디렉터리입니다. 로컬 스모크 테스트로 확인:

resolveWorkspaceDirectory(root, "..config") → throws  // 정상 이름인데 거부됨
  • 방향 자체는 fail-closed(과도하게 엄격) 라서 보안 취약점은 아닙니다 — 이탈을 통과시키는 게 아니라 정상 이름을 막는 가용성 이슈입니다.
  • resolveIssueWorkspaceDirectory 에서는 startsWith(".") reserved 검사에 먼저 걸려 가려지지만, resolveWorkspaceDirectory(safety.ts) 경로에는 그 방어막이 없습니다.
  • 실무에서 workspace id/key 는 대부분 정제된 슬러그라 트리거 가능성은 낮습니다. 원본 이슈가 제시한 공식(!rel.startsWith(".."))을 그대로 따른 것이라 blocking 은 아닙니다.

정밀하게 하려면 세그먼트 경계까지 확인하는 형태를 권장합니다:

import { sep } from "node:path";
// ...
rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)

선택 사항이며 후속 처리로 남겨도 무방합니다.


Generated by Claude Code

);
Comment on lines +55 to +58
}
7 changes: 5 additions & 2 deletions packages/core/src/workspace/safety.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolve } from "node:path";
import { isPathWithinRoot } from "./path-safety.js";

export function resolveWorkspaceDirectory(
workspaceRoot: string,
Expand All @@ -7,7 +8,7 @@ export function resolveWorkspaceDirectory(
const normalizedRoot = resolve(workspaceRoot);
const candidate = resolve(normalizedRoot, workspaceId);

if (candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}/`)) {
if (!isPathWithinRoot(normalizedRoot, candidate)) {
throw new Error("Workspace path escapes the configured workspace root.");
}

Expand All @@ -19,6 +20,8 @@ export function assertRepositoryAllowed(
allowedRepositoryCloneUrls: string[]
): void {
if (!allowedRepositoryCloneUrls.includes(targetRepositoryCloneUrl)) {
throw new Error(`Repository is not in the workspace allowlist: ${targetRepositoryCloneUrl}`);
throw new Error(
`Repository is not in the workspace allowlist: ${targetRepositoryCloneUrl}`
);
}
}
Loading