fix: 限制文件 API 访问会话工作区

This commit is contained in:
kelvinwww
2026-08-23 10:28:05 +08:00
parent d9b8f15d9e
commit e9ce58afea
6 changed files with 307 additions and 44 deletions
+16 -18
View File
@@ -28,8 +28,9 @@ import {
type SidebarConfig,
type SidebarPrefs,
} from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { writeWorkspaceUpload } from './fs-operations.ts'
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
import { searchFiles } from './fs-search.ts'
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
@@ -128,6 +129,12 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri
*/
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
if (isAbsolute(raw)) return requireAbsolute(raw)
// Prefer the session-relative interpretation when it names an existing
// path. Git status reports repository-root-relative names, but the sidebar
// security boundary is the session workspace; this preference keeps files
// inside a nested session readable without reopening the repository root.
const sessionPath = requireAbsolute(join(cwd, raw))
if (await stat(sessionPath).then(() => true).catch(() => false)) return sessionPath
const root = await git.repoRoot(cwd).catch(() => cwd)
return requireAbsolute(join(root, raw))
}
@@ -248,7 +255,7 @@ function buildApi(
'fs.tree': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown }
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
const target = record.path === undefined ? cwd : await ensureWorkspacePath(cwd, requireString(payload, 'path'))
return listDirectory(target, resolved.listLimit)
},
'fs.search': async (payload) => {
@@ -263,14 +270,14 @@ function buildApi(
const { cwd } = cwdOf(payload)
// Relative paths are git-derived (status/diff report repo-root-relative
// names; the untracked diff view reads the file through this route).
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated, head }
return { kind: 'text', content, truncated }
},
'fs.write': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const path = await ensureWorkspaceWritePath(cwd, requireString(payload, 'path'))
const content = requireString(payload, 'content')
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
try {
@@ -722,14 +729,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const raw = url.searchParams.get('path')
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const path = requireAbsolute(raw)
if (!isWithin(cwd, path)) {
// Only files under the session cwd are served as media (the editor
// opens images from the explorer; produced files go through read).
// isWithin (not a raw startsWith) so case-mismatched Windows paths
// and mixed separators cannot be misclassified.
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
}
const path = await ensureWorkspacePath(cwd, raw)
const info = await stat(path)
if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
@@ -784,13 +784,11 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const { sessionId, path } = decoded.ref
// The session's authoritative cwd (client cwd cannot ride in the URL
// — the path encoding has no query; a detached first request falls
// back to the process cwd and is normally refused by isWithin, same
// semantics as the media route's fallback).
// back to the process cwd and is normally refused by the workspace
// real-path guard, with the same semantics as the media route's
// fallback.
const cwd = sessionCwdOf(ctx, sessionId)
const absolute = requireAbsolute(path)
if (!isWithin(cwd, absolute)) {
throw new SidebarError('fs-error', 'html path outside the session working directory', 403)
}
const absolute = await ensureWorkspacePath(cwd, path)
const info = await stat(absolute)
if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)