Skip to content

fix(desktop): reject unsafe source slugs before deletion#5829

Merged
wenshao merged 8 commits into
QwenLM:mainfrom
VectorPeak:codex/source-delete-slug-validation
Jun 27, 2026
Merged

fix(desktop): reject unsafe source slugs before deletion#5829
wenshao merged 8 commits into
QwenLM:mainfrom
VectorPeak:codex/source-delete-slug-validation

Conversation

@VectorPeak

@VectorPeak VectorPeak commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

What this PR does

This PR prevents source deletion from accepting path-like slugs. Invalid source identifiers are rejected before the delete path is resolved, so inputs like ../sessions or ..\sessions cannot move the deletion target from the workspace sources directory to a neighboring workspace directory.

Change

  • Adds a source slug guard before resolving the delete target.
  • Allows normal lowercase hyphenated source identifiers such as good-source.
  • Rejects path-like or otherwise invalid identifiers such as ../sessions and ..\sessions before any recursive filesystem deletion is attempted.

Why it's needed

This hardens the desktop source-deletion path against path traversal caused by treating a caller-provided source slug as a filesystem path segment. The affected value is sourceSlug, which is expected to be a source identifier, but the deletion path previously accepted path-like values and then resolved them with the workspace sources directory.

This is a CWE-22 style path traversal issue because .. segments in a caller-provided slug can make the resolved deletion target escape the intended sources directory.

The vulnerable path shape is:

sourceSlug = "../sessions"
sourceRoot = join(workspaceRoot, "sources")
deleteTarget = join(sourceRoot, sourceSlug)
             = join(workspaceRoot, "sources", "../sessions")
             = [workspace]\sessions

Because path joining normalizes .. segments, a path-like slug can resolve outside the intended sources directory. If that neighboring workspace directory exists, the delete operation can remove it recursively.

A local isolated reproduction showed this behavior:

sourceSlug=../sessions
resolvedTarget=[workspace]\sessions
beforeSessionsExists=true
afterSessionsExists=false

This PR is scoped to the source deletion path. It does not claim arbitrary filesystem deletion or unauthenticated remote access.

Possible call chain / impact

The relevant call chain is:

client source delete request
  -> RPC_CHANNELS.sources.DELETE handler
  -> deleteSource(workspace.rootPath, sourceSlug)
  -> getSourcePath(workspaceRootPath, sourceSlug)
  -> rmSync(resolvedSourcePath, { recursive: true })

The impact is limited to callers that can invoke source deletion for a workspace. A path-like sourceSlug could redirect the recursive delete target from sources/ to a neighboring workspace directory such as sessions. Valid source slugs continue to delete normally, and this PR does not change source creation, skill deletion, or other slug-based storage helpers.

Reviewer Test Plan

How to verify

Create a temporary workspace with sources/good-source and a neighboring sessions/session-1 directory. Attempt to delete a source with slug ../sessions and ..\sessions. The invalid slugs should be rejected, and sessions/session-1 should remain intact. Then delete good-source and confirm normal source deletion still works.

Evidence (Before & After)

The bug can be reproduced with a crafted sourceSlug containing path traversal segments:

../sessions
..\sessions

The root cause is that sourceSlug is intended to be a name, such as good-source, but the previous code treated it as a filesystem path segment when constructing the deletion target:

sourceRoot = join(workspaceRoot, "sources")
deleteTarget = join(sourceRoot, "../sessions")
             = [workspace]\sessions

Node's path.join() normalizes the joined path, including .. segments. As a result, sources/../sessions resolves back to the neighboring sessions directory, and the previous code then continued with recursive deletion. This matches the CWE-22 class of issue: external input is used to construct a path intended to stay under a restricted directory, but special path elements are not constrained correctly.

After the fix, Windows and WSL validation rejected both ../sessions and ..\sessions, kept the neighboring sessions/session-1/marker.txt file intact, and confirmed good-source still deletes normally.

Tested on

OS Status
macOS ⚠️ not tested
Windows ✅ tested
Linux ✅ tested via WSL

Environment (optional)

Windows PowerShell 5.1 and WSL Ubuntu-22.04 with Bash 5.1.16. Local paths were redacted from validation output.

Windows validation:

Environment: Windows PowerShell
PowerShell: 5.1.26100.8655
Commit: 9e992e4
Sanitized workspace: [tmp-workspace]
Rejected slugs: ../sessions, ..\sessions
Neighbor marker intact: true
Valid source deletion works: true
Result: PASS
ExitCode=0

WSL validation:

Environment: WSL Ubuntu-22.04
Shell: bash 5.1.16
Commit: 9e992e4
Sanitized workspace: [tmp-workspace]
Rejected slugs: ../sessions, ..\sessions
Neighbor marker intact: true
Valid source deletion works: true
Result: PASS
ExitCode=0

Risk & Scope

  • Main risk or tradeoff: path-like source slugs that previously reached filesystem deletion now fail early.
  • Not validated / out of scope: skill deletion and other slug-based storage helpers are not changed in this PR.
  • Breaking changes / migration notes: valid source slugs should not be affected.

Linked Issues

Fixes #5834

中文说明

这个 PR 做了什么

这个 PR 防止 source 删除逻辑接受路径型 slug。删除路径解析前会先拒绝非法 source 标识符,避免 ../sessions..\sessions 这类输入把删除目标从 workspace 的 sources 目录移动到相邻 workspace 目录。

Change

  • 删除目标解析前增加 source slug 校验。
  • 继续允许 good-source 这类正常的小写连字符 source 标识符。
  • 在触发任何递归文件系统删除前拒绝 ../sessions..\sessions 这类路径型或非法标识符。

为什么需要

这个 PR 加固了 desktop 的 source 删除路径,防止调用方传入的 source slug 被当作文件系统路径片段处理后造成路径穿越。受影响的值是 sourceSlug,它语义上应当是 source 标识符,但此前删除路径会接受路径型值,并把它和 workspace sources 目录一起解析。

这属于 CWE-22 风格的路径穿越问题,因为调用方提供的 slug 中的 .. 片段可能让解析后的删除目标逃逸出预期的 sources 目录。

风险路径形态如下:

sourceSlug = "../sessions"
sourceRoot = join(workspaceRoot, "sources")
deleteTarget = join(sourceRoot, sourceSlug)
             = join(workspaceRoot, "sources", "../sessions")
             = [workspace]\sessions

由于路径拼接会归一化 .. 片段,路径型 slug 可能解析到预期的 sources 目录之外。如果该相邻 workspace 目录存在,删除操作可能递归移除它。

本地隔离复现显示:

sourceSlug=../sessions
resolvedTarget=[workspace]\sessions
beforeSessionsExists=true
afterSessionsExists=false

这个 PR 只覆盖 source 删除路径,不声称任意文件系统删除或未认证远程访问。

Possible call chain / impact

相关调用链如下:

client source delete request
  -> RPC_CHANNELS.sources.DELETE handler
  -> deleteSource(workspace.rootPath, sourceSlug)
  -> getSourcePath(workspaceRootPath, sourceSlug)
  -> rmSync(resolvedSourcePath, { recursive: true })

影响范围限于能够对 workspace 调用 source 删除的路径。路径型 sourceSlug 可能把递归删除目标从 sources/ 重定向到相邻 workspace 目录,例如 sessions。合法 source slug 仍会正常删除;本 PR 不更改 source 创建、skill 删除或其他基于 slug 的 storage helper。

Reviewer Test Plan

如何验证

创建一个临时 workspace,包含 sources/good-source 和相邻目录 sessions/session-1。尝试用 ../sessions..\sessions 作为 source slug 删除 source。非法 slug 应被拒绝,且 sessions/session-1 应保持存在。然后删除 good-source,确认正常 source 删除仍然可用。

Evidence (Before & After)

可以通过精心设计的 sourceSlug 复现这个 bug。核心 payload 是路径穿越片段:

../sessions
..\sessions

原因很直接:sourceSlug 本来应该是一个名字,例如 good-source;但旧代码把它当作路径片段拼进删除目标:

sourceRoot = join(workspaceRoot, "sources")
deleteTarget = join(sourceRoot, "../sessions")
             = [workspace]\sessions

Node 的 path.join() 会把路径片段拼接后归一化,也就是会解析 ..。所以 sources/../sessions 会回退到相邻的 sessions 目录,随后旧代码继续执行递归删除。MITRE 的 CWE-22 描述的正是这类问题:外部输入被用于构造本应限制在特定目录下的路径,但没有正确限制特殊路径元素,导致解析后的路径逃逸到目录外。

修复后:Windows 和 WSL 验证拒绝了 ../sessions..\sessions,保留相邻的 sessions/session-1/marker.txt 文件,并确认 good-source 仍可正常删除。

Tested on

OS Status
macOS ⚠️ not tested
Windows ✅ tested
Linux ✅ tested via WSL

Environment (optional)

Windows PowerShell 5.1,WSL Ubuntu-22.04 / Bash 5.1.16。验证输出中的本地路径已脱敏。

Windows 验证:

Environment: Windows PowerShell
PowerShell: 5.1.26100.8655
Commit: 9e992e4
Sanitized workspace: [tmp-workspace]
Rejected slugs: ../sessions, ..\sessions
Neighbor marker intact: true
Valid source deletion works: true
Result: PASS
ExitCode=0

WSL 验证:

Environment: WSL Ubuntu-22.04
Shell: bash 5.1.16
Commit: 9e992e4
Sanitized workspace: [tmp-workspace]
Rejected slugs: ../sessions, ..\sessions
Neighbor marker intact: true
Valid source deletion works: true
Result: PASS
ExitCode=0

Risk & Scope

  • 主要风险或权衡:之前能进入文件系统删除逻辑的路径型 source slug 现在会提前失败。
  • 未验证 / 不在范围内:skill 删除和其他基于 slug 的 storage helper 不在本 PR 范围内。
  • 破坏性变更 / 迁移说明:合法 source slug 不应受影响。

Linked Issues

Fixes #5834

@VectorPeak
VectorPeak force-pushed the codex/source-delete-slug-validation branch from 08cb279 to 9e992e4 Compare June 24, 2026 13:45
@VectorPeak

Copy link
Copy Markdown
Contributor Author

WSL validation completed with local paths redacted.

Environment: WSL Ubuntu-22.04
Shell: bash 5.1.16
Commit: 9e992e4
Sanitized workspace: <tmp-workspace>
Rejected slugs: ../sessions, ..\sessions
Neighbor marker intact: true
Valid source deletion works: true
Result: PASS
ExitCode=0

Note: the committed PR diff now contains only the source deletion guard; the temporary focused test file was removed from the PR as requested.

@VectorPeak
VectorPeak marked this pull request as ready for review June 24, 2026 16:12
@VectorPeak

Copy link
Copy Markdown
Contributor Author

Added Windows validation with local paths redacted, then marked the PR ready for review.

Environment: Windows PowerShell
PowerShell: 5.1.26100.8655
Commit: 9e992e4
Sanitized workspace: <tmp-workspace>
Rejected slugs: ../sessions, ..\sessions
Neighbor marker intact: true
Valid source deletion works: true
Result: PASS
ExitCode=0

The PR body now lists Windows as tested and keeps the prior WSL validation as Linux coverage.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Additional notes (not line-specific):

  • Regex inconsistency: validators.ts uses ^[a-z0-9-]+$ (allows leading/trailing/consecutive hyphens) while this PR uses the stricter ^[a-z0-9]+(?:-[a-z0-9]+)*$. Worth aligning to a single shared constant.
  • Duplicate getSourcePath in session-tools-core/source-helpers.ts also lacks slug validation — pre-existing architectural issue that would benefit from consolidation.
  • No tests for assertValidSourceSlug or deleteSource — a few unit tests covering valid slugs, path-traversal attempts, and edge cases would guard against regressions.

* Delete a source from a workspace
*/
export function deleteSource(workspaceRootPath: string, sourceSlug: string): void {
assertValidSourceSlug(sourceSlug);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Validation is applied only to deleteSource. All other slug-accepting functions (loadSourceConfig, saveSourceConfig, loadSource, sourceExists, saveSourcePermissions) pass sourceSlug to path.join() via getSourcePath() without validation. Consider moving the assertion into getSourcePath() itself so every caller is protected through a single chokepoint:

Suggested change
assertValidSourceSlug(sourceSlug);
export function getSourcePath(workspaceRootPath: string, sourceSlug: string): string {
assertValidSourceSlug(sourceSlug);
return join(getWorkspaceSourcesPath(workspaceRootPath), sourceSlug);
}

The narrow scope (delete only) is defensible since rmSync({recursive: true}) is the highest-risk operation, but this leaves read and write paths unprotected.


Also note: generateSourceSlug (~line 432) applies .replace(/^-|-$/g, '') before .substring(0, 50). A name producing a slug where position 50 lands right after a non-alphanumeric character yields a trailing hyphen after truncation (e.g., 49 as + !b"aaa...a-"). That source directory is created on disk but deleteSource rejects it, making it undeletable. Fix: re-trim after truncation:

let slug = name
  .toLowerCase()
  .replace(/[^a-z0-9]+/g, '-')
  .substring(0, 50)
  .replace(/^-|-$/g, '');

— qwen3.7-max via Qwen Code /review

function assertValidSourceSlug(sourceSlug: string): void {
if (!SOURCE_SLUG_REGEX.test(sourceSlug)) {
throw new Error('Invalid source slug');
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The error message 'Invalid source slug' provides no context about which slug was rejected. Including the value would help diagnose failures (especially the truncation edge case in generateSourceSlug):

Suggested change
}
throw new Error(`Invalid source slug: ${JSON.stringify(sourceSlug)}`);

JSON.stringify ensures the value is visible even for empty strings or whitespace-containing inputs.

— qwen3.7-max via Qwen Code /review

@VectorPeak

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review and the concrete suggestions.

I updated the PR to address the review feedback:

  • Moved source slug validation into getSourcePath() so all shared source path callers go through the same guard, not only deleteSource().
  • Re-trimmed generated source slugs after the 50-character truncation so generateSourceSlug() cannot create a trailing-hyphen slug that later fails deletion.
  • Updated the invalid slug error to include JSON.stringify(sourceSlug) for easier diagnosis.
  • Added focused regression tests for valid slugs, ../sessions, ..\\sessions, malformed slug edge cases, sibling-directory preservation during deletion, and the truncation edge case.
  • Added matching validation coverage for the session-tools-core source path helpers as well.

Validation run locally:

bun test packages/shared/src/sources/__tests__/storage-source-slug.test.ts packages/session-tools-core/src/source-helpers.test.ts packages/shared/src/sources/__tests__/source-config-validation.test.ts packages/session-tools-core/src/handlers/source-test.test.ts
# 22 pass, 0 fail

bun run typecheck:shared
# pass

cd packages/session-tools-core && bun run typecheck
# pass

Appreciate the review, especially the truncation edge case callout.


const SOURCE_SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

function assertValidSourceSlug(sourceSlug: string): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] assertValidSourceSlug is the sole defense against a CWE-22 path traversal, but has no test coverage. No tests verify that path-traversal payloads (../sessions, ..\sessions, absolute paths) are rejected, or that valid slugs (good-source, my-source-123) are accepted. deleteSource itself has never had tests.

Without regression tests, a future change to the regex or validation logic could silently re-introduce the vulnerability. Consider adding a test file (e.g., storage.test.ts in sources/__tests__/) covering rejection of traversal payloads, acceptance of valid slugs, and no-op behavior for non-existent valid slugs.

— qwen3.7-max via Qwen Code /review

@VectorPeak

Copy link
Copy Markdown
Contributor Author

Thanks for the follow-up. I added the missing regression coverage you called out:

  • accepts valid slugs including good-source / my-source-123
  • rejects traversal payloads including ../sessions, ..\\sessions, and absolute paths
  • verifies deleteSource() removes an existing valid source directory
  • verifies deleteSource() is a no-op for a non-existent valid slug

Validation run locally:

bun test packages/shared/src/sources/__tests__/storage-source-slug.test.ts packages/session-tools-core/src/source-helpers.test.ts packages/shared/src/sources/__tests__/source-config-validation.test.ts packages/session-tools-core/src/handlers/source-test.test.ts
# 23 pass, 0 fail

bun run typecheck:shared
# pass

cd packages/session-tools-core && bun run typecheck
# pass

Appreciate the extra test coverage callout.

* Get path to a source folder within a workspace
*/
export function getSourcePath(workspaceRootPath: string, sourceSlug: string): string {
assertValidSourceSlug(sourceSlug);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Adding assertValidSourceSlug to getSourcePath means the throw now fires before the try/catch blocks in callers like loadSourceConfig (line ~67) and loadSourceGuide (line ~194). Both functions document a null-return contract ("returns null if the config doesn't exist"), but an invalid slug now produces an uncaught exception instead of null.

This also affects loadWorkspaceSources (~line 346): it iterates readdirSync entries and passes each entry.name to loadSourcegetSourcePath. The old Zod regex (/^[a-z0-9-]+$/) was more permissive — it allowed leading/trailing/consecutive hyphens. Any source directory created under that regex (or by the old generateSourceSlug truncation bug that could leave trailing hyphens) will now crash the entire source-listing pipeline.

Consider either:

  • Wrapping the getSourcePath call inside the existing try/catch in loadSourceConfig/loadSourceGuide to preserve the null-return contract, or
  • Adding a try/catch around loadSource in the loadWorkspaceSources loop with a debug() log to skip non-conforming directories gracefully.

— qwen3.7-max via Qwen Code /review

import { join } from 'node:path';
import type { SourceConfig } from './types.ts';

export const SOURCE_SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] SOURCE_SLUG_REGEX and assertValidSourceSlug are defined identically in both source-helpers.ts and validators.ts (shared). The duplication is architecturally motivated (session-tools-core doesn't depend on shared), but neither site references the other. A future maintainer may update one copy without realizing the twin exists, silently creating validation divergence between the two getSourcePath implementations.

Consider adding a one-line cross-reference comment at each definition site, e.g.:

// Keep in sync with shared/src/config/validators.ts — session-tools-core cannot import from shared

— qwen3.7-max via Qwen Code /review

@VectorPeak

Copy link
Copy Markdown
Contributor Author

Thanks for the follow-up review. I updated the PR to preserve the existing null-return/listing behavior while keeping the stricter source slug guard in place.

Changes in this update:

  • kept getSourcePath() validation as the shared chokepoint
  • moved getSourcePath() usage inside the existing try/catch flow for loadSourceConfig() and loadSourceGuide() so invalid slugs still return null rather than escaping as uncaught exceptions
  • made loadWorkspaceSources() skip invalid or unreadable legacy source directories instead of letting one bad directory break the full listing pipeline
  • added a regression test covering legacy invalid source directories alongside a valid source to verify the null-return contract and source-listing behavior
  • added cross-reference comments on the duplicated slug rule definitions in shared and session-tools-core to reduce future drift risk

Validation run locally:

bun test packages/shared/src/sources/__tests__/storage-source-slug.test.ts packages/session-tools-core/src/source-helpers.test.ts packages/shared/src/sources/__tests__/source-config-validation.test.ts packages/session-tools-core/src/handlers/source-test.test.ts
# 24 pass, 0 fail

bun run typecheck:shared
# pass

cd packages/session-tools-core && bun run typecheck
# pass

Appreciate the catch on the legacy-directory/null-contract behavior.

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification report — local macOS build & real-filesystem tests

Verdict: the security fix is correct and safe to merge. The path-traversal deletion was reproduced on macOS (the platform the PR marked "⚠️ not tested") and is properly blocked by the fix. All unit tests, a mutation test, and typecheck pass. Three non-blocking follow-ups are noted below (one test-isolation bug, one cross-package inconsistency, one backward-compat note).

Environment: macOS (darwin, arm64) · bun 1.3.14 · node v22.22.2 · PR head 86b20b0f8 · isolated git worktree off origin/main, real bun install, driven under tmux.

1. Path traversal reproduced & fixed on macOS (real deleteSource)

Built a temp workspace with sources/good-source and a neighbor sessions/session-1/marker.txt, then ran the actual deleteSource/getSourcePath from this branch (AFTER) vs a faithful inline replica of the pre-fix delete path (BEFORE — verified line-for-line against origin/main: getSourcePath = join(getWorkspaceSourcesPath(root), slug), no guard; deleteSource unchanged by the PR).

=== BEFORE (pre-fix delete path, no guard) ===
  resolved delete target for slug "../sessions" -> [ws]/sessions
  PASS  neighbor marker exists before attack
  PASS  VULNERABILITY REPRODUCED: neighbor marker DELETED by "../sessions"

=== AFTER (real fixed deleteSource from this branch) ===
  PASS  rejects "../sessions"   (threw: Invalid source slug: "../sessions")
  PASS  rejects "..\\sessions"  (threw: Invalid source slug: "..\\sessions")
  PASS  rejects "/sessions"     (threw: Invalid source slug: "/sessions")
  PASS  rejects "source/child"  (threw: Invalid source slug: "source/child")
  PASS  rejects "Source"        (threw: Invalid source slug: "Source")
  PASS  rejects ""              (threw: Invalid source slug: "")
  PASS  neighbor marker STILL intact after all unsafe slugs
  PASS  valid slug "good-source" deletes normally
  PASS  valid-but-missing slug "missing-source" is a no-op
RESULT: PASS (all checks green)

So on macOS the pre-fix code recursively deletes the sibling sessions/ directory, and the fix rejects every unsafe slug before any filesystem deletion. ✅

2. Unit tests + mutation test (tests are real, not vacuous)

  • Both new test files pass in isolation: 8 pass / 0 fail, 56 assertions — stable across 7/7 repeated isolated runs.
  • Mutation: turning assertValidSourceSlug into a no-op makes 4/8 tests fail — including the security one (does not delete sibling directories when given an unsafe slug), both traversal-rejection tests, and the legacy-null test. The 4 guard-independent tests still pass, confirming the mutation is well-targeted.
  • Independent confirmation: overlaying origin/main production code (no guard) under the PR's tests fails the 5 slug assertions.

3. typecheck & regression

  • bun run tsc --noEmitshared ✅, session-tools-core ✅ (both exit 0).
  • Clean origin/main sweep of sources/ + config/ + session-tools-core/: 229/229 pass, 3/3 runs.
  • 👍 The generateSourceSlug truncate-before-trim reorder is correct and necessary — it stops the generator from emitting a trailing-hyphen slug that the new stricter regex would reject (covered by the trims after truncating test).

F1 — Test isolation bug: the new legacy-source test fails under the full desktop bun test (non-blocking for CI)

storage-source-slug.test.ts > preserves null-return and source listing behavior for legacy invalid source directories fails 3/3 when run as part of cd packages/desktop && bun run test (or any sweep that includes sources/), while passing 7/7 in isolation.

Root cause (confirmed by controlled experiment): credential-manager-renew.test.ts installs a process-global mock.module('../storage.ts', () => ({ … loadSourceConfig: mock(() => null) … })) and never restores it (its afterEach only restores globalThis.fetch). bun's mock.module persists across files; credential-manager-renew sorts before storage-source-slug, so the new test's loadWorkspaceSources() runs against the mocked module and returns [] instead of ['valid-source']. Disabling that one mock.module makes the pair go 14 pass / 0 fail.

Why it does not block merge: root package.json has "!packages/desktop" (desktop is excluded from the npm workspaces), and ci.yml runs only check:desktop-isolation for desktop — no workflow runs cd packages/desktop && bun test on PRs. So CI stays green; only a local full-suite run is affected.

Suggested fix (follow-up): scope/restore the storage mock.module in credential-manager-renew.test.ts, or move the source-storage integration assertions into a *.isolated.ts file (the desktop test script already runs those in separate processes).

F2 — Cross-package consistency: loadSourceConfig throws on one side, returns null on the other (low)

The PR moves getSourcePath inside the try in shared/.../storage.ts (so an invalid slug returns null), but session-tools-core/.../source-helpers.ts loadSourceConfig keeps the guard outside the try, so it throws. Reachable via session-mcp-serversource-oauth.ts / credential-prompt.ts, whose handlers expect null and would propagate the throw.

legacy slug "source-":
  shared.loadSourceConfig             -> returned null
  session-tools-core.loadSourceConfig -> THREW "Invalid source slug: \"source-\""

Both remain path-safe — the difference is graceful-null vs propagated-throw. Mirroring the shared package's try placement would make the two paths consistent.

F3 — Backward-compat note (informational)

The stricter regex makes some pre-existing slugs invalid (e.g. trailing-hyphen slugs the old generateSourceSlug could emit for long names — the very bug fixed here). Such legacy sources are now silently skipped from the source list (directory kept on disk, not deleted) and throw on the session-tools-core path. The PR's own legacy-source test documents the skip behavior — worth a one-line release note.


Recommendation: ✅ Mergeable — the security fix is correct, macOS-verified, and CI-safe. Suggest handling F1 as a follow-up (it's pre-existing and not CI-gating, but will bite anyone running the desktop suite locally); F2/F3 are minor consistency/compat notes.

中文版(完整对应)

维护者验证报告 —— 本地 macOS 构建 + 真实文件系统测试

结论:安全修复正确,可以合并。 路径穿越删除在 macOS(PR 标注"⚠️ not tested"的平台)上被成功复现,并被修复正确拦截。全部单测、变异测试、typecheck 均通过。下面列出三个不阻塞合并的后续项(一个测试隔离 bug、一个跨包不一致、一个向后兼容说明)。

环境: macOS(darwin, arm64)· bun 1.3.14 · node v22.22.2 · PR head 86b20b0f8 · 从 origin/main 拉的独立 git worktree、真实 bun install、在 tmux 中驱动。

1. macOS 上复现并修复路径穿越(真实 deleteSource

建临时 workspace:sources/good-source + 相邻 sessions/session-1/marker.txt,然后跑本分支真实deleteSource/getSourcePath(AFTER)对比忠实复刻的修复前删除路径(BEFORE —— 已逐行核对 origin/maingetSourcePath = join(getWorkspaceSourcesPath(root), slug) 无守卫;deleteSource 本 PR 未改)。

=== BEFORE(修复前删除路径,无守卫)===
  slug "../sessions" 解析删除目标 -> [ws]/sessions
  PASS  攻击前相邻 marker 存在
  PASS  漏洞复现:相邻 marker 被 "../sessions" 删除

=== AFTER(本分支真实修复代码)===
  PASS  拒绝 "../sessions"   (抛:Invalid source slug: "../sessions")
  PASS  拒绝 "..\\sessions"  (抛:Invalid source slug: "..\\sessions")
  PASS  拒绝 "/sessions" / "source/child" / "Source" / ""
  PASS  所有非法 slug 后相邻 marker 仍完好
  PASS  合法 slug "good-source" 正常删除
  PASS  合法但不存在的 "missing-source" 是 no-op
RESULT: PASS(全绿)

即:macOS 上修复前代码会递归删除相邻 sessions/ 目录,修复后在任何文件系统删除之前就拒绝所有非法 slug。✅

2. 单测 + 变异测试(证明测试非空过)

  • 两个新测试文件隔离下通过: 8 pass / 0 fail,56 个断言 —— 重复隔离运行 7/7 稳定。
  • 变异:assertValidSourceSlug 改成 no-op 后 4/8 测试挂 —— 包括安全核心(does not delete sibling directories...)、两个 traversal 拒绝测试、legacy-null 测试。另外 4 个不依赖守卫的仍通过,说明变异精准。
  • 独立印证:origin/main 生产代码(无守卫)配 PR 测试,5 个 slug 断言全挂。

3. typecheck 与回归

  • bun run tsc --noEmitshared ✅session-tools-core ✅(均 exit 0)。
  • 干净 origin/main 扫描 sources/ + config/ + session-tools-core/229/229 通过,3/3 次
  • 👍 generateSourceSlug 的"先截断后去连字符"重排正确且必要 —— 防止生成器产出末尾连字符的 slug 再被新的更严 regex 拒绝(由 trims after truncating 测试覆盖)。

F1 —— 测试隔离 bug:新 legacy 测试在 desktop 全量 bun test 下失败(不阻塞 CI)

storage-source-slug.test.ts > preserves null-return and source listing behavior for legacy invalid source directoriescd packages/desktop && bun run test(或任何包含 sources/ 的扫描)下 3/3 失败,但隔离跑 7/7 通过。

根因(控制实验已确认): credential-manager-renew.test.ts 安装了进程级全局 mock.module('../storage.ts', () => ({ … loadSourceConfig: mock(() => null) … })) 且从不还原(它的 afterEach 只还原 globalThis.fetch)。bun 的 mock.module 跨文件持久;credential-manager-renew 按字母序在 storage-source-slug 之前运行,于是新测试的 loadWorkspaceSources() 跑在被污染的模块上,返回 [] 而非 ['valid-source']。禁用那一个 mock.module 后该配对变成 14 pass / 0 fail

为何不阻塞合并: root package.json"!packages/desktop"(desktop 被排除出 npm workspaces),且 ci.yml 对 desktop 只跑 check:desktop-isolation —— 没有任何 workflow 在 PR 上跑 cd packages/desktop && bun test。所以 CI 保持绿,只影响本地全量运行。

建议修复(后续):credential-manager-renew.test.ts 里限定/还原 storage 的 mock.module,或把 source-storage 集成断言移到 *.isolated.ts 文件(desktop 的 test 脚本已对这类文件用独立进程跑)。

F2 —— 跨包一致性:loadSourceConfig 一边抛错、一边返回 null(低)

本 PR 把 getSourcePath 挪进 shared/.../storage.tstry (非法 slug 返回 null),但 session-tools-core/.../source-helpers.tsloadSourceConfig 守卫仍在 try ,所以会抛错。可经 session-mcp-serversource-oauth.ts / credential-prompt.ts 触达,而这些 handler 期望 null,会把异常向上传播。

遗留 slug "source-":
  shared.loadSourceConfig             -> 返回 null
  session-tools-core.loadSourceConfig -> 抛 "Invalid source slug: \"source-\""

两侧都是 path-safe —— 区别只是优雅返回 null vs 抛错传播。把 session-tools-core 的 try 位置对齐 shared 即可一致。

F3 —— 向后兼容说明(信息性)

更严的 regex 会让一些既有 slug 变非法(例如旧 generateSourceSlug 对长名字可能产出的末尾连字符 slug —— 正是本 PR 修的 bug)。这类遗留 source 现在会被静默地从 source 列表跳过(目录仍在磁盘、不删除),并在 session-tools-core 路径上抛错。PR 自己的 legacy 测试已记录这个 skip 行为 —— 值得加一行 release note。


建议: ✅ 可合并 —— 安全修复正确、已在 macOS 验证、CI 安全。建议把 F1(测试隔离)作为后续处理(它是既有问题、不卡 CI,但会困扰本地跑 desktop 套件的人);F2/F3 是次要的一致性/兼容说明。

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The core path traversal fix is correct — the regex is ReDoS-safe, generateSourceSlug reorder is a proper bug fix, and tests pass (8/8). However, getSourcePath now throws on invalid slugs, and several callers that assumed it never fails were not updated. These are the same class of issue already fixed for loadSourceConfig/loadSourceGuide in shared/storage.ts, but applied to functions that were missed:

[Critical] Unchanged callers that now throw unhandled:

  1. exportSources (resource-bundle.ts:190) — passes readdirSync directory names to getSourcePath with no try/catch. A single legacy directory name (e.g., my-source-, valid under old /^[a-z0-9-]+$/ regex) crashes the entire workspace export.
  2. validateAllPermissions (validators.ts:1506) — same readdirSync iteration pattern, crashes workspace validation.
  3. sourceExists (storage.ts:573, source-helpers.ts:52) — now throws instead of returning false for invalid slugs. Callers like source-test.ts:72 use it as a boolean guard and don't expect a throw.
  4. session-tools-core loadSourceConfig (source-helpers.ts:70) — getSourceConfigPath is outside the try/catch. The shared version was correctly fixed in this PR but this parallel implementation was missed.

[Suggestion] Additional callers missing try/catch:

  1. loadSourcePermissionsConfig (permissions-config.ts:473) and loadRawSourcePermissions (permissions-config.ts:510) — getSourcePermissionsPath called before/outside try/catch.
  2. RPC sources.GET_PERMISSIONS handler (server-core/src/handlers/rpc/sources.ts:100) — slug comes from RPC parameters, getSourcePermissionsPath called before try/catch.
  3. validateSource (validators.ts:535-593) — constructs paths via join(sourcesDir, slug, ...) without calling assertValidSourceSlug (defense-in-depth gap; the function is already imported in the same file).

Suggested pattern for readdirSync callers:

try {
  sourcePath = getSourcePath(workspaceRootPath, slug);
} catch {
  warnings.push(`Source '${slug}' has invalid slug format, skipping`);
  continue;
}

For boolean/null-returning callers (sourceExists, loadSourceConfig, etc.): wrap in try/catch returning the default value (false / null).

— qwen3.7-max via Qwen Code /review

* Get the path to a source's directory
*/
export function getSourcePath(workspaceRootPath: string, sourceSlug: string): string {
assertValidSourceSlug(sourceSlug);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Adding assertValidSourceSlug here means every caller of getSourcePath in this package now potentially throws. The loadSourceConfig at line 70 calls getSourceConfigPathgetSourcePath outside its try/catch (line 74), and sourceExists at line 52 has no try/catch at all. The shared storage.ts versions were correctly fixed in this PR — please apply the same pattern here:

Suggested change
assertValidSourceSlug(sourceSlug);
assertValidSourceSlug(sourceSlug);
return join(workspaceRootPath, 'sources', sourceSlug);
}

Also wrap loadSourceConfig's getSourceConfigPath call inside the existing try/catch (matching shared/storage.ts), and add try/catch to sourceExists returning false on invalid slug.

— qwen3.7-max via Qwen Code /review

if (source) {
sources.push(source);
} else {
debug(`[loadWorkspaceSources] Skipping invalid or unreadable source directory: ${entry.name}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This else correctly makes loadWorkspaceSources tolerate directories whose name fails the new stricter SOURCE_SLUG_REGEX (e.g. legacy legacy-source- — the exact dir your storage-source-slug.test.ts "legacy" case creates). But getSourcePath now throws on such names, and two sibling loops that iterate the same on-disk directory names were not given this treatment:

  • resources/resource-bundle.ts:190 exportSources (when selection === 'all', slugs come from readdirSync(sourcesDir)) calls getSourcePath(workspaceRootPath, slug) outside any try/catch. One legacy/invalid directory name now throws and aborts the entire workspace export (RPC resources.EXPORT, server-core/.../rpc/resources.ts:32, no try/catch), instead of the pre-existing warnings.push('… skipping'); continue.
  • config/validators.ts:1502 validateAllPermissionsvalidateSourcePermissions(workspaceRoot, entry)getSourcePermissionsPathgetSourcePath (no try/catch) — one invalid dir name aborts all-permissions validation.

Verified: SOURCE_SLUG_REGEX.test('legacy-source-') === false, so getSourcePath('…','legacy-source-') throws — yet loadWorkspaceSources (here) and your test treat that same directory as a tolerated, skippable legacy source. The export / validation paths are now inconsistent with it.

Fix: mirror this skip-and-warn in those loops, e.g. in exportSources:

for (const slug of slugs) {
  let sourcePath: string
  try {
    sourcePath = getSourcePath(workspaceRootPath, slug)
  } catch {
    warnings.push(`Source '${slug}' has an invalid slug, skipping`)
    continue
  }
  if (!existsSync(sourcePath)) { /* … */ }
}

(The related session-tools-core loadSourceConfig/sourceExists/sourceConfigExists throws — already flagged at source-helpers.ts:31 — are the same root cause; in that path the MCP dispatcher's catch at session-mcp-server/src/index.ts:559 downgrades them to an error response rather than a crash, so they are less severe than the two cases above.)

中文

这个 else 分支让 loadWorkspaceSources 能容忍名字不满足新版更严格 SOURCE_SLUG_REGEX 的目录(例如旧的 legacy-source-,正是你 storage-source-slug.test.ts 里 legacy 用例创建的目录)。但 getSourcePath 现在对这类名字会抛异常,而另外两处遍历同样的磁盘目录名的循环没有做同样处理:

  • resource-bundle.ts:190exportSources(当 selection === 'all' 时 slug 来自 readdirSync(sourcesDir))在 try/catch 之外调用 getSourcePath。一个旧的/非法目录名现在会抛异常并中断整个 workspace 导出(RPC resources.EXPORTrpc/resources.ts:32 无 try/catch),而原行为是 warnings.push('…skipping'); continue 跳过。
  • validators.ts:1502validateAllPermissionsvalidateSourcePermissionsgetSourcePermissionsPathgetSourcePath(无 try/catch)——一个非法目录名会中断整个权限校验。

已验证:SOURCE_SLUG_REGEX.test('legacy-source-') === false,所以 getSourcePath('…','legacy-source-') 抛异常;但此处的 loadWorkspaceSources 和你的测试都把同一个目录当作可跳过的 legacy source。导出/校验路径与之不一致。

修复:在那些循环里照搬这里的跳过+告警逻辑(见上面 exportSources 示例)。

session-tools-coreloadSourceConfig/sourceExists 的抛异常——已在 source-helpers.ts:31 标注——属于同一根因;但该路径会被 session-mcp-server/src/index.ts:559 的 dispatcher catch 兜底降级为错误响应而非崩溃,所以严重性低于上面两处。)

— claude-opus-4-8 via Claude Code /qreview

@wenshao

wenshao commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor
_Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/28210285720)._

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

The path traversal fix is correctly implemented at the getSourcePath chokepoint, with proper try/catch wrapping for null-return callers and solid test coverage.

Note: the same CWE-22 pattern exists in skill handlers (skills.ts) and render-template.ts — worth addressing in a follow-up.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

@doudouOUC @wenshao thanks again for the detailed reviews. I have updated the PR according to your feedback.

The latest update addresses the previously missed callers around invalid source slugs, including export/validation paths, boolean/null-returning helpers, session-tools-core consistency, permissions config loading, RPC permission lookup handling, and additional defense-in-depth validation where appropriate.

I also re-ran the focused validation locally:

bun test packages/shared/src/sources/__tests__/storage-source-slug.test.ts packages/session-tools-core/src/source-helpers.test.ts packages/shared/src/sources/__tests__/source-config-validation.test.ts packages/session-tools-core/src/handlers/source-test.test.ts
# pass

bun run typecheck:shared
# pass

cd packages/session-tools-core && bun run typecheck
# pass

Appreciate the careful callouts, especially around preserving existing null/false-return contracts and avoiding legacy source directories breaking broader workflows.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review of 2d313bd9e (preserve invalid source slug fallbacks) — the previously-raised concerns are resolved.

The getSourcePath / getSourcePermissionsPath throw is now tolerated everywhere a legacy / invalid on-disk source directory name can surface, so the stricter slug guard no longer turns those callers into hard failures:

  • resources/resource-bundle.ts exportSources → try/catch → warn + skip (was: aborts the whole export)
  • config/validators.ts validateAllPermissions (warn + skip), validateSourcePermissions / validateSource (clean error)
  • session-tools-core/source-helpers.ts loadSourceConfig / sourceExists / sourceConfigExists → return null/false (the prior source-helpers.ts:31 [Critical])
  • shared sourceExists, loadSourcePermissionsConfig, loadRawSourcePermissions, and the sources RPC permissions handler

The headline deleteSource guard still rejects ../sessions before rmSync, and the resource-import loop already records invalid entries as failures rather than throwing. New regression tests cover the exact legacy-source- directory scenario for export, permissions validation, and the session-tools-core loaders — I ran the desktop slug suites locally (all green) and CI is passing.

LGTM. — claude-opus-4-8 via Claude Code /qreview

中文

2d313bd9epreserve invalid source slug fallbacks)的复审——之前提的问题都已解决。

getSourcePath / getSourcePermissionsPath 的抛异常现在在所有可能遇到旧的/非法磁盘目录名的调用点都做了兜底,更严格的 slug 校验不再把这些调用变成硬失败:

  • resource-bundle.tsexportSources → try/catch → 告警并跳过(原先会中断整个导出)
  • validators.tsvalidateAllPermissions(告警+跳过)、validateSourcePermissions / validateSource(返回干净的错误)
  • session-tools-core/source-helpers.tsloadSourceConfig / sourceExists / sourceConfigExists → 返回 null/false(即之前 source-helpers.ts:31 的 [Critical])
  • shared 的 sourceExistsloadSourcePermissionsConfigloadRawSourcePermissions,以及 sources 的 RPC 权限处理器

头号修复 deleteSource 仍会在 rmSync 之前拒绝 ../sessions;资源导入循环本就把非法条目记为 failed 而非抛异常。新增的回归测试精确覆盖了 legacy-source- 目录在导出、权限校验、session-tools-core 加载器中的场景——我在本地实跑了 desktop 的 slug 测试套件(全绿),CI 也通过。

@VectorPeak
VectorPeak requested a review from doudouOUC June 26, 2026 03:04
@wenshao

wenshao commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

🔍 Maintainer local verification report — PR #5829

I built and tested this PR locally in isolated git worktrees (PR head 13d320c8c and baseline origin/main 87da4ef33, each with its own bun install; macOS, bun 1.3.14).

TL;DR

  • The security fix is correct and effective. I independently reproduced the path traversal on origin/main and confirmed it is fully blocked on this PR head.
  • ✅ All new tests pass in isolation; tsc --noEmit is clean for shared, session-tools-core, and server-core.
  • ⚠️ One net-new, order-dependent test failure in the full bun test run of @craft-agent/shared. Root cause is a pre-existing global-mock leak in a file this PR does not touch, not the PR's logic. It does not block CI, but it is a real latent defect worth fixing (see §3).

1. Security fix verification (core)

I wrote a standalone end-to-end reproduction that imports the real storage.ts and runs the attack against a temp workspace with a sibling sessions/marker.txt living outside sources/.

Baseline (origin/main, no fix) — VULNERABLE:

slug="../sessions"
  getSourcePath -> [ws]/sessions           (escapes sources/)
  deleteSource  -> completed (no throw)
  sibling marker before=true after=false   => siblingDeleted=TRUE
SUMMARY: VULNERABLE=true

PR head (#5829, with fix) — BLOCKED:

slug="../sessions"  -> THREW: Invalid source slug: "../sessions"
slug="..\\sessions" -> THREW: Invalid source slug: "..\\sessions"
  sibling marker before=true after=true    => siblingDeleted=false
valid slug 'good-source' deletes normally  => true
SUMMARY: VULNERABLE=false | ALL ATTACKS REJECTED=true | VALID SLUG WORKS=true

The documented RPC chain is confirmed real: RPC_CHANNELS.sources.DELETE handler → deleteSource()getSourcePath()rmSync(). After the fix, getSourcePath calls assertValidSourceSlug, so the destructive op is rejected before any rmSync, while valid slugs still delete normally.

Note: ..\sessions only traverses on Windows (where \ is a path separator); on macOS it stays literal, which matches the author's Windows-only repro for that variant. The regex rejects it on all platforms regardless = defense in depth.

2. Test results

Each new/changed PR test file — all green:

Test file Result
sources/__tests__/storage-source-slug.test.ts 6 pass
config/__tests__/source-slug-validation.test.ts 3 pass
agent/__tests__/permissions-config-source-slug.test.ts 1 pass
resources/__tests__/resource-bundle.test.ts 50 pass
session-tools-core/src/source-helpers.test.ts 3 pass

Full package suites:

  • session-tools-core: 51 pass / 0 fail
  • shared: 2341 pass / 11 skip / 2 fail (stable across 3 consecutive runs)
    1. SAFE_MODE_CONFIG > should have display propertiespre-existing on origin/main (baseline fails it too). tests/mode-manager.test.ts:659 asserts displayName === 'Explore' but it is now 'Plan mode'. Stale test from a mode rename, unrelated to this PR.
    2. source storage slug validation > preserves null-return ...net-new → see §3.
  • Baseline shared (origin/main, no PR): 2331 pass / 1 fail (only the SAFE_MODE one) → confirms this PR adds exactly one new failure.

Two OAuth "metadata discovery" E2E tests flaked once (5s network timeouts in my offline sandbox); non-deterministic and unrelated to the PR.

3. The one finding: order-dependent test failure ⚠️

storage-source-slug.test.ts passes alone (6/6) but fails deterministically inside a full bun test run:

isolated                                   -> 6 pass, 0 fail
+ credential-manager-renew.test.ts (first) -> 1 fail

Failing assertion (line 112): loadWorkspaceSources(ws) returns [] instead of ['valid-source'].

Root cause — in a file this PR does NOT touch: sources/__tests__/credential-manager-renew.test.ts calls, at module top level with no teardown:

mock.module('../storage.ts', () => ({ loadSourceConfig: mock(() => null), ... }))

bun's mock.module is process-global and never restored. Files run alphabetically, so credential-manager-renew runs before storage-source-slug; the leaked mock forces loadSourceConfig → null, so loadWorkspaceSources → loadSource → loadSourceConfig returns null even for the valid source → []. The PR's new test is simply the first test to read loadWorkspaceSources after this polluter, so it exposes a pre-existing landmine.

Impact:

  • Does NOT block CI: root package.json workspaces excludes desktop ("!packages/desktop"), test:ci runs --workspaces, and desktop-release.yml is workflow_dispatch-only — no PR workflow runs the desktop bun test.
  • But it is a real defect: anyone running bun test in packages/shared locally (or any future desktop CI) will hit it.

Fix options (any one):

  1. Best (root cause): make credential-manager-renew.test.ts restore its module mock (e.g. afterAll(() => mock.restore()) / scope it) so it stops poisoning later files. Out of this PR's current scope, but the correct fix.
  2. In-PR escape hatch: rename to storage-source-slug.isolated.ts — the desktop test runner runs *.isolated.ts files each in their own process (convention already used, e.g. pre-tool-use-checks.isolated.ts), avoiding the shared-process pollution.

4. Code audit notes (all clean)

  • The two duplicated SOURCE_SLUG_REGEX copies (shared validators.ts + session-tools-core/source-helpers.ts) are byte-identical (/^[a-z0-9]+(?:-[a-z0-9]+)*$/). Manual "keep in sync" is a minor future-maintainability risk but currently correct.
  • getSourcePath becomes partial (throws on invalid slug). I traced all callers across shared, session-tools-core, server-core:
    • Read/iteration & boolean/null helpers (loadSourceConfig, loadSourceGuide, sourceExists, sourceConfigExists, loadWorkspaceSources, exportSources, validateAllPermissions, loadSource*Permissions, server-core GET_PERMISSIONS) → wrapped to return null/false/skip-with-warning. ✓
    • Action/write paths (deleteSource, saveSourceConfig, saveSourceGuide, downloadSourceIcon, createSource) → fail-closed throw (correct); createSource validates its config first. ✓
    • session-tools-core/handlers/source-test.ts (4 unwrapped calls, file not modified by the PR) → all gated behind the early if (!sourceExists(...)) return errorResponse(...) guard, which the PR hardened to return false on invalid slugs. No new crash path.
    • resource-bundle import loop → generic catch records the failure (a malicious imported slug is caught, not traversed). ✓
  • generateSourceSlug reorder (truncate→trim) is correct: run-collapsing makes internal double-hyphens impossible, trailing-trim now runs after truncation, empty falls back to 'source', and the uniqueness suffix ${slug}-${n} stays valid.
  • tsc --noEmit passes for shared, session-tools-core, server-core.

Recommendation

The security change is correct, effective, and well-scoped — 👍 for merge from a security/correctness standpoint. The only nit is the order-dependent test (§3), which is non-blocking for CI; I'd suggest addressing it (option 1 ideally, or option 2 inside this PR) so the desktop suite stays green for local runs and any future desktop CI.

中文版(点击展开)

🔍 维护者本地验证报告 — PR #5829

我在隔离的 git worktree 中本地构建并测试了本 PR(PR head 13d320c8c 与基线 origin/main 87da4ef33,各自独立 bun install;macOS,bun 1.3.14)。

结论速览

  • 安全修复正确且有效。 我在 origin/main 上独立复现了路径穿越,并确认在本 PR head 上已被完全阻断。
  • ✅ 所有新增测试单独跑均通过;sharedsession-tools-coreserver-coretsc --noEmit 全部干净。
  • ⚠️@craft-agent/shared 全量 bun test新增了一个依赖执行顺序的测试失败。根因是本 PR 未触碰的一个文件里早已存在的全局 mock 泄漏,并非本 PR 的逻辑问题。它不会阻塞 CI,但属于真实的潜在缺陷,值得修复(见 §3)。

1. 安全修复验证(核心)

我写了一个独立的端到端复现脚本,导入真实的 storage.ts,针对一个临时 workspace 发起攻击 —— 该 workspace 在 sources/ 之外放了一个相邻的 sessions/marker.txt

基线(origin/main,无修复)—— 存在漏洞:

slug="../sessions"
  getSourcePath -> [ws]/sessions           (逃逸出 sources/)
  deleteSource  -> 正常完成(未抛错)
  相邻 marker before=true after=false       => siblingDeleted=TRUE(被删)
SUMMARY: VULNERABLE=true

PR head(#5829,有修复)—— 已阻断:

slug="../sessions"  -> 抛错: Invalid source slug: "../sessions"
slug="..\\sessions" -> 抛错: Invalid source slug: "..\\sessions"
  相邻 marker before=true after=true        => siblingDeleted=false(保留)
合法 slug 'good-source' 仍正常删除           => true
SUMMARY: VULNERABLE=false | 所有攻击被拒=true | 合法 slug 仍可用=true

PR 描述的调用链被证实真实存在:RPC_CHANNELS.sources.DELETE handler → deleteSource()getSourcePath()rmSync()。修复后 getSourcePath 调用 assertValidSourceSlug,因此在任何 rmSync 之前就拒绝了破坏性操作,而合法 slug 仍正常删除。

注:..\sessions 仅在 Windows(\ 是路径分隔符)上才会穿越;在 macOS 上它保持字面量,这与作者"仅在 Windows 验证该变体"一致。无论平台,正则都会拒绝它 = 纵深防御。

2. 测试结果

每个新增/改动的 PR 测试文件 —— 全绿:

测试文件 结果
sources/__tests__/storage-source-slug.test.ts 6 pass
config/__tests__/source-slug-validation.test.ts 3 pass
agent/__tests__/permissions-config-source-slug.test.ts 1 pass
resources/__tests__/resource-bundle.test.ts 50 pass
session-tools-core/src/source-helpers.test.ts 3 pass

全量包套件:

  • session-tools-core:51 pass / 0 fail
  • shared:2341 pass / 11 skip / 2 fail(连续 3 次运行结果一致)
    1. SAFE_MODE_CONFIG > should have display properties —— origin/main 上早已失败(基线也挂)。tests/mode-manager.test.ts:659 断言 displayName === 'Explore',但实际已是 'Plan mode'。这是某次模式改名遗留的陈旧测试,与本 PR 无关
    2. source storage slug validation > preserves null-return ... —— 本 PR 新增 → 见 §3。
  • 基线 shared(origin/main,无 PR):2331 pass / 1 fail(只有 SAFE_MODE 那个)→ 证明本 PR 恰好只新增一个失败。

另有两个 OAuth "metadata discovery" E2E 测试偶发失败一次(我离线沙箱里 5s 网络超时);非确定性,且与本 PR 无关。

3. 唯一发现:依赖顺序的测试失败 ⚠️

storage-source-slug.test.ts 单独跑通过(6/6),但在全量 bun test确定性失败:

单独跑                                       -> 6 pass, 0 fail
+ credential-manager-renew.test.ts(先跑)   -> 1 fail

失败断言(第 112 行):loadWorkspaceSources(ws) 返回 [],而非 ['valid-source']

根因 —— 在本 PR 未触碰的文件里: sources/__tests__/credential-manager-renew.test.ts 在模块顶层、无任何还原地调用:

mock.module('../storage.ts', () => ({ loadSourceConfig: mock(() => null), ... }))

bun 的 mock.module进程级全局、且不会自动还原。文件按字母序执行,credential-manager-renew 先于 storage-source-slug 运行;泄漏的 mock 让 loadSourceConfig → null,于是 loadWorkspaceSources → loadSource → loadSourceConfig 连合法 source 都返回 null → []。本 PR 的新测试只是第一个在该污染源之后读取 loadWorkspaceSources 的测试,从而踩中了这颗早已埋下的雷。

影响:

  • 不会阻塞 CI: root package.json 的 workspaces 排除了 desktop("!packages/desktop"),test:ci--workspaces,且 desktop-release.yml 只在 workflow_dispatch 触发 —— 没有任何 PR workflow 会跑 desktop 的 bun test
  • 但它确实是真实缺陷:任何人在本地 packages/sharedbun test(或未来 desktop 接入 CI)都会撞上。

修复选项(任选其一):

  1. 最佳(根因):credential-manager-renew.test.ts 还原它的 module mock(如 afterAll(() => mock.restore()) / 收窄作用域),不再污染后续文件。超出本 PR 当前范围,但这是正确的修法。
  2. PR 内的逃生通道: 改名为 storage-source-slug.isolated.ts —— desktop 测试脚本会把 *.isolated.ts 各自单独进程运行(项目已有此约定,如 pre-tool-use-checks.isolated.ts),从而避开共享进程污染。

4. 代码审计补充(均干净)

  • 两份重复的 SOURCE_SLUG_REGEX(shared validators.tssession-tools-core/source-helpers.ts)逐字节一致(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)。手工"保持同步"是个轻微的未来可维护性风险,但当前正确。
  • getSourcePath全函数变为偏函数(非法 slug 抛错)。我追踪了 sharedsession-tools-coreserver-core 里的全部调用方:
    • 读取/遍历类与返回 boolean/null 的 helper(loadSourceConfigloadSourceGuidesourceExistssourceConfigExistsloadWorkspaceSourcesexportSourcesvalidateAllPermissionsloadSource*Permissions、server-core GET_PERMISSIONS)→ 均已包裹为返回 null/false/跳过并告警。✓
    • 写入/动作类(deleteSourcesaveSourceConfigsaveSourceGuidedownloadSourceIconcreateSource)→ 非法即抛错(fail-closed,正确);createSource 会先校验 config。✓
    • session-tools-core/handlers/source-test.ts(4 处未包裹调用,文件未被本 PR 修改)→ 全部位于入口处 if (!sourceExists(...)) return errorResponse(...) 守卫之后,而本 PR 已加固 sourceExists 对非法 slug 返回 false。无新增崩溃路径。
    • resource-bundle 导入循环 → 通用 catch 记录失败(恶意导入的 slug 被捕获,而非穿越)。✓
  • generateSourceSlug 顺序调整(先截断后 trim)正确:折叠连续非字母数字使内部双连字符不可能出现,尾部 trim 现在在截断之后执行,空值回退为 'source',唯一性后缀 ${slug}-${n} 仍然合法。
  • tsc --noEmitsharedsession-tools-coreserver-core 均通过。

建议

安全改动正确、有效、范围合理 —— 从安全/正确性角度 👍 可合并。唯一的小问题是 §3 的顺序依赖测试,它不阻塞 CI;建议处理一下(理想是选项 1,或在本 PR 内用选项 2),让 desktop 套件在本地运行及未来 desktop CI 中保持绿色。


Verified locally on isolated worktrees (PR head 13d320c8c vs origin/main 87da4ef33), bun 1.3.14. Reproduction imported the real storage.ts to confirm baseline-vulnerable → fixed.

@wenshao
wenshao added this pull request to the merge queue Jun 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jun 27, 2026
@wenshao
wenshao added this pull request to the merge queue Jun 27, 2026
Merged via the queue into QwenLM:main with commit 51ec7c3 Jun 27, 2026
24 checks passed

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The core CWE-22 fix is correct — all 3 Critical findings from the prior review are resolved. 63/63 tests pass. No Critical findings remain.

Remaining defense-in-depth Suggestions (all on unchanged code outside the diff):

  1. getCredentialCachePath bypasses slug validationsession-mcp-server/src/index.ts:95-96 constructs a filesystem path using the slug directly without routing through getSourcePath or calling assertValidSourceSlug. This is the only slug-to-path construction that bypasses the new validation. Exploitability is low, but pre-existing configs from before this fix could still be read.

  2. validateAllSources reports legacy dirs as errors vs validateAllPermissions skips with warningsvalidators.ts:656 iterates all subdirectories and calls validateSource, which returns valid: false for legacy invalid-slug directories, making workspace validation fail. validateAllPermissions gracefully skips the same dirs with a warning. The inconsistency means a workspace with a legacy directory fails validateAll but passes validateAllPermissions.

  3. config-validate.ts fallback path returns generic errorhandlers/config-validate.ts:96 calls getSourceConfigPath without try/catch. The throw is caught by the MCP server's global handler, returning a generic error instead of a structured validation result. No test covers this path.

  4. deleteSource throw propagates to RPC handlerstorage.ts deleteSource calls getSourcePath without try/catch, and rpc/sources.ts:55 has no local try/catch either. The WsRpcServer global catch handles it, but the error is generic. Other slug-accepting handlers wrap calls in try/catch.

  5. source-test.ts uses config.json slug field for path constructionhandlers/source-test.ts:317 passes source.slug (from config.json, JSON-parsed but not slug-validated) to getSourceGuidePath. Defense-in-depth gap.

— qwen3.7-max via Qwen Code /review

try {
const content = readFileSync(path, 'utf-8')
return safeJsonParse(content)
} catch (error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The catch block now also catches assertValidSourceSlug throws from getSourcePermissionsPath (line 101), but the log message 'Error reading permissions config:' frames it as an I/O error. For slug validation failures, this misdirects debugging — an oncall engineer would chase disk permissions instead of a data validation issue.

Consider distinguishing the error type:

} catch (error) {
  if (error instanceof Error && error.message.startsWith('Invalid source slug')) {
    log.warn('Invalid source slug for permissions:', error.message)
  } else {
    log.error('Error reading permissions config:', error)
  }
  return null
}

— qwen3.7-max via Qwen Code /review

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

2 Critical issues found (both in files not modified by this PR but broken by the new assertValidSourceSlug guard):

[Critical] config-validate.ts:96 — fallback path throws on invalid slugs

The fallback case 'sources' in config-validate.ts (when ctx.validators is falsy) calls getSourceConfigPath(ctx.workspacePath, sourceSlug) without try/catch. getSourceConfigPath now throws on invalid slugs via assertValidSourceSlug. This unhandled throw propagates from the fallback handler. The full-validators path handles this correctly via validateSource's own try/catch.

Suggested fix: wrap the fallback getSourceConfigPath call in try/catch, consistent with the other call sites.

[Critical] source-test.ts:318SLUG_REGEX vs SOURCE_SLUG_REGEX mismatch

checkCompleteness uses source.slug (from loaded config) to call getSourceGuidePath. But validateSourceConfigBasic validates the slug with SLUG_REGEX (/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/ — allows consecutive hyphens), while getSourceGuidePath enforces SOURCE_SLUG_REGEX (/^[a-z0-9]+(?:-[a-z0-9]+)*$/ — rejects consecutive hyphens). A config with slug: "my--source" passes validateSourceConfigBasic but throws at getSourceGuidePath with no try/catch. Fix: align SLUG_REGEX with SOURCE_SLUG_REGEX, or use the already-validated sourceSlug parameter instead of source.slug.

— qwen3.7-max via Qwen Code /review

const content = readFileSync(filePath, 'utf-8');
const json = safeJsonParse(content);
const result = PermissionsConfigSchema.safeParse(json);
return result.success ? result.data : null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Missing debug log in catch block

The new catch {} returns null silently, while the sibling loadSourcePermissionsConfig (line 483) logs debug('[Permissions] Error loading source config:', error). Consider adding debug('[Permissions] Error loading raw source permissions:', error) for consistency and easier debugging.

— qwen3.7-max via Qwen Code /review

assertValidSourceSlug(slug);
} catch (error) {
return {
valid: false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Unsanitized slug in validation error file field

file is set to `sources/${slug}/config.json` before assertValidSourceSlug(slug) runs. If slug is ../sessions, the file field becomes sources/../sessions/config.json in the error response. Consider using a sanitized placeholder (e.g. 'sources/<invalid>/config.json') or constructing file after the assertion passes.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Thanks, @doudouOUC. I appreciate the careful follow-up review and the concrete hardening suggestions.

Since the core CWE-22 fix in this PR has been addressed and merged, I’ll keep the remaining slug-validation, fallback-error, and diagnostics items out of this PR’s scope and track them in follow-up issue(s) with targeted PR(s). That should make each change easier to review and avoid mixing the main path-traversal fix with broader defense-in-depth cleanup.

I’ll split the follow-up work into:

  1. normalizing source slug validation and structured error handling;
  2. hardening remaining slug/name-to-path call sites and improving diagnostics/test reliability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Source deletion accepts path-like slugs that can escape the sources directory

4 participants