Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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).
24 changes: 24 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,21 @@ 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 });
}
});
});

describe("resolveIssueRepositoryPath", () => {
Expand Down
41 changes: 36 additions & 5 deletions packages/core/src/workspace-safety.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,56 @@
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("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
33 changes: 33 additions & 0 deletions packages/core/src/workspace/path-safety.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { existsSync, realpathSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative } from "node:path";

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

while (!existsSync(current)) {
const parent = dirname(current);
if (parent === current) {
return path;
}
missingTail.unshift(basename(current));
current = parent;
}

return join(realpathSync(current), ...missingTail);
}

export function isPathWithinRoot(
root: string,
candidate: string,
allowRoot = true
): boolean {
const realRoot = realpathWithMissingTail(root);
const realCandidate = realpathWithMissingTail(candidate);
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