security.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import { randomBytes } from 'node:crypto'
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import type Koa from 'koa'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { loadServerConfig, type ServerConfig } from '../../src/server/config.js'
  8. import {
  9. CsrfProtection,
  10. isAllowedHostHeader,
  11. isExactOrigin,
  12. applySecurityHeaders,
  13. } from '../../src/server/security.js'
  14. const configHomes: string[] = []
  15. afterEach(() => {
  16. for (const home of configHomes.splice(0)) rmSync(home, { recursive: true, force: true })
  17. })
  18. function isolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
  19. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-security-config-'))
  20. configHomes.push(home)
  21. return { HERMES_YAOYAO_HOME: home, ...overrides }
  22. }
  23. function config(overrides: Partial<ServerConfig> = {}): ServerConfig {
  24. return {
  25. host: '127.0.0.1',
  26. port: 15300,
  27. upstream: new URL('http://127.0.0.1:9119'),
  28. allowedHosts: new Set(),
  29. home: '/tmp/hermes-yaoyao-test',
  30. allowInsecureLan: false,
  31. insecureLan: false,
  32. production: false,
  33. ...overrides,
  34. }
  35. }
  36. describe('server security boundary', () => {
  37. it('accepts loopback/private hosts and configured DNS only', () => {
  38. const value = config({ allowedHosts: new Set(['yaoyao.internal']) })
  39. expect(isAllowedHostHeader('127.0.0.1:15300', value)).toBe(true)
  40. expect(isAllowedHostHeader('[::1]:15300', value)).toBe(true)
  41. expect(isAllowedHostHeader('192.168.10.8:15300', value)).toBe(true)
  42. expect(isAllowedHostHeader('yaoyao.internal:15300', value)).toBe(true)
  43. expect(isAllowedHostHeader('127.0.0.1:9999', value)).toBe(false)
  44. expect(isAllowedHostHeader('127.0.0.1', value)).toBe(true)
  45. expect(isAllowedHostHeader('192.168.10.8:443', value)).toBe(true)
  46. expect(isAllowedHostHeader('yaoyao.internal', value)).toBe(true)
  47. expect(isAllowedHostHeader('attacker.example:15300', value)).toBe(false)
  48. expect(isAllowedHostHeader('127.0.0.1@attacker.example', value)).toBe(false)
  49. })
  50. it('accepts the configured production reverse-proxy domain on public ports', () => {
  51. const value = config({ allowedHosts: new Set(['yaoyao-lc.samien.cn']) })
  52. expect(isAllowedHostHeader('yaoyao-lc.samien.cn', value)).toBe(true)
  53. expect(isAllowedHostHeader('yaoyao-lc.samien.cn:443', value)).toBe(true)
  54. expect(isAllowedHostHeader('other.samien.cn', value)).toBe(false)
  55. })
  56. it('accepts an HTTPS browser Origin behind an HTTP TLS-terminating proxy', () => {
  57. expect(isExactOrigin('https://yaoyao.internal', 'yaoyao.internal', false)).toBe(true)
  58. expect(isExactOrigin('https://attacker.example', 'yaoyao.internal', false)).toBe(false)
  59. })
  60. it('accepts a configured public Origin when the reverse proxy rewrites Host internally', () => {
  61. const allowed = new Set(['yaoyao-lc.samien.cn'])
  62. expect(isExactOrigin('http://yaoyao-lc.samien.cn', '10.1.5.100', false, allowed)).toBe(true)
  63. expect(isExactOrigin('http://attacker.example', '10.1.5.100', false, allowed)).toBe(false)
  64. })
  65. it('adds configured reverse-proxy domains to the WebSocket CSP', () => {
  66. const headers: Record<string, string> = {}
  67. const ctx = { host: '10.1.5.100', set(name: string, value: string) { headers[name] = value } } as unknown as Koa.Context
  68. applySecurityHeaders(ctx, false, new Set(['yaoyao-lc.samien.cn']))
  69. expect(headers['Content-Security-Policy']).toContain('ws://yaoyao-lc.samien.cn')
  70. expect(headers['Content-Security-Policy']).toContain('wss://yaoyao-lc.samien.cn')
  71. })
  72. it('allows the supplier logo CDNs only in the image policy', () => {
  73. const headers: Record<string, string> = {}
  74. const ctx = { host: '127.0.0.1:15300', set(name: string, value: string) { headers[name] = value } } as unknown as Koa.Context
  75. applySecurityHeaders(ctx, false)
  76. const directives = headers['Content-Security-Policy'].split('; ')
  77. expect(directives.find(value => value.startsWith('img-src '))).toBe("img-src 'self' data: blob: https://logos.composio.dev https://assets.composio.dev")
  78. expect(directives.find(value => value.startsWith('script-src '))).toBe("script-src 'self'")
  79. expect(directives.find(value => value.startsWith('connect-src '))).not.toContain('composio.dev')
  80. })
  81. it('accepts configured IPv6 addresses and brackets them in WebSocket CSP origins', () => {
  82. const allowed = new Set(['2001:db8::10'])
  83. expect(isAllowedHostHeader('[2001:db8::10]:15300', config({ allowedHosts: allowed }))).toBe(true)
  84. expect(isExactOrigin('https://[2001:db8::10]', '10.1.5.100', false, allowed)).toBe(true)
  85. const headers: Record<string, string> = {}
  86. const ctx = { host: '10.1.5.100', set(name: string, value: string) { headers[name] = value } } as unknown as Koa.Context
  87. applySecurityHeaders(ctx, false, allowed)
  88. expect(headers['Content-Security-Policy']).toContain('ws://[2001:db8::10]')
  89. expect(headers['Content-Security-Policy']).toContain('wss://[2001:db8::10]')
  90. })
  91. it('requires an exact scheme and host Origin', () => {
  92. expect(isExactOrigin('http://127.0.0.1:15300', '127.0.0.1:15300', false)).toBe(true)
  93. expect(isExactOrigin('https://127.0.0.1:15300', '127.0.0.1:15300', false)).toBe(true)
  94. expect(isExactOrigin('http://127.0.0.1:8801', '127.0.0.1:15300', false)).toBe(false)
  95. expect(isExactOrigin('null', '127.0.0.1:15300', false)).toBe(false)
  96. })
  97. it('uses a signed double-submit CSRF token', () => {
  98. const csrf = new CsrfProtection(randomBytes(32), false)
  99. const headers: Record<string, unknown> = {}
  100. let incomingCookie = ''
  101. const ctx = {
  102. response: { headers },
  103. set(name: string, value: unknown) { headers[name.toLowerCase()] = value },
  104. get(name: string) { return name.toLowerCase() === 'cookie' ? incomingCookie : '' },
  105. } as unknown as Koa.Context
  106. const token = csrf.issue(ctx)
  107. const setCookie = (headers['set-cookie'] as string[])[0]!
  108. const cookie = setCookie.split(';', 1)[0]!
  109. incomingCookie = cookie
  110. expect(csrf.verify(cookie, token)).toBe(true)
  111. expect(csrf.issue(ctx)).toBe(token)
  112. expect(csrf.issue(ctx, true)).not.toBe(token)
  113. expect(csrf.verify(cookie, `${token}x`)).toBe(false)
  114. expect(csrf.verify(cookie.replace(/.$/, 'x'), token)).toBe(false)
  115. })
  116. it('refuses production LAN HTTP unless explicitly enabled', () => {
  117. expect(() => loadServerConfig(isolatedEnv({
  118. NODE_ENV: 'production',
  119. HERMES_YAOYAO_HOST: '0.0.0.0',
  120. }))).toThrow(/ALLOW_INSECURE_LAN/)
  121. expect(loadServerConfig(isolatedEnv({
  122. NODE_ENV: 'production',
  123. HERMES_YAOYAO_HOST: '0.0.0.0',
  124. HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
  125. })).insecureLan).toBe(true)
  126. })
  127. it('defaults the YaoYao Web listener to loopback', () => {
  128. const value = loadServerConfig(isolatedEnv())
  129. expect(value.host).toBe('127.0.0.1')
  130. expect(value.insecureLan).toBe(false)
  131. expect(value).not.toHaveProperty('yaoyaoPluginSource')
  132. expect(value.releaseSource).toBe('https://github.com/Lsamien/yaoyao.git')
  133. expect(value.allowRemoteUpdate).toBe(false)
  134. })
  135. it('requires a credential-free HTTPS or SSH system release source', () => {
  136. expect(() => loadServerConfig(isolatedEnv({
  137. HERMES_YAOYAO_RELEASE_SOURCE: 'http://git.example/hermes-yaoyao.git',
  138. }))).toThrow(/HTTPS or SSH/)
  139. expect(() => loadServerConfig(isolatedEnv({
  140. HERMES_YAOYAO_RELEASE_SOURCE: 'https://user:secret@git.example/hermes-yaoyao.git',
  141. }))).toThrow(/must not contain credentials/)
  142. expect(() => loadServerConfig(isolatedEnv({
  143. HERMES_YAOYAO_RELEASE_SOURCE: 'ssh://git:secret@git.example/hermes-yaoyao.git',
  144. }))).toThrow(/must not contain credentials/)
  145. expect(loadServerConfig(isolatedEnv({ HERMES_YAOYAO_RELEASE_SOURCE: 'ssh://git@git.example/hermes-yaoyao.git' })).releaseSource)
  146. .toBe('ssh://git@git.example/hermes-yaoyao.git')
  147. })
  148. })
  149. it('requires explicit mounted-Hermes opt-in and container-absolute installation paths',()=>{
  150. const base={HERMES_YAOYAO_LOCAL_VM_HOST:'runner',HERMES_YAOYAO_UPSTREAM:'http://hermes:9119'}
  151. expect(loadServerConfig(isolatedEnv({...base,HERMES_YAOYAO_BRIDGE_HOME:'/hermes'})).hermesBridgeMount).toBeUndefined()
  152. const mapped=loadServerConfig(isolatedEnv({...base,HERMES_YAOYAO_BRIDGE_MOUNTED:'1',HERMES_YAOYAO_BRIDGE_HOME:'/hermes'}))
  153. expect(mapped.hermesBridgeMount).toEqual({home:'/hermes',python:'/usr/bin/python3'})
  154. expect(mapped.localVmHost).toBe('runner');expect(mapped.superviseDashboard).toBe(false)
  155. for(const patch of [{HERMES_YAOYAO_BRIDGE_HOME:''},{HERMES_YAOYAO_BRIDGE_HOME:'relative'},{HERMES_YAOYAO_BRIDGE_HOME:'/hermes\nother'},{HERMES_YAOYAO_BRIDGE_PYTHON:'python3'}]){
  156. expect(()=>loadServerConfig(isolatedEnv({...base,HERMES_YAOYAO_BRIDGE_MOUNTED:'1',HERMES_YAOYAO_BRIDGE_HOME:'/hermes',...patch}))).toThrow()
  157. }
  158. })