Parcourir la source

fix: 受限访问历史本地媒体链接

samien il y a 2 mois
Parent
commit
1b3f5d452b
3 fichiers modifiés avec 90 ajouts et 2 suppressions
  1. 6 1
      src/server/config.ts
  2. 63 0
      src/server/routes.ts
  3. 21 1
      tests/server/app.test.ts

+ 6 - 1
src/server/config.ts

@@ -1,6 +1,6 @@
 import { isIP } from 'node:net'
 import { homedir } from 'node:os'
-import { resolve } from 'node:path'
+import { basename, resolve } from 'node:path'
 
 export interface ServerConfig {
   host: string
@@ -8,6 +8,8 @@ export interface ServerConfig {
   upstream: URL
   allowedHosts: ReadonlySet<string>
   home: string
+  mediaRoot: string
+  mediaOwner: string
   tlsCert?: string
   tlsKey?: string
   allowInsecureLan: boolean
@@ -83,6 +85,7 @@ export function loadServerConfig(
   const port = parsePort(env.HERMES_YAOYAO_PORT)
   const upstream = parseUpstream(env.HERMES_YAOYAO_UPSTREAM)
   const home = resolve(env.HERMES_YAOYAO_HOME?.trim() || `${homedir()}/.hermes-yaoyao`)
+  const mediaRoot = resolve(env.HERMES_YAOYAO_MEDIA_ROOT?.trim() || `${homedir()}/Agents`)
   const tlsCert = env.HERMES_YAOYAO_TLS_CERT?.trim() || undefined
   const tlsKey = env.HERMES_YAOYAO_TLS_KEY?.trim() || undefined
   if (Boolean(tlsCert) !== Boolean(tlsKey)) {
@@ -108,6 +111,8 @@ export function loadServerConfig(
     upstream,
     allowedHosts,
     home,
+    mediaRoot,
+    mediaOwner: basename(homedir()),
     tlsCert,
     tlsKey,
     allowInsecureLan,

+ 63 - 0
src/server/routes.ts

@@ -1,6 +1,9 @@
 import type Koa from 'koa'
 import Router from '@koa/router'
 import { parse } from 'cookie'
+import { createReadStream, realpathSync, statSync } from 'node:fs'
+import { basename, resolve, sep } from 'node:path'
+import { lookup as mimeLookup } from 'mime-types'
 import type { ServerConfig } from './config.js'
 import { isLoopbackUpstream } from './config.js'
 import { HttpError } from './errors.js'
@@ -371,6 +374,49 @@ function groupPath(ctx: Koa.Context, suffix = ''): string {
   return `/api/plugins/yaoyao/v1/rooms/${roomID}${suffix}`
 }
 
+function localMediaPath(root: string, relativePath: string): string {
+  let resolvedRoot: string
+  let resolvedFile: string
+  try {
+    resolvedRoot = realpathSync(root)
+    resolvedFile = realpathSync(resolve(resolvedRoot, relativePath))
+  } catch {
+    throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
+  }
+  if (!resolvedFile.startsWith(`${resolvedRoot}${sep}`) || !statSync(resolvedFile).isFile()) {
+    throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
+  }
+  return resolvedFile
+}
+
+function sendLocalMedia(ctx: Koa.Context, path: string): void {
+  const size = statSync(path).size
+  const type = mimeLookup(path) || 'application/octet-stream'
+  const fileName = basename(path)
+  const range = ctx.get('range').match(/^bytes=(\d*)-(\d*)$/)
+  ctx.set('Accept-Ranges', 'bytes')
+  ctx.set('Cache-Control', 'private, no-store')
+  ctx.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`)
+  ctx.type = type
+  if (!range) {
+    ctx.length = size
+    ctx.body = createReadStream(path)
+    return
+  }
+  const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0))
+  const end = range[2] ? Number(range[2]) : size - 1
+  if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
+    ctx.status = 416
+    ctx.set('Content-Range', `bytes */${size}`)
+    return
+  }
+  const boundedEnd = Math.min(end, size - 1)
+  ctx.status = 206
+  ctx.set('Content-Range', `bytes ${start}-${boundedEnd}/${size}`)
+  ctx.length = boundedEnd - start + 1
+  ctx.body = createReadStream(path, { start, end: boundedEnd })
+}
+
 export function createApiRouter(dependencies: RouteDependencies): Router {
   const router = new Router()
 
@@ -378,6 +424,23 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     ctx.set('Cache-Control', 'no-store')
     json(ctx, 200, { ok: true })
   })
+
+  // Historical messages can contain Markdown links such as
+  // /Users/<owner>/Agents/<path>. Map only the configured media root, never
+  // an arbitrary local filesystem path.
+  router.get('/Users/:owner/Agents/*filePath', async (ctx) => {
+    if (!isLoopbackUpstream(dependencies.config.upstream)) {
+      throw new HttpError(409, '本地媒体只支持回环 Hermes 上游', 'remote_local_media_disabled')
+    }
+    if (ctx.params.owner !== dependencies.config.mediaOwner) {
+      throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
+    }
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
+      sendLocalMedia(ctx, localMediaPath(dependencies.config.mediaRoot, filePath))
+    })
+  })
   router.get('/readyz', async (ctx) => {
     ctx.set('Cache-Control', 'no-store')
     try {

+ 21 - 1
tests/server/app.test.ts

@@ -1,4 +1,4 @@
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import request from 'supertest'
@@ -24,6 +24,8 @@ afterEach(() => {
 
 function makeConfig(upstream = 'http://127.0.0.1:9119'): ServerConfig {
   const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-server-'))
+  const mediaRoot = join(home, 'media')
+  mkdirSync(mediaRoot)
   homes.push(home)
   return {
     host: '127.0.0.1',
@@ -31,6 +33,8 @@ function makeConfig(upstream = 'http://127.0.0.1:9119'): ServerConfig {
     upstream: new URL(upstream),
     allowedHosts: new Set(),
     home,
+    mediaRoot,
+    mediaOwner: 'samien',
     allowInsecureLan: false,
     insecureLan: false,
     production: false,
@@ -93,6 +97,22 @@ function cookieHeader(response: request.Response): string {
 }
 
 describe('8800 BFF', () => {
+  it('serves authenticated historical media only from the configured root', async () => {
+    const config = makeConfig()
+    writeFileSync(join(config.mediaRoot, '报告.txt'), '历史文件内容')
+    const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
+    runtimes.push(runtime)
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const cookies = cookieHeader(bootstrap)
+
+    const response = await request(runtime.app.callback())
+      .get('/Users/samien/Agents/报告.txt').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(200)
+    expect(response.text).toBe('历史文件内容')
+    expect(response.headers['content-disposition']).toContain('inline')
+    await request(runtime.app.callback())
+      .get('/Users/samien/Agents/../state.db').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(404)
+  })
   it('bootstraps sequentially without exposing the upstream address', async () => {
     const records: RecordedRequest[] = []
     const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })