security.test.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. import { randomBytes } from 'node:crypto'
  2. import type Koa from 'koa'
  3. import { describe, expect, it } from 'vitest'
  4. import { loadServerConfig, type ServerConfig } from '../../src/server/config.js'
  5. import {
  6. CsrfProtection,
  7. isAllowedHostHeader,
  8. isExactOrigin,
  9. applySecurityHeaders,
  10. } from '../../src/server/security.js'
  11. function config(overrides: Partial<ServerConfig> = {}): ServerConfig {
  12. return {
  13. host: '127.0.0.1',
  14. port: 8800,
  15. upstream: new URL('http://127.0.0.1:9119'),
  16. allowedHosts: new Set(),
  17. home: '/tmp/hermes-yaoyao-test',
  18. allowInsecureLan: false,
  19. insecureLan: false,
  20. production: false,
  21. ...overrides,
  22. }
  23. }
  24. describe('server security boundary', () => {
  25. it('accepts loopback/private hosts and configured DNS only', () => {
  26. const value = config({ allowedHosts: new Set(['yaoyao.internal']) })
  27. expect(isAllowedHostHeader('127.0.0.1:8800', value)).toBe(true)
  28. expect(isAllowedHostHeader('[::1]:8800', value)).toBe(true)
  29. expect(isAllowedHostHeader('192.168.10.8:8800', value)).toBe(true)
  30. expect(isAllowedHostHeader('yaoyao.internal:8800', value)).toBe(true)
  31. expect(isAllowedHostHeader('127.0.0.1:9999', value)).toBe(false)
  32. expect(isAllowedHostHeader('127.0.0.1', value)).toBe(true)
  33. expect(isAllowedHostHeader('192.168.10.8:443', value)).toBe(true)
  34. expect(isAllowedHostHeader('yaoyao.internal', value)).toBe(true)
  35. expect(isAllowedHostHeader('attacker.example:8800', value)).toBe(false)
  36. expect(isAllowedHostHeader('127.0.0.1@attacker.example', value)).toBe(false)
  37. })
  38. it('accepts the configured production reverse-proxy domain on public ports', () => {
  39. const value = config({ allowedHosts: new Set(['yaoyao-lc.samien.cn']) })
  40. expect(isAllowedHostHeader('yaoyao-lc.samien.cn', value)).toBe(true)
  41. expect(isAllowedHostHeader('yaoyao-lc.samien.cn:443', value)).toBe(true)
  42. expect(isAllowedHostHeader('other.samien.cn', value)).toBe(false)
  43. })
  44. it('accepts an HTTPS browser Origin behind an HTTP TLS-terminating proxy', () => {
  45. expect(isExactOrigin('https://yaoyao.internal', 'yaoyao.internal', false)).toBe(true)
  46. expect(isExactOrigin('https://attacker.example', 'yaoyao.internal', false)).toBe(false)
  47. })
  48. it('accepts a configured public Origin when the reverse proxy rewrites Host internally', () => {
  49. const allowed = new Set(['yaoyao-lc.samien.cn'])
  50. expect(isExactOrigin('http://yaoyao-lc.samien.cn', '10.1.5.100', false, allowed)).toBe(true)
  51. expect(isExactOrigin('http://attacker.example', '10.1.5.100', false, allowed)).toBe(false)
  52. })
  53. it('adds configured reverse-proxy domains to the WebSocket CSP', () => {
  54. const headers: Record<string, string> = {}
  55. const ctx = { host: '10.1.5.100', set(name: string, value: string) { headers[name] = value } } as unknown as Koa.Context
  56. applySecurityHeaders(ctx, false, new Set(['yaoyao-lc.samien.cn']))
  57. expect(headers['Content-Security-Policy']).toContain('ws://yaoyao-lc.samien.cn')
  58. expect(headers['Content-Security-Policy']).toContain('wss://yaoyao-lc.samien.cn')
  59. })
  60. it('requires an exact scheme and host Origin', () => {
  61. expect(isExactOrigin('http://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)
  62. expect(isExactOrigin('https://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)
  63. expect(isExactOrigin('http://127.0.0.1:8801', '127.0.0.1:8800', false)).toBe(false)
  64. expect(isExactOrigin('null', '127.0.0.1:8800', false)).toBe(false)
  65. })
  66. it('uses a signed double-submit CSRF token', () => {
  67. const csrf = new CsrfProtection(randomBytes(32), false)
  68. const headers: Record<string, unknown> = {}
  69. let incomingCookie = ''
  70. const ctx = {
  71. response: { headers },
  72. set(name: string, value: unknown) { headers[name.toLowerCase()] = value },
  73. get(name: string) { return name.toLowerCase() === 'cookie' ? incomingCookie : '' },
  74. } as unknown as Koa.Context
  75. const token = csrf.issue(ctx)
  76. const setCookie = (headers['set-cookie'] as string[])[0]!
  77. const cookie = setCookie.split(';', 1)[0]!
  78. incomingCookie = cookie
  79. expect(csrf.verify(cookie, token)).toBe(true)
  80. expect(csrf.issue(ctx)).toBe(token)
  81. expect(csrf.issue(ctx, true)).not.toBe(token)
  82. expect(csrf.verify(cookie, `${token}x`)).toBe(false)
  83. expect(csrf.verify(cookie.replace(/.$/, 'x'), token)).toBe(false)
  84. })
  85. it('refuses production LAN HTTP unless explicitly enabled', () => {
  86. expect(() => loadServerConfig({
  87. NODE_ENV: 'production',
  88. HERMES_YAOYAO_HOST: '0.0.0.0',
  89. })).toThrow(/ALLOW_INSECURE_LAN/)
  90. expect(loadServerConfig({
  91. NODE_ENV: 'production',
  92. HERMES_YAOYAO_HOST: '0.0.0.0',
  93. HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
  94. }).insecureLan).toBe(true)
  95. })
  96. it('defaults the YaoYao Web listener to loopback', () => {
  97. const value = loadServerConfig({})
  98. expect(value.host).toBe('127.0.0.1')
  99. expect(value.insecureLan).toBe(false)
  100. })
  101. })