node-half.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
  60. await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
  61. expect(routes).toHaveLength(0)
  62. })
  63. it('registers the /api prefix route and removes it with the fiber', async () => {
  64. const { routes, dispose } = await mounted()
  65. expect(routes).toHaveLength(1)
  66. expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
  67. await dispose()
  68. expect(routes).toHaveLength(0)
  69. })
  70. it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
  71. const { routes, dispose } = await mounted()
  72. const { response, state } = fakeResponse()
  73. await routes[0]!.handler(fakeRequest({
  74. host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
  75. }), response)
  76. expect(state.status).toBe(403)
  77. expect(state.body).toBe('forbidden')
  78. await dispose()
  79. })
  80. it('pins privileged methods to loopback even for a declared trusted authority', async () => {
  81. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  82. // The privileged set: native dialogs plus the whole settings/credential
  83. // configuration plane, reads included. The same declared authority reaches
  84. // ordinary reads (carrier-level 404 from the empty proxy proves the fence
  85. // passed), but each privileged method stays loopback-only and 403s.
  86. for (const method of [
  87. 'host.pickDirectory', 'host.openPath',
  88. 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
  89. 'credentials.describe', 'credentials.set', 'credentials.unset',
  90. ]) {
  91. const denied = fakeResponse()
  92. await routes[0]!.handler(
  93. fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`),
  94. denied.response,
  95. )
  96. expect(denied.state.status).toBe(403)
  97. expect(denied.state.body).toBe('forbidden')
  98. }
  99. const read = fakeResponse()
  100. await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response)
  101. expect(read.state.status).not.toBe(403)
  102. await dispose()
  103. })
  104. it('passes loopback and declared-authority requests through to the bridge', async () => {
  105. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
  106. // Loopback, no browser markers (curl shape): the fence passes; the carrier
  107. // answers 404 for a GET unary path — proof the bridge ran.
  108. const loopback = fakeResponse()
  109. await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
  110. expect(loopback.state.status).toBe(404)
  111. // LAN authority declared as a port-less IP literal — the shape the CLI
  112. // derives for `--host 0.0.0.0` — passes markerless curl on any port.
  113. const lan = fakeResponse()
  114. await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
  115. expect(lan.state.status).toBe(404)
  116. // Declared public authority, same-origin browser shape.
  117. const declared = fakeResponse()
  118. await routes[0]!.handler(fakeRequest({
  119. host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
  120. }), declared.response)
  121. expect(declared.state.status).toBe(404)
  122. await dispose()
  123. })
  124. })
  125. describe('connection node half over a real HTTP server', () => {
  126. /** Serve the registered prefix route from a real server and return its port. */
  127. async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
  128. const server = createServer((request, response) => {
  129. void routes[0]!.handler(request, response)
  130. })
  131. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  132. const address = server.address() as AddressInfo
  133. return {
  134. port: address.port,
  135. close: () => new Promise<void>((resolve, reject) => {
  136. server.close((error) => {
  137. if (error === undefined || error === null) resolve()
  138. else reject(error)
  139. })
  140. }),
  141. }
  142. }
  143. /** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
  144. function call(port: number, method: string, host: string): Promise<number> {
  145. return new Promise((resolve, reject) => {
  146. const request = httpRequest(
  147. { host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
  148. (response) => {
  149. response.resume()
  150. response.on('end', () => { resolve(response.statusCode ?? 0) })
  151. },
  152. )
  153. request.on('error', reject)
  154. request.end()
  155. })
  156. }
  157. it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
  158. // The fence's input is a real IncomingMessage parsed by Node from the
  159. // wire, not a hand-assembled object: the Host header a LAN browser sends
  160. // is exactly what decides loopback-only here, so the boundary is asserted
  161. // against the parse the server actually performs.
  162. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  163. const { port, close } = await serve(routes)
  164. try {
  165. // Reads are as privileged as writes: describe returns the exposed
  166. // configuration, and credentials.describe probes arbitrary env-var names.
  167. for (const method of [
  168. 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
  169. 'credentials.describe', 'credentials.set', 'credentials.unset',
  170. 'host.pickDirectory', 'host.openPath',
  171. ]) {
  172. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
  173. }
  174. // The model catalog stays reachable for the same authority: a LAN
  175. // client's model picker needs it, and it carries no key or endpoint
  176. // state (404 is the empty proxy's carrier answer — the fence passed).
  177. for (const method of ['llm.providers', 'llm.models']) {
  178. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
  179. }
  180. // Loopback reaches everything, configuration included.
  181. expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
  182. } finally {
  183. await close()
  184. await dispose()
  185. }
  186. })
  187. })