node-half.spec.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /** Node half: registers the /api prefix route bridging to the api gateway. */
  2. import { EventEmitter } from 'node:events'
  3. import { createServer, request as httpRequest } from 'node:http'
  4. import { Readable } from 'node:stream'
  5. import { Context } from 'cordis'
  6. import { describe, expect, it } from 'vitest'
  7. import type { AddressInfo } from 'node:net'
  8. import type { IncomingMessage, ServerResponse } from 'node:http'
  9. import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
  10. import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
  11. import { API_PATH, apply, inject } from '../src/index.ts'
  12. /** Structural httpServer fake: the plugin only touches register(). */
  13. function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
  14. return {
  15. register(route) {
  16. routes.push(route)
  17. return () => { routes.splice(routes.indexOf(route), 1) }
  18. },
  19. tapIndex: () => () => {},
  20. port: 0,
  21. }
  22. }
  23. /** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
  24. function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
  25. const request = Readable.from([]) as unknown as IncomingMessage
  26. Object.assign(request, { url, method: 'GET', headers })
  27. return request
  28. }
  29. /** Response recorder compatible with both the fence's short-circuit and the bridge. */
  30. function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
  31. const state: { status?: number; body?: unknown } = {}
  32. const response = Object.assign(new EventEmitter(), {
  33. writableEnded: false,
  34. writeHead(value: number) { state.status = value; return this },
  35. write() { return true },
  36. end(this: { writableEnded: boolean }, value?: unknown) {
  37. if (value !== undefined) state.body = value
  38. this.writableEnded = true
  39. return this
  40. },
  41. }) as unknown as ServerResponse
  42. return { response, state }
  43. }
  44. async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
  45. const ctx = new Context()
  46. const routes: WebRoute[] = []
  47. ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
  48. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  49. const fiber = ctx.plugin({ inject: [...inject], apply }, config)
  50. await fiber.await()
  51. return { routes, dispose: () => fiber.dispose() }
  52. }
  53. describe('connection node half', () => {
  54. it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
  55. const routes: WebRoute[] = []
  56. const ctx = new Context()
  57. ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
  58. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  59. // The apply throw also escapes cordis as a late rejection — the shape the
  60. // boot's installFailLoud is contracted to catch. Capture it so the run
  61. // stays clean, same pattern as the webserver bind-failure test.
  62. const rejections: unknown[] = []
  63. const onUnhandled = (err: unknown): void => { rejections.push(err) }
  64. process.on('unhandledRejection', onUnhandled)
  65. try {
  66. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
  67. await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
  68. expect(routes).toHaveLength(0)
  69. for (let i = 0; i < 100 && rejections.length === 0; i++) {
  70. await new Promise(resolve => setTimeout(resolve, 10))
  71. }
  72. expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
  73. } finally {
  74. process.off('unhandledRejection', onUnhandled)
  75. }
  76. })
  77. it('registers the /api prefix route and removes it with the fiber', async () => {
  78. const { routes, dispose } = await mounted()
  79. expect(routes).toHaveLength(1)
  80. expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
  81. await dispose()
  82. expect(routes).toHaveLength(0)
  83. })
  84. it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
  85. const { routes, dispose } = await mounted()
  86. const { response, state } = fakeResponse()
  87. await routes[0]!.handler(fakeRequest({
  88. host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
  89. }), response)
  90. expect(state.status).toBe(403)
  91. expect(state.body).toBe('forbidden')
  92. await dispose()
  93. })
  94. it('pins privileged methods to loopback even for a declared trusted authority', async () => {
  95. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  96. // The privileged set: native dialogs plus the whole settings/credential
  97. // configuration plane, reads included. The same declared authority reaches
  98. // ordinary reads (carrier-level 404 from the empty proxy proves the fence
  99. // passed), but each privileged method stays loopback-only and 403s.
  100. for (const method of [
  101. 'host.pickDirectory', 'host.openPath',
  102. 'settings.describe', 'settings.update', 'settings.replace',
  103. 'credentials.describe', 'credentials.set', 'credentials.unset',
  104. ]) {
  105. const denied = fakeResponse()
  106. await routes[0]!.handler(
  107. fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`),
  108. denied.response,
  109. )
  110. expect(denied.state.status).toBe(403)
  111. expect(denied.state.body).toBe('forbidden')
  112. }
  113. const read = fakeResponse()
  114. await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response)
  115. expect(read.state.status).not.toBe(403)
  116. await dispose()
  117. })
  118. it('passes loopback and declared-authority requests through to the bridge', async () => {
  119. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
  120. // Loopback, no browser markers (curl shape): the fence passes; the carrier
  121. // answers 404 for a GET unary path — proof the bridge ran.
  122. const loopback = fakeResponse()
  123. await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
  124. expect(loopback.state.status).toBe(404)
  125. // LAN authority declared as a port-less IP literal — the shape the CLI
  126. // derives for `--host 0.0.0.0` — passes markerless curl on any port.
  127. const lan = fakeResponse()
  128. await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
  129. expect(lan.state.status).toBe(404)
  130. // Declared public authority, same-origin browser shape.
  131. const declared = fakeResponse()
  132. await routes[0]!.handler(fakeRequest({
  133. host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
  134. }), declared.response)
  135. expect(declared.state.status).toBe(404)
  136. await dispose()
  137. })
  138. })
  139. describe('connection node half over a real HTTP server', () => {
  140. /** Serve the registered prefix route from a real server and return its port. */
  141. async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
  142. const server = createServer((request, response) => {
  143. void routes[0]!.handler(request, response)
  144. })
  145. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  146. const address = server.address() as AddressInfo
  147. return {
  148. port: address.port,
  149. close: () => new Promise<void>((resolve, reject) => {
  150. server.close((error) => {
  151. if (error === undefined || error === null) resolve()
  152. else reject(error)
  153. })
  154. }),
  155. }
  156. }
  157. /** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
  158. function call(port: number, method: string, host: string): Promise<number> {
  159. return new Promise((resolve, reject) => {
  160. const request = httpRequest(
  161. { host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
  162. (response) => {
  163. response.resume()
  164. response.on('end', () => { resolve(response.statusCode ?? 0) })
  165. },
  166. )
  167. request.on('error', reject)
  168. request.end()
  169. })
  170. }
  171. it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
  172. // The fence's input is a real IncomingMessage parsed by Node from the
  173. // wire, not a hand-assembled object: the Host header a LAN browser sends
  174. // is exactly what decides loopback-only here, so the boundary is asserted
  175. // against the parse the server actually performs.
  176. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  177. const { port, close } = await serve(routes)
  178. try {
  179. // Reads are as privileged as writes: describe returns the exposed
  180. // configuration, and credentials.describe probes arbitrary env-var names.
  181. for (const method of [
  182. 'settings.describe', 'settings.update', 'settings.replace',
  183. 'credentials.describe', 'credentials.set', 'credentials.unset',
  184. 'host.pickDirectory', 'host.openPath',
  185. ]) {
  186. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
  187. }
  188. // The model catalog stays reachable for the same authority: a LAN
  189. // client's model picker needs it, and it carries no key or endpoint
  190. // state (404 is the empty proxy's carrier answer — the fence passed).
  191. for (const method of ['llm.providers', 'llm.models']) {
  192. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
  193. }
  194. // Loopback reaches everything, configuration included.
  195. expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
  196. } finally {
  197. await close()
  198. await dispose()
  199. }
  200. })
  201. })