diff --git a/.changeset/fix-kap-server-auth-encoded-path.md b/.changeset/fix-kap-server-auth-encoded-path.md new file mode 100644 index 0000000000..c868271ff4 --- /dev/null +++ b/.changeset/fix-kap-server-auth-encoded-path.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web server bearer-token check being bypassed by percent-encoded API paths (e.g. `/%61pi/v1/…`), which allowed unauthenticated access to every API route. diff --git a/.changeset/fix-sessionfs-symlink-escape.md b/.changeset/fix-sessionfs-symlink-escape.md new file mode 100644 index 0000000000..b0995fbaf5 --- /dev/null +++ b/.changeset/fix-sessionfs-symlink-escape.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the session filesystem API following symlinks that point outside the workspace, which allowed reading, listing, creating, and downloading host files beyond the session directory through a planted symlink. diff --git a/.changeset/fix-symlinked-workspace-root.md b/.changeset/fix-symlinked-workspace-root.md new file mode 100644 index 0000000000..52820e1033 --- /dev/null +++ b/.changeset/fix-symlinked-workspace-root.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions failing to be created when the workspace directory is given through a symlink, which the v2 engine rejected as "not a directory". diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 16e1547697..75277c2920 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -31,9 +31,12 @@ * `createOrTouch` is the single choke point every workspace/session creation * funnels through, so it owns the root-existence contract: the root must be * an existing directory on the host filesystem, otherwise it throws - * `fs.path_not_found` (mirrors v1's `WorkspaceRootNotFoundError`). The rebuild - * and merge paths bypass the check on purpose — they catalog where sessions - * *were*, not where new ones may open. Bound at App scope. + * `fs.path_not_found` (mirrors v1's `WorkspaceRootNotFoundError`). The + * directory probe follows symlinks (`IHostFileSystem.stat` is lstat-based, so + * a symlink-form root is re-checked through `realpath`), while the workspace + * identity stays lexical — v1 deliberately never realpaths the root either. + * The rebuild and merge paths bypass the check on purpose — they catalog + * where sessions *were*, not where new ones may open. Bound at App scope. */ import { basename, isAbsolute } from 'pathe'; @@ -101,6 +104,13 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { } throw error; } + if (!stat.isDirectory) { + try { + stat = await this.hostFs.stat(await this.hostFs.realpath(root)); + } catch { + // Fall through to the not-a-directory error below. + } + } if (!stat.isDirectory) { throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `workspace root ${root} is not a directory`); } diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index f9d7fb11e9..8d911ce5d7 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -5,7 +5,17 @@ * Bound at App scope. */ -import { appendFile, lstat, open, readFile, readdir, mkdir, rm, writeFile } from 'node:fs/promises'; +import { + appendFile, + lstat, + open, + readFile, + readdir, + mkdir, + realpath as nodeRealpath, + rm, + writeFile, +} from 'node:fs/promises'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; @@ -222,6 +232,14 @@ export class HostFileSystem implements IHostFileSystem { throw toHostFsError(error, { path, op: 'remove' }); } } + + async realpath(path: string): Promise { + try { + return await nodeRealpath(path); + } catch (error) { + throw toHostFsError(error, { path, op: 'realpath' }); + } + } } registerScopedService( diff --git a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts index a6ce33be05..9f1eae9983 100644 --- a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts +++ b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts @@ -3,8 +3,10 @@ * * Defines the `IHostFileSystem` used by the program side (persistence, skill * loading, workspace registry) and the os file tools to read and write files on - * the real local disk, plus the stat/entry models. App-scoped — one shared - * instance. + * the real local disk, plus the stat/entry models. `realpath` canonicalizes a + * path by resolving every symlink component (Node `fs.realpath` semantics) and + * rejects with `os.fs.not_found` for a missing path; consumers use it to make + * lexical path confinement symlink-aware. App-scoped — one shared instance. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -46,6 +48,7 @@ export interface IHostFileSystem { readdir(path: string): Promise; mkdir(path: string, options?: { readonly recursive?: boolean }): Promise; remove(path: string): Promise; + realpath(path: string): Promise; } export const IHostFileSystem: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/session/sessionFs/fsService.ts index 8eecf00dc0..aa314b3d92 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsService.ts @@ -9,11 +9,13 @@ * `IGitService`; this service only confines paths and computes repo-relative * paths before calling it. * - * Path confinement is lexical (`ISessionWorkspaceContext.isWithin`); it does not - * follow symlinks, matching the rest of v2 (`_base/tools/policies/path-access.ts`). + * Path confinement applies the lexical `ISessionWorkspaceContext.isWithin` + * check first, then re-verifies the candidate through `IHostFileSystem.realpath` + * (resolving the longest existing prefix, so not-yet-created paths still work): + * a symlink inside the workspace must not steer fs actions to files outside it. */ -import { basename, extname, isAbsolute, join, relative, sep } from 'node:path'; +import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path'; import { ErrorCode, @@ -46,7 +48,7 @@ import ignore, { type Ignore } from 'ignore'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; +import { ErrorCodes, Error2, isError2, unwrapErrorCause } from '#/errors'; import { IGitService } from '#/app/git/git'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem'; @@ -97,7 +99,7 @@ export class SessionFsService implements ISessionFsService { } async list(req: FsListRequest): Promise { - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); const rel = this.toRel(abs); let topStat: HostFileStat; @@ -189,7 +191,7 @@ export class SessionFsService implements ISessionFsService { } async read(req: FsReadRequest): Promise { - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); const rel = this.toRel(abs); let st: HostFileStat; @@ -289,7 +291,7 @@ export class SessionFsService implements ISessionFsService { } async stat(req: FsStatRequest): Promise { - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); const rel = this.toRel(abs); let st: HostFileStat; try { @@ -302,10 +304,12 @@ export class SessionFsService implements ISessionFsService { } async statMany(req: FsStatManyRequest): Promise { - const resolved = req.paths.map((p) => { - const abs = this.resolveWithin(p); - return { raw: p, rel: this.toRel(abs), abs }; - }); + const resolved = await Promise.all( + req.paths.map(async (p) => { + const abs = await this.resolveWithin(p); + return { raw: p, rel: this.toRel(abs), abs }; + }), + ); const entries: Record = {}; await Promise.all( @@ -323,7 +327,7 @@ export class SessionFsService implements ISessionFsService { } async mkdir(req: FsMkdirRequest): Promise { - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); const rel = this.toRel(abs); try { await this.hostFs.mkdir(abs, { recursive: req.recursive }); @@ -346,7 +350,7 @@ export class SessionFsService implements ISessionFsService { } async resolvePath(relPath: string): Promise { - const abs = this.resolveWithin(relPath); + const abs = await this.resolveWithin(relPath); const rel = this.toRel(abs); let st: HostFileStat; try { @@ -358,7 +362,7 @@ export class SessionFsService implements ISessionFsService { } async resolveDownload(relPath: string): Promise { - const abs = this.resolveWithin(relPath); + const abs = await this.resolveWithin(relPath); const rel = this.toRel(abs); let st: HostFileStat; try { @@ -442,7 +446,7 @@ export class SessionFsService implements ISessionFsService { if (req.paths !== undefined && req.paths.length > 0) { filter = new Set(); for (const p of req.paths) { - filter.add(this.toRel(this.resolveWithin(p))); + filter.add(this.toRel(await this.resolveWithin(p))); } } @@ -451,7 +455,7 @@ export class SessionFsService implements ISessionFsService { async diff(req: FsDiffRequest): Promise { const cwd = this.workspace.workDir; - const abs = this.resolveWithin(req.path); + const abs = await this.resolveWithin(req.path); return this.git.diff(cwd, this.toRel(abs), abs); } @@ -669,7 +673,43 @@ export class SessionFsService implements ISessionFsService { return this.rgResolution; } - private resolveWithin(inputPath: string): string { + private realRootsCache: { readonly key: string; readonly roots: readonly string[] } | undefined; + + private async realRoots(): Promise { + const dirs = [this.workspace.workDir, ...this.workspace.additionalDirs]; + const key = dirs.join('\n'); + if (this.realRootsCache?.key === key) return this.realRootsCache.roots; + const roots: string[] = []; + for (const dir of dirs) { + try { + roots.push(await this.hostFs.realpath(dir)); + } catch { + roots.push(dir); + } + } + this.realRootsCache = { key, roots }; + return roots; + } + + private async realpathExistingPrefix(abs: string): Promise { + const tail: string[] = []; + let current = abs; + for (let i = 0; i < 256; i++) { + try { + const real = await this.hostFs.realpath(current); + return tail.length === 0 ? real : join(real, ...tail.reverse()); + } catch (err) { + if (!isMissingPathError(err)) throw err; + const parent = dirname(current); + if (parent === current) return abs; + tail.push(basename(current)); + current = parent; + } + } + return abs; + } + + private async resolveWithin(inputPath: string): Promise { if (inputPath === '' || inputPath === '/') { throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (empty)`, { details: { path: inputPath, reason: 'empty' }, @@ -692,6 +732,15 @@ export class SessionFsService implements ISessionFsService { details: { path: inputPath, reason: 'resolved_outside' }, }); } + const resolved = await this.realpathExistingPrefix(abs); + const roots = await this.realRoots(); + if (!roots.some((root) => isInsideOrEqual(resolved, root))) { + throw new Error2( + ErrorCodes.FS_PATH_ESCAPES, + `path "${inputPath}" escapes workspace through a symlink`, + { details: { path: inputPath, reason: 'symlink_outside' } }, + ); + } return abs; } @@ -905,6 +954,24 @@ function errnoCode(err: unknown): string | undefined { return undefined; } +function isMissingPathError(err: unknown): boolean { + if (isError2(err)) { + return ( + err.code === ErrorCodes.OS_FS_NOT_FOUND || err.code === ErrorCodes.OS_FS_NOT_DIRECTORY + ); + } + const code = errnoCode(err); + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +function isInsideOrEqual(child: string, parent: string): boolean { + const rel = relative(parent, child); + if (rel === '') return true; + if (rel.startsWith('..')) return false; + if (isAbsolute(rel)) return false; + return true; +} + function mapFsError(err: unknown, inputPath: string): Error { const code = errnoCode(err); if (code === 'ENOENT' || code === 'ENOTDIR') { diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts index 1c9f8abbc3..3b242dfe7a 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -382,6 +382,16 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect(await build().list()).toEqual([]); }); + it('accepts createOrTouch when the root is given through a symlink', async () => { + const real = join(homeDir, 'real-root'); + await fsp.mkdir(real, { recursive: true }); + const link = join(homeDir, 'link-root'); + await fsp.symlink(real, link, 'dir'); + const ws = await build().createOrTouch(link); + expect(ws.root).toBe(link); + expect(ws.id).toBe(encodeWorkDirKey(link)); + }); + it('rejects createOrTouch when a parent of the root is not a directory', async () => { const file = join(homeDir, 'a-file.txt'); await fsp.writeFile(file, 'hi', 'utf8'); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index e6a7c506c6..b5c69f2a99 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -142,6 +142,7 @@ function createTestFs(kaos: FakeKaos): IHostFileSystem { readdir: () => notImplemented('readdir'), mkdir: () => notImplemented('mkdir'), remove: () => notImplemented('remove'), + realpath: () => notImplemented('realpath'), }; } diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts index 089cd27b27..7349ebc2ba 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts @@ -39,7 +39,11 @@ function stubWorkspace(): ISessionWorkspaceContext { }; } -function fakeFs(files: Record, symlinks: readonly string[] = []): IHostFileSystem { +function fakeFs( + files: Record, + symlinks: readonly string[] = [], + symlinkTargets: Record = {}, +): IHostFileSystem { const fileMap = new Map(); const dirSet = new Set([WORK_DIR]); const addAncestors = (rel: string): void => { @@ -57,6 +61,13 @@ function fakeFs(files: Record, symlinks: readonly string[] = []) symlinkSet.add(join(WORK_DIR, rel)); addAncestors(rel); } + const symlinkTargetMap = new Map(); + for (const [rel, target] of Object.entries(symlinkTargets)) { + const abs = join(WORK_DIR, rel); + symlinkTargetMap.set(abs, target); + symlinkSet.add(abs); + addAncestors(rel); + } const isDir = (p: string): boolean => p === WORK_DIR || dirSet.has(p); const enoent = (p: string): NodeJS.ErrnoException => { const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; @@ -162,6 +173,29 @@ function fakeFs(files: Record, symlinks: readonly string[] = []) dirSet.add(p); }, remove: async () => {}, + realpath: async (p) => { + let current = p; + for (let i = 0; i < 40; i++) { + let longest: string | undefined; + for (const linkPath of symlinkTargetMap.keys()) { + if ( + (current === linkPath || current.startsWith(`${linkPath}/`)) && + (longest === undefined || linkPath.length > longest.length) + ) { + longest = linkPath; + } + } + if (longest === undefined) { + if (current === p) { + if (p === WORK_DIR || fileMap.has(p) || isDir(p) || symlinkSet.has(p)) return p; + throw enoent(p); + } + return current; + } + current = symlinkTargetMap.get(longest)! + current.slice(longest.length); + } + return current; + }, }; } @@ -291,11 +325,12 @@ function makeSession( git: IGitService = defaultGitStub(), symlinks: readonly string[] = [], runner?: ISessionProcessRunner, + symlinkTargets: Record = {}, ): ISessionFsService { host = createScopedTestHost(); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, stubWorkspace()), - stubPair(IHostFileSystem, fakeFs(files, symlinks)), + stubPair(IHostFileSystem, fakeFs(files, symlinks, symlinkTargets)), stubPair(ISessionProcessRunner, runner ?? fakeRunner(handler)), stubPair(ITelemetryService, telemetryStub(events)), stubPair(IGitService, git), @@ -711,3 +746,84 @@ describe('SessionFsService.resolveDownload', () => { await expect(fs.resolveDownload('src')).rejects.toMatchObject({ code: 'fs.is_directory' }); }); }); + +describe('SessionFsService symlink confinement', () => { + const escapeTargets = { docs: '/outside' }; + + function escapeSession(): ISessionFsService { + return makeSession( + { 'src/a.ts': '' }, + emptyHandler, + [], + defaultGitStub(), + [], + undefined, + escapeTargets, + ); + } + + it('rejects reads that escape through a symlinked directory', async () => { + const fs = escapeSession(); + await expect( + fs.read({ path: 'docs/secret.txt', offset: 0, length: 1024, encoding: 'utf-8' }), + ).rejects.toMatchObject({ code: 'fs.path_escapes' }); + }); + + it('rejects list through a symlinked directory', async () => { + const fs = escapeSession(); + await expect( + fs.list({ + path: 'docs', + depth: 1, + limit: 200, + show_hidden: false, + follow_gitignore: false, + sort: 'name_asc', + include_git_status: false, + }), + ).rejects.toMatchObject({ code: 'fs.path_escapes' }); + }); + + it('rejects stat, mkdir, resolvePath and resolveDownload through a symlinked directory', async () => { + const fs = escapeSession(); + await expect(fs.stat({ path: 'docs/secret.txt' })).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + await expect(fs.mkdir({ path: 'docs/newdir', recursive: true })).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + await expect(fs.resolvePath('docs/secret.txt')).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + await expect(fs.resolveDownload('docs/secret.txt')).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + }); + + it('rejects statMany when any path escapes through a symlinked directory', async () => { + const fs = escapeSession(); + await expect(fs.statMany({ paths: ['docs/secret.txt'] })).rejects.toMatchObject({ + code: 'fs.path_escapes', + }); + }); + + it('still allows a symlink whose target stays inside the workspace', async () => { + const fs = makeSession( + { 'real/a.txt': 'hi' }, + emptyHandler, + [], + defaultGitStub(), + [], + undefined, + { link: '/repo/real' }, + ); + const entry = await fs.stat({ path: 'link' }); + expect(entry.kind).toBe('symlink'); + }); + + it('still resolves ordinary in-workspace paths', async () => { + const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler); + const res = await fs.read({ path: 'src/a.ts', offset: 0, length: 1024, encoding: 'utf-8' }); + expect(res.content).toBe('content'); + }); +}); diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts index fe1c7c3eeb..5354a54e33 100644 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts @@ -133,6 +133,11 @@ class MemoryHostFs implements IHostFileSystem { this.files.delete(path); this.dirs.delete(path); } + + async realpath(path: string): Promise { + if (this.files.has(path) || this.dirs.has(path)) return path; + throw enoent(path); + } } function enoent(path: string): NodeJS.ErrnoException { diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts index 6a85e62f86..f8c952f05c 100644 --- a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts +++ b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts @@ -29,6 +29,7 @@ export function createFakeHostFs(overrides: Partial = {}): IHos readdir: () => notImplemented('FakeHostFs.readdir'), mkdir: () => notImplemented('FakeHostFs.mkdir'), remove: () => notImplemented('FakeHostFs.remove'), + realpath: () => notImplemented('FakeHostFs.realpath'), }; return { ...fs, ...overrides }; } diff --git a/packages/kap-server/src/middleware/auth.ts b/packages/kap-server/src/middleware/auth.ts index f3a1d670d6..b79711242c 100644 --- a/packages/kap-server/src/middleware/auth.ts +++ b/packages/kap-server/src/middleware/auth.ts @@ -29,6 +29,24 @@ export interface AuthHookOptions { readonly validateCredential?: CredentialValidator; } +/** + * Decode the request path the same way the router does before matching. + * + * `req.url` is the raw, still percent-encoded URL, while find-my-way matches + * routes against the decoded path — so a raw `/%61pi/…` reaches the `/api/…` + * handlers. Checking the decoded path keeps the auth decision aligned with + * routing. Returns `null` when the path cannot be decoded, in which case the + * caller must fail closed. + */ +function decodeRequestPath(rawUrl: string): string | null { + const path = rawUrl.split('?', 1)[0] ?? rawUrl; + try { + return decodeURIComponent(path); + } catch { + return null; + } +} + /** * Default bypass policy — the security boundary. * @@ -47,7 +65,11 @@ function defaultIsBypassed(req: FastifyRequest): boolean { if (req.method === 'OPTIONS') { return true; } - const path = req.url.split('?', 1)[0] ?? req.url; + const path = decodeRequestPath(req.url); + if (path === null) { + // Fail closed: an undecodable path must never skip authentication. + return false; + } if (req.method === 'GET' && path === '/api/v1/healthz') { return true; } diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index 25764d5b2e..ed42db8928 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -225,6 +225,49 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { expect(body.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); }); + it('rejects reads and downloads that escape the workspace through a symlink', async () => { + const outside = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-outside-')); + try { + await writeFile(join(outside, 'secret.txt'), 'top-secret'); + await symlink(outside, join(work!, 'docs'), 'dir'); + const id = await createSession(); + + const body = await postFs(id, 'read', { path: 'docs/secret.txt' }); + expect(body.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + + const res = await fetch(`${base}/api/v1/sessions/${id}/fs/docs/secret.txt:download`, { + headers: authHeaders(server as RunningServer), + } as never); + const downloadBody = (await res.json()) as Envelope; + expect(downloadBody.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('serves fs actions when the session cwd itself goes through a symlink', async () => { + const link = join(tmpdir(), `kimi-server-v2-fs-cwd-link-${process.pid}`); + await symlink(work!, link, 'dir'); + try { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: link } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + + await writeFile(join(work!, 'via-link.txt'), 'through-link'); + const read = await postFs<{ content: string }>(body.data.id, 'read', { + path: 'via-link.txt', + }); + expect(read.code).toBe(0); + expect(read.data.content).toBe('through-link'); + } finally { + await rm(link, { force: true }); + } + }); + it('GET fs/{path}:download streams the file and honors If-None-Match', async () => { await writeFile(join(work!, 'a.txt'), 'download-me'); const id = await createSession(); diff --git a/packages/kap-server/test/rateLimit.test.ts b/packages/kap-server/test/rateLimit.test.ts index bdb932bc12..c668fa70c8 100644 --- a/packages/kap-server/test/rateLimit.test.ts +++ b/packages/kap-server/test/rateLimit.test.ts @@ -97,6 +97,54 @@ describe('createAuthHook rate limiting', () => { }); }); +describe('createAuthHook bypass policy (URL encoding)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = buildApp(undefined); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('requires a token for percent-encoded /api/ paths', async () => { + // The router matches these against /api/v1/sessions; the auth hook must + // see the same decoded path instead of bypassing on the raw URL. + for (const url of ['/%61pi/v1/sessions', '/%61%70%69/v1/sessions']) { + const res = await app.inject({ method: 'GET', url }); + expect(res.statusCode).toBe(401); + } + }); + + it('serves percent-encoded /api/ paths with a valid token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/%61pi/v1/sessions', + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.statusCode).toBe(200); + }); + + it('requires a token for percent-encoded meta documents', async () => { + const res = await app.inject({ method: 'GET', url: '/%6fpenapi.json' }); + expect(res.statusCode).toBe(401); + }); + + it('still bypasses non-API paths without a token', async () => { + // No static route is registered, so a bypassed request falls through to + // the router's 404 instead of being rejected with 401. + const res = await app.inject({ method: 'GET', url: '/index.html' }); + expect(res.statusCode).toBe(404); + }); + + it('still bypasses the healthz probe when percent-encoded', async () => { + const res = await app.inject({ method: 'GET', url: '/%61pi/v1/healthz' }); + expect(res.statusCode).toBe(404); + }); +}); + describe('createAuthFailureLimiter (unit)', () => { afterEach(() => { vi.useRealTimers();