security.test.ts 3.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  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. } from '../../src/server/security.js'
  10. function config(overrides: Partial<ServerConfig> = {}): ServerConfig {
  11. return {
  12. host: '127.0.0.1',
  13. port: 8800,
  14. upstream: new URL('http://127.0.0.1:9119'),
  15. allowedHosts: new Set(),
  16. home: '/tmp/hermes-yaoyao-test',
  17. allowInsecureLan: false,
  18. insecureLan: false,
  19. production: false,
  20. ...overrides,
  21. }
  22. }
  23. describe('server security boundary', () => {
  24. it('accepts loopback/private hosts and configured DNS only', () => {
  25. const value = config({ allowedHosts: new Set(['yaoyao.internal']) })
  26. expect(isAllowedHostHeader('127.0.0.1:8800', value)).toBe(true)
  27. expect(isAllowedHostHeader('[::1]:8800', value)).toBe(true)
  28. expect(isAllowedHostHeader('192.168.10.8:8800', value)).toBe(true)
  29. expect(isAllowedHostHeader('yaoyao.internal:8800', value)).toBe(true)
  30. expect(isAllowedHostHeader('127.0.0.1:9999', value)).toBe(false)
  31. expect(isAllowedHostHeader('127.0.0.1', value)).toBe(false)
  32. expect(isAllowedHostHeader('attacker.example:8800', value)).toBe(false)
  33. expect(isAllowedHostHeader('127.0.0.1@attacker.example', value)).toBe(false)
  34. })
  35. it('requires an exact scheme and host Origin', () => {
  36. expect(isExactOrigin('http://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)
  37. expect(isExactOrigin('https://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(false)
  38. expect(isExactOrigin('http://127.0.0.1:8801', '127.0.0.1:8800', false)).toBe(false)
  39. expect(isExactOrigin('null', '127.0.0.1:8800', false)).toBe(false)
  40. })
  41. it('uses a signed double-submit CSRF token', () => {
  42. const csrf = new CsrfProtection(randomBytes(32), false)
  43. const headers: Record<string, unknown> = {}
  44. let incomingCookie = ''
  45. const ctx = {
  46. response: { headers },
  47. set(name: string, value: unknown) { headers[name.toLowerCase()] = value },
  48. get(name: string) { return name.toLowerCase() === 'cookie' ? incomingCookie : '' },
  49. } as unknown as Koa.Context
  50. const token = csrf.issue(ctx)
  51. const setCookie = (headers['set-cookie'] as string[])[0]!
  52. const cookie = setCookie.split(';', 1)[0]!
  53. incomingCookie = cookie
  54. expect(csrf.verify(cookie, token)).toBe(true)
  55. expect(csrf.issue(ctx)).toBe(token)
  56. expect(csrf.issue(ctx, true)).not.toBe(token)
  57. expect(csrf.verify(cookie, `${token}x`)).toBe(false)
  58. expect(csrf.verify(cookie.replace(/.$/, 'x'), token)).toBe(false)
  59. })
  60. it('refuses production LAN HTTP unless explicitly enabled', () => {
  61. expect(() => loadServerConfig({
  62. NODE_ENV: 'production',
  63. HERMES_YAOYAO_HOST: '0.0.0.0',
  64. })).toThrow(/ALLOW_INSECURE_LAN/)
  65. expect(loadServerConfig({
  66. NODE_ENV: 'production',
  67. HERMES_YAOYAO_HOST: '0.0.0.0',
  68. HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
  69. }).insecureLan).toBe(true)
  70. })
  71. })