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
+14 -18
View File
@@ -1,13 +1,12 @@
/**
* Workspace-safe file mutations for the sidebar (the upload route today).
*
* Every write is lexically confined to the session workspace: the upload
* directory is resolved absolute and must sit inside the session cwd, the
* relative path is sanitized (absolute paths, '.', '..' and empty segments
* are refused), and the final target must stay inside both. Containment is
* lexical (no symlink resolution) — a symlinked directory inside the cwd can
* redirect writes outside, matching the trust model of the other /sidebar/*
* routes. Bytes stream from the request body to a uniquely named temp sibling
* Every write is confined to the real session workspace: the upload
* directory is resolved absolute and its target is checked through existing
* filesystem ancestors, the relative path is sanitized (absolute paths, '.',
* '..' and empty segments are refused), and the final target must stay inside
* the workspace after symlink resolution. Bytes stream from the request body
* to a uniquely named temp sibling
* and are renamed into place, so a failed, aborted, or oversized upload never
* leaves a partial file at the target path.
*/
@@ -17,7 +16,8 @@ import { createWriteStream } from 'node:fs'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import type { SidebarHttpRequest } from './context-types.ts'
import { isWithin, requireAbsolute } from './fs-tree.ts'
import { requireAbsolute } from './fs-tree.ts'
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
import { SidebarError } from './wire.ts'
/** Inputs of one upload: the session scope plus the request body stream. */
@@ -48,9 +48,7 @@ export interface WorkspaceUploadInput {
export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> {
const { cwd, dir, relativePath, chunks, limit } = input
const base = requireAbsolute(dir)
if (!isWithin(cwd, base)) {
throw new SidebarError('forbidden', 'upload directory escapes the session workspace', 403)
}
await ensureWorkspacePath(cwd, base)
if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
}
@@ -59,11 +57,9 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
}
const target = join(base, ...segments)
if (!isWithin(cwd, target) || !isWithin(base, target)) {
throw new SidebarError('forbidden', 'target escapes the session workspace', 403)
}
const tmp = join(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`)
await mkdir(dirname(target), { recursive: true })
const safeTarget = await ensureWorkspaceWritePath(cwd, target)
const tmp = join(dirname(safeTarget), `.${basename(safeTarget)}.dsh-upload-${randomUUID()}.tmp`)
await mkdir(dirname(safeTarget), { recursive: true })
const stream = createWriteStream(tmp, { flags: 'wx' })
// Resolves once the stream fully closes; created up front so a stream that
// already closed (successful end, later failure) cannot leave the wait hanging.
@@ -85,8 +81,8 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
stream.end((error?: Error | null) => (error === undefined || error === null ? resolve() : reject(error)))
})
if (streamError !== undefined) throw streamError
await rename(tmp, target)
const info = await stat(target)
await rename(tmp, safeTarget)
const info = await stat(safeTarget)
return { path: target, size: info.size }
} catch (error) {
// Wait for the stream to fully close before unlinking (Windows locks open
+2 -2
View File
@@ -57,7 +57,7 @@ export function encodeHtmlUrl(sessionId: string, path: string): string {
* Decode a route pathname into the session + absolute file path. Rejects
* a wrong prefix (404), an empty path, malformed percent encoding, and a
* missing sessionId or file path (400). The caller still must bound the
* decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
* decoded path with the workspace real-path guard — a decoded `..`
* segment resolves outside the cwd and is refused there.
*/
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
@@ -96,7 +96,7 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
// the html route's isWithin(cwd) fence would then reject every drive path.
// the html route's workspace fence would then reject every drive path.
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
path = tail.join('/')
} else {
+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)
+75
View File
@@ -0,0 +1,75 @@
/** Filesystem path guards shared by sidebar APIs that access a session workspace. */
import { realpath } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { isWithin, requireAbsolute } from './fs-tree.ts'
import { SidebarError } from './wire.ts'
/** Resolve a path and convert filesystem resolution failures to an API error. */
async function resolveRealPath(path: string, label: string): Promise<string> {
try {
return await realpath(path)
} catch (error) {
throw new SidebarError('fs-error', `cannot resolve ${label} "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
}
/** Reject a resolved path whose real filesystem target escapes the workspace. */
function assertWithinWorkspace(workspace: string, target: string): void {
if (!isWithin(workspace, target)) {
throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403)
}
}
/**
* Resolve an existing workspace path through symlinks and enforce containment.
*
* @param cwd - Session workspace directory.
* @param target - Client-supplied absolute path.
* @returns The canonical absolute path used for the filesystem operation.
*/
export async function ensureWorkspacePath(cwd: string, target: string): Promise<string> {
const absolute = requireAbsolute(target)
const [realCwd, realTarget] = await Promise.all([
resolveRealPath(cwd, 'workspace'),
resolveRealPath(absolute, 'target'),
])
assertWithinWorkspace(realCwd, realTarget)
return realTarget
}
/**
* Validate a write destination, including destinations that do not exist yet.
* Existing targets are resolved to catch symlinks; missing targets are checked
* against the nearest existing ancestor before the caller creates or renames.
* The returned path is rebuilt from that canonical ancestor, so an existing
* symlink is never left in the path passed to the write operation.
*
* @param cwd - Session workspace directory.
* @param target - Client-supplied absolute destination path.
* @returns A canonical path for an existing target or its nearest existing ancestor.
*/
export async function ensureWorkspaceWritePath(cwd: string, target: string): Promise<string> {
const absolute = requireAbsolute(target)
const realCwd = await resolveRealPath(cwd, 'workspace')
let existingPath = absolute
const missingSegments: string[] = []
for (;;) {
try {
const realTarget = await realpath(existingPath)
assertWithinWorkspace(realCwd, realTarget)
return missingSegments.reduce((path, segment) => join(path, segment), realTarget)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
if (error instanceof SidebarError) throw error
throw new SidebarError('fs-error', `cannot resolve target "${existingPath}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
const parent = dirname(existingPath)
if (parent === existingPath) {
throw new SidebarError('fs-error', `cannot resolve target "${absolute}"`, 400)
}
missingSegments.unshift(basename(existingPath))
existingPath = parent
}
}
}