workspaceAssetsAuthorization.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. // @vitest-environment node
  2. import { createHash } from 'node:crypto'
  3. import { existsSync, mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import type Koa from 'koa'
  7. import { afterEach, expect, it, vi } from 'vitest'
  8. import { WorkspaceAssets, type StoredWorkspaceFile } from '../../src/server/workspaceAssets'
  9. import { WorkspaceStore } from '../../src/server/workspaceStore'
  10. import { workspaceRouter } from '../../src/server/workspaceRoutes'
  11. import { saveFileAccess } from '../../src/server/fileAccess'
  12. import { HttpError } from '../../src/server/errors'
  13. import type { WorkspaceNodes } from '../../src/server/workspaceGateway'
  14. import type { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
  15. import type { UploadStore } from '../../src/server/uploads'
  16. import type { LocalAuthStore } from '../../src/server/localAuth'
  17. import type { PushCoordinator } from '../../src/server/pushCoordinator'
  18. const fixtures: Array<{ home: string; store: WorkspaceStore; assets: WorkspaceAssets }> = []
  19. afterEach(() => {
  20. for (const { home, store, assets } of fixtures.splice(0)) {
  21. assets.close(); store.close(); rmSync(home, { recursive: true, force: true })
  22. }
  23. })
  24. const response = (value: unknown) => ({ status: 200, body: Buffer.from(JSON.stringify(value)), headers: new Headers() })
  25. const downloaded = () => ({ status: 200, body: Buffer.from('fixture-report'), headers: new Headers({ 'content-type': 'text/plain' }) })
  26. const keyFor = (nodeId: string) => createHash('sha256').update(JSON.stringify([nodeId, 'alpha', 'message', '/remote/work/report.txt'])).digest('hex')
  27. function fixture() {
  28. const home = mkdtempSync(join(tmpdir(), 'yaoyao-assets-auth-')), store = new WorkspaceStore(home)
  29. let allowed = true
  30. const request = vi.fn(async (route: string, options: { search?: URLSearchParams }) => {
  31. if (route === '/api/config') return response({ terminal: { cwd: '/remote/work' } })
  32. if (route === '/api/files') {
  33. const path = options.search!.get('path')!
  34. return response({ path, entries: [{ name: 'report.txt', path: path + '/report.txt' }, { name: 'escape.txt', path: '/private/escape.txt' }] })
  35. }
  36. if (route === '/api/files/download') return downloaded()
  37. throw new Error('Unexpected mocked request: ' + route)
  38. })
  39. const requireSource = vi.fn((owner: string, _source: { nodeId: string; profile: string }) => {
  40. if (owner !== 'owner' || !allowed) throw new HttpError(403, 'Fixture source revoked', 'agent_source_forbidden')
  41. })
  42. const nodes = { requireSource, target: () => ({ session: { request } }) } as unknown as WorkspaceNodes
  43. const assets = new WorkspaceAssets(store, nodes, home)
  44. const router = workspaceRouter(store, {} as WorkspaceRuntime, nodes, assets, {} as UploadStore,
  45. { require: () => ({ id: 'owner' }) } as LocalAuthStore, {} as PushCoordinator)
  46. const handler = router.stack.find(layer => layer.path === '/api/app/files/:id/download')!.stack[0]!
  47. const download = async (id: string) => {
  48. const ctx = { params: { id }, get: () => '', set() {} } as unknown as Koa.Context
  49. await handler(ctx, async () => {})
  50. const bytes: Buffer[] = []
  51. for await (const chunk of ctx.body as NodeJS.ReadableStream) bytes.push(Buffer.from(chunk))
  52. return Buffer.concat(bytes).toString()
  53. }
  54. fixtures.push({ home, store, assets })
  55. return { home, store, assets, request, requireSource, download, revoke: () => { allowed = false },
  56. files: () => store.list<StoredWorkspaceFile>('owner', 'file'),
  57. archive: (path = '/remote/work/report.txt', messageId = 'message') =>
  58. assets.archiveText('owner', `[report](${path})`, 'local', 'alpha', 'conversation', messageId) }
  59. }
  60. it('archives authorized references with their source and checks it for UUID and library-row downloads', async () => {
  61. const f = fixture()
  62. await f.archive()
  63. const file = f.files()[0]!
  64. expect(file).toMatchObject({ sourceNodeId: 'local', profile: 'alpha', sourcePath: '/remote/work/report.txt' })
  65. expect(await f.download(file.id)).toBe('fixture-report')
  66. const row = f.store.db.prepare("SELECT rowid AS n FROM workspace_entities WHERE owner='owner' AND kind='file' AND id=?").get(file.id)!
  67. f.revoke()
  68. for (const id of [file.id, String(row.n)]) await expect(f.download(id)).rejects.toMatchObject({ status: 403, code: 'agent_source_forbidden' })
  69. })
  70. it('refuses directory and symlink escapes before making a download request', async () => {
  71. const f = fixture()
  72. await f.archive('/outside/report.txt')
  73. await f.archive('/remote/work/escape.txt')
  74. expect(f.request.mock.calls.some(([route]) => route === '/api/files/download')).toBe(false)
  75. expect(f.files()).toEqual([])
  76. expect(existsSync(join(f.home, 'workspace-files'))).toBe(false)
  77. })
  78. it('refuses an unassigned source before any upstream request', async () => {
  79. const f = fixture(); f.revoke()
  80. await f.archive()
  81. expect(f.request).not.toHaveBeenCalled()
  82. expect(f.files()).toEqual([])
  83. })
  84. it.each(['source', 'directory', 'quota'] as const)('does not persist bytes if %s authorization changes during a download', async change => {
  85. const f = fixture()
  86. saveFileAccess(f.home, { mode: 'folders', folders: ['/extra'] })
  87. let finish!: (value: ReturnType<typeof downloaded>) => void
  88. let started!: () => void
  89. const pending = new Promise<ReturnType<typeof downloaded>>(resolve => { finish = resolve })
  90. const downloading = new Promise<void>(resolve => { started = resolve })
  91. const original = f.request.getMockImplementation()!
  92. f.request.mockImplementation((route, options) => {
  93. if (route === '/api/files/download') { started(); return pending }
  94. return original(route, options)
  95. })
  96. const archive = f.archive('/extra/report.txt')
  97. await downloading
  98. if (change === 'source') f.revoke()
  99. else if (change === 'directory') saveFileAccess(f.home, { mode: 'folders', folders: [] })
  100. else f.store.put('owner', 'file', 'full-quota', { id: 'full-quota', size: 2 * 1024 * 1024 * 1024 })
  101. finish(downloaded()); await archive
  102. expect(f.files()).toHaveLength(change === 'quota' ? 1 : 0)
  103. expect(existsSync(join(f.home, 'workspace-files'))).toBe(false)
  104. expect(f.store.list('owner', 'archived-path')).toEqual([])
  105. })
  106. it.each(['quota', 'message-files'] as const)('enforces the existing %s limit for automatic archives', async limit => {
  107. const f = fixture()
  108. for (let i = 0; i < (limit === 'quota' ? 1 : 8); i++)
  109. f.store.put('owner', 'file', `existing-${i}`, { id: `existing-${i}`, messageId: limit === 'quota' ? 'older-message' : 'message', size: limit === 'quota' ? 2 * 1024 * 1024 * 1024 : 1 })
  110. await f.archive()
  111. expect(f.files()).toHaveLength(limit === 'quota' ? 1 : 8)
  112. expect(existsSync(join(f.home, 'workspace-files'))).toBe(false)
  113. })
  114. it('recovers a uniquely authorized legacy source from its archive key without downloading again', async () => {
  115. const f = fixture(); await f.archive()
  116. const file = f.files()[0]!
  117. delete file.sourceNodeId; f.store.put('owner', 'file', file.id, file)
  118. f.request.mockClear()
  119. expect(await f.download(file.id)).toBe('fixture-report')
  120. expect(f.files()[0]!.sourceNodeId).toBe('local')
  121. expect(f.request).not.toHaveBeenCalled()
  122. })
  123. it.each(['unknown', 'ambiguous', 'ambiguous-revoked', 'duplicate-record', 'revoked'] as const)('denies a %s legacy archive instead of guessing its source', async reason => {
  124. const f = fixture(); await f.archive()
  125. const file = f.files()[0]!
  126. delete file.sourceNodeId; f.store.put('owner', 'file', file.id, file)
  127. if (reason === 'unknown') f.store.remove('owner', 'archived-path', keyFor('local'))
  128. if (reason === 'ambiguous' || reason === 'ambiguous-revoked') {
  129. f.store.put('owner', 'node', 'other-node', { id: 'other-node' })
  130. f.store.put('owner', 'archived-path', keyFor('other-node'), true)
  131. if (reason === 'ambiguous-revoked') f.requireSource.mockImplementation((owner, source: { nodeId: string }) => {
  132. if (owner !== 'owner' || source.nodeId !== 'local') throw new HttpError(403, 'Fixture source revoked', 'agent_source_forbidden')
  133. })
  134. }
  135. if (reason === 'duplicate-record') f.store.put('owner', 'file', 'duplicate', { ...file, id: 'duplicate' })
  136. if (reason === 'revoked') f.revoke()
  137. f.request.mockClear()
  138. await expect(f.download(file.id)).rejects.toMatchObject({ status: 403, code: 'archived_file_source_unknown' })
  139. expect(f.files()[0]!.sourceNodeId).toBeUndefined()
  140. expect(f.request).not.toHaveBeenCalled()
  141. })