loopbackAuthorization.test.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. // @vitest-environment node
  2. import { createServer } from 'node:http'
  3. import { once } from 'node:events'
  4. import type { AddressInfo } from 'node:net'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { allowsLocalAuthorization, isLocalAuthorizationTarget, localSessionToken, LoopbackTransport } from '../../src/server/loopbackAuthorization.js'
  7. import { UpstreamServiceSession } from '../../src/server/localAuth.js'
  8. import { CookieJar, UpstreamClient } from '../../src/server/upstream.js'
  9. const clients: UpstreamClient[] = []
  10. afterEach(() => { clients.splice(0).forEach(client => client.close()); vi.unstubAllEnvs() })
  11. function fixture(base = 'http://127.0.0.1:9119') {
  12. let token = 'native_loopback_fixture_token_initial', gated = false, page = true
  13. const paths: string[] = [], headers: Headers[] = []
  14. const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
  15. const path = new URL(String(input)).pathname, header = new Headers(init?.headers)
  16. paths.push(path); headers.push(header)
  17. if (path === '/api/status') return Response.json({ auth_required: gated })
  18. if (path === '/') return new Response(page ? `<script>window.__HERMES_SESSION_TOKEN__=${JSON.stringify(token)};</script>` : '<html>login required</html>')
  19. if (path === '/api/auth/me') return Response.json({}, { status: 401 })
  20. if (header.get('x-hermes-session-token') !== token || gated) return Response.json({}, { status: 401 })
  21. return Response.json({ profiles: [], ok: true })
  22. })
  23. const client = new UpstreamClient(new URL(base), fetch); clients.push(client)
  24. const session = new UpstreamServiceSession(client, () => undefined)
  25. return { session, client, fetch, paths, headers, get token() { return token },
  26. rotate() { token = 'native_loopback_fixture_token_rotated' }, gate() { gated = true }, hideToken() { page = false } }
  27. }
  28. describe('native loopback authorization', () => {
  29. it('uses the native session token for protected REST and WebSocket, without password login', async () => {
  30. const f = fixture()
  31. expect((await f.session.request('/api/profiles')).status).toBe(200)
  32. expect(await f.session.webSocketCredential()).toEqual({ name: 'token', value: f.token })
  33. expect(f.session.authorizationMode).toBe('local')
  34. expect(f.paths).not.toContain('/auth/password-login')
  35. expect(f.paths).not.toContain('/api/auth/ws-ticket')
  36. expect(f.headers.every(h => !h.has('x-forwarded-for'))).toBe(true)
  37. expect(f.session.jar.browserCookies).toEqual([])
  38. expect(f.session.jar.header).toBeUndefined()
  39. })
  40. it('coalesces token acquisition and refreshes once after parallel 401s', async () => {
  41. const f = fixture()
  42. await Promise.all(Array.from({ length: 8 }, () => f.session.webSocketCredential()))
  43. expect(f.paths.filter(p => p === '/')).toHaveLength(1)
  44. f.rotate()
  45. const result = await Promise.all(Array.from({ length: 8 }, (_, i) => f.session.request(`/api/sessions/${i}`)))
  46. expect(result.every(r => r.status === 200)).toBe(true)
  47. expect(f.paths.filter(p => p === '/')).toHaveLength(2)
  48. expect(f.session.jar.sessionToken).toBe(f.token)
  49. })
  50. it('detects a rotated token before opening another socket, within the validation lease', async () => {
  51. const f = fixture()
  52. await f.session.webSocketCredential(); f.rotate()
  53. expect(await f.session.webSocketCredential()).toEqual({ name: 'token', value: f.token })
  54. expect(f.paths.filter(p => p === '/')).toHaveLength(2)
  55. })
  56. it('does not bypass account authorization on a loopback server', async () => {
  57. const f = fixture(); f.gate()
  58. await expect(f.session.request('/api/profiles')).rejects.toMatchObject({ code: 'upstream_credentials_required' })
  59. expect(f.paths).not.toContain('/')
  60. expect(f.paths).not.toContain('/auth/password-login')
  61. })
  62. it('fails closed when local Hermes begins requiring an account', async () => {
  63. const f = fixture(); await f.session.webSocketCredential(); f.gate()
  64. await expect(f.session.webSocketCredential()).rejects.toMatchObject({ code: 'upstream_credentials_required' })
  65. expect(f.paths.filter(p => p === '/')).toHaveLength(1)
  66. })
  67. it('rechecks auth mode on a ticket 401 after the upstream restarts into local mode', async () => {
  68. const f = fixture()
  69. f.fetch.mockResolvedValueOnce(Response.json({ auth_required: true }))
  70. .mockResolvedValueOnce(Response.json({ user_id: 'service' }))
  71. await f.session.ensure()
  72. f.fetch.mockResolvedValueOnce(Response.json({}, { status: 401 }))
  73. expect(await f.session.webSocketCredential()).toEqual({ name: 'token', value: f.token })
  74. expect(f.session.authorizationMode).toBe('local')
  75. expect(f.paths).not.toContain('/auth/password-login')
  76. })
  77. it('renews an expired account before retrying ticket issuance only once', async () => {
  78. const f = fixture()
  79. const session = new UpstreamServiceSession(f.client, () => ({ username: 'service', password: 'fixture' }))
  80. f.fetch.mockResolvedValueOnce(Response.json({ auth_required: true }))
  81. .mockResolvedValueOnce(Response.json({ user_id: 'service' }))
  82. .mockResolvedValueOnce(Response.json({}, { status: 401 }))
  83. .mockResolvedValueOnce(Response.json({ auth_required: true }))
  84. .mockResolvedValueOnce(Response.json({ providers: [] }))
  85. .mockResolvedValueOnce(Response.json({ ok: true }, { headers: { 'Set-Cookie': 'service=renewed; Path=/' } }))
  86. .mockResolvedValueOnce(Response.json({ ticket: 'new-ticket' }))
  87. expect(await session.webSocketCredential()).toEqual({ name: 'ticket', value: 'new-ticket' })
  88. expect(f.fetch.mock.calls.map(([input]) => new URL(String(input)).pathname)).toEqual([
  89. '/api/status', '/api/auth/me', '/api/auth/ws-ticket', '/api/status', '/api/auth/providers', '/auth/password-login', '/api/auth/ws-ticket',
  90. ])
  91. expect(session.jar.sessionToken).toBeUndefined()
  92. })
  93. it.each(['http://192.168.1.2:9119', 'http://host.docker.internal:9119', 'http://localhost:9119', 'https://remote.example'])('rejects account-free authorization outside literal loopback: %s', async base => {
  94. const f = fixture(base)
  95. await expect(f.session.ensure()).rejects.toMatchObject({ code: 'loopback_auth_required' })
  96. expect(f.paths).toEqual(['/api/status'])
  97. })
  98. it('never attaches a local token to a remote request', async () => {
  99. const f = fixture('https://remote.example'), jar = new CookieJar()
  100. jar.setSessionToken(f.token)
  101. await expect(f.client.request('/api/profiles', jar)).rejects.toMatchObject({ code: 'loopback_auth_required' })
  102. expect(f.fetch).not.toHaveBeenCalled()
  103. })
  104. it('fails closed if the native token is absent, without persisting partial authorization', async () => {
  105. const f = fixture(); f.hideToken()
  106. await expect(f.session.webSocketCredential()).rejects.toMatchObject({ code: 'local_authorization_unavailable' })
  107. expect(f.session.jar.sessionToken).toBeUndefined()
  108. })
  109. it('does not replay a command after transport failure or an upstream 500', async () => {
  110. const f = fixture(); await f.session.webSocketCredential()
  111. f.fetch.mockResolvedValueOnce(Response.json({}, { status: 500 }))
  112. const before = f.fetch.mock.calls.length
  113. expect((await f.session.request('/api/command', { method: 'POST', body: { text: 'once' } })).status).toBe(500)
  114. expect(f.fetch.mock.calls.length - before).toBe(1)
  115. f.fetch.mockRejectedValueOnce(new Error('connection lost'))
  116. await expect(f.session.request('/api/command', { method: 'POST', body: { text: 'once' } })).rejects.toMatchObject({ code: 'upstream_unavailable' })
  117. expect(f.fetch.mock.calls.length - before).toBe(2)
  118. })
  119. it('parses only a literal native token, never executes HTML or JavaScript', () => {
  120. expect(allowsLocalAuthorization({ auth_required: true, authRequired: false })).toBe(false)
  121. expect(allowsLocalAuthorization({})).toBe(false)
  122. expect(isLocalAuthorizationTarget(new URL('http://[::1]:9119'))).toBe(true)
  123. expect(isLocalAuthorizationTarget(new URL('http://user:pass@127.0.0.1:9119'))).toBe(false)
  124. expect(localSessionToken('window.__HERMES_SESSION_TOKEN__ = "abc"')).toBeUndefined()
  125. expect(localSessionToken('window.__HERMES_SESSION_TOKEN__ = alert(1)')).toBeUndefined()
  126. expect(localSessionToken('window.__HERMES_SESSION_TOKEN__ = "native_token_with_\\u0031_escape";')).toBe('native_token_with_1_escape')
  127. })
  128. it('bounds token-page reads even when the response has no Content-Length', async () => {
  129. const f = fixture()
  130. let cancelled = false
  131. f.fetch.mockResolvedValueOnce(new Response(new ReadableStream({
  132. pull(controller) { controller.enqueue(new Uint8Array(1024)) },
  133. cancel() { cancelled = true },
  134. })))
  135. await expect(f.client.request('/', new CookieJar(), { maxResponseBytes: 128 })).rejects.toMatchObject({ code: 'upstream_too_large' })
  136. expect(cancelled).toBe(true)
  137. })
  138. it('connects directly despite proxy settings, never follows redirects or leaks a token to a redirect target', async () => {
  139. let proxyHits = 0, redirectHits = 0, received = ''
  140. const proxy = createServer((_req, res) => { proxyHits++; res.end('proxy') })
  141. proxy.listen(0, '127.0.0.1'); await once(proxy, 'listening')
  142. const origin = createServer((req, res) => {
  143. if (req.url === '/target') redirectHits++
  144. received = String(req.headers['x-hermes-session-token'] ?? '')
  145. res.writeHead(302, { Location: '/target' }); res.end()
  146. })
  147. origin.listen(0, '127.0.0.1'); await once(origin, 'listening')
  148. vi.stubEnv('HTTP_PROXY', `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`)
  149. vi.stubEnv('NODE_USE_ENV_PROXY', '1'); vi.stubEnv('NO_PROXY', '')
  150. const transport = new LoopbackTransport()
  151. try {
  152. const response = await transport.fetch(new URL(`http://127.0.0.1:${(origin.address() as AddressInfo).port}/`), { headers: { 'X-Hermes-Session-Token': 'fixture-token' } })
  153. expect(response.status).toBe(302); await response.arrayBuffer()
  154. expect(received).toBe('fixture-token'); expect(proxyHits).toBe(0); expect(redirectHits).toBe(0)
  155. } finally {
  156. transport.close()
  157. await Promise.all([new Promise<void>(r => origin.close(() => r())), new Promise<void>(r => proxy.close(() => r()))])
  158. }
  159. })
  160. })