security.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
  2. import type { IncomingMessage } from 'node:http'
  3. import type Koa from 'koa'
  4. import { parse, serialize } from 'cookie'
  5. import type { ServerConfig } from './config.js'
  6. import { isLoopbackHost, isPrivateHost } from './config.js'
  7. export const CSRF_COOKIE = 'hermes_yaoyao_csrf'
  8. function splitHostPort(value: string): { hostname: string; port?: string } | null {
  9. const trimmed = value.trim().toLowerCase()
  10. if (!trimmed || /[\s/@\\]/.test(trimmed)) return null
  11. if (trimmed.startsWith('[')) {
  12. const end = trimmed.indexOf(']')
  13. if (end < 0) return null
  14. const hostname = trimmed.slice(1, end)
  15. const suffix = trimmed.slice(end + 1)
  16. if (suffix && !/^:\d{1,5}$/.test(suffix)) return null
  17. return { hostname, port: suffix ? suffix.slice(1) : undefined }
  18. }
  19. const colonCount = (trimmed.match(/:/g) ?? []).length
  20. if (colonCount > 1) return { hostname: trimmed }
  21. const [hostname, port] = trimmed.split(':')
  22. if (!hostname || (port && !/^\d{1,5}$/.test(port))) return null
  23. return { hostname: hostname.replace(/\.$/, ''), port }
  24. }
  25. export function isAllowedHostHeader(value: string | undefined, config: ServerConfig): boolean {
  26. if (!value) return false
  27. const parsed = splitHostPort(value)
  28. if (!parsed) return false
  29. if (config.allowedHosts.has(parsed.hostname)) return true
  30. if (!isPrivateHost(parsed.hostname)) return false
  31. return parsed.port === String(config.port) || parsed.port === undefined || parsed.port === '80' || parsed.port === '443'
  32. }
  33. function canonicalOrigin(origin: string): string | null {
  34. try {
  35. const url = new URL(origin)
  36. if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null
  37. if (url.pathname !== '/' || url.search || url.hash) return null
  38. return `${url.protocol}//${url.host.toLowerCase()}`
  39. } catch {
  40. return null
  41. }
  42. }
  43. export function expectedRequestOrigin(host: string, secure: boolean): string | null {
  44. if (!splitHostPort(host)) return null
  45. return `${secure ? 'https' : 'http'}://${host.toLowerCase()}`
  46. }
  47. export function isExactOrigin(
  48. origin: string | undefined,
  49. host: string | undefined,
  50. secure: boolean,
  51. allowedHosts: ReadonlySet<string> = new Set(),
  52. ): boolean {
  53. if (!origin || !host) return false
  54. const expected = expectedRequestOrigin(host, secure)
  55. const actual = canonicalOrigin(origin)
  56. if (expected === null || actual === null) return false
  57. if (actual === expected) return true
  58. // A TLS-terminating reverse proxy forwards HTTP internally while the
  59. // browser's same-origin request correctly carries an https Origin.
  60. if (!secure && actual === expectedRequestOrigin(host, true)) return true
  61. try {
  62. return allowedHosts.has(new URL(actual).hostname.toLowerCase().replace(/\.$/, ''))
  63. } catch {
  64. return false
  65. }
  66. }
  67. export function acceptedRequestOrigin(
  68. origin: string | undefined,
  69. host: string | undefined,
  70. secure: boolean,
  71. allowedHosts: ReadonlySet<string>,
  72. ): string | null {
  73. if (!isExactOrigin(origin, host, secure, allowedHosts)) return null
  74. return canonicalOrigin(origin!)
  75. }
  76. export function appendSetCookies(ctx: Koa.Context, values: readonly string[]): void {
  77. if (values.length === 0) return
  78. const existing = ctx.response.headers['set-cookie']
  79. const current = Array.isArray(existing) ? existing : existing ? [String(existing)] : []
  80. ctx.set('Set-Cookie', [...current, ...values])
  81. }
  82. export class CsrfProtection {
  83. readonly #secret: Buffer
  84. readonly #secure: boolean
  85. constructor(secret: Buffer = randomBytes(32), secure = false) {
  86. this.#secret = secret
  87. this.#secure = secure
  88. }
  89. issue(ctx: Koa.Context, rotate = false): string {
  90. const currentCookie = typeof ctx.get === 'function' ? ctx.get('cookie') : undefined
  91. if (!rotate) {
  92. const current = this.#read(currentCookie)
  93. if (current) return current
  94. }
  95. const token = randomBytes(24).toString('base64url')
  96. const signed = `${token}.${this.#sign(token)}`
  97. appendSetCookies(ctx, [serialize(CSRF_COOKIE, signed, {
  98. httpOnly: true,
  99. secure: this.#secure,
  100. sameSite: 'strict',
  101. path: '/',
  102. maxAge: 60 * 60 * 8,
  103. })])
  104. return token
  105. }
  106. verify(cookieHeader: string | undefined, headerToken: string | undefined): boolean {
  107. if (!headerToken || headerToken.length > 256) return false
  108. const token = this.#read(cookieHeader)
  109. if (!token || token !== headerToken) return false
  110. return true
  111. }
  112. #read(cookieHeader: string | undefined): string | undefined {
  113. if (!cookieHeader) return undefined
  114. const signed = parse(cookieHeader)[CSRF_COOKIE]
  115. if (!signed) return undefined
  116. const separator = signed.lastIndexOf('.')
  117. if (separator <= 0) return undefined
  118. const token = signed.slice(0, separator)
  119. const signature = signed.slice(separator + 1)
  120. const expected = this.#sign(token)
  121. const actualBuffer = Buffer.from(signature)
  122. const expectedBuffer = Buffer.from(expected)
  123. if (actualBuffer.length !== expectedBuffer.length
  124. || !timingSafeEqual(actualBuffer, expectedBuffer)) return undefined
  125. return token
  126. }
  127. clear(ctx: Koa.Context): void {
  128. appendSetCookies(ctx, [serialize(CSRF_COOKIE, '', {
  129. httpOnly: true,
  130. secure: this.#secure,
  131. sameSite: 'strict',
  132. path: '/',
  133. maxAge: 0,
  134. })])
  135. }
  136. #sign(token: string): string {
  137. return createHmac('sha256', this.#secret).update(token).digest('base64url')
  138. }
  139. }
  140. export function accountKeyFromCookieHeader(cookieHeader: string | undefined, fallback = ''): string {
  141. const cookies = parse(cookieHeader ?? '')
  142. delete cookies[CSRF_COOKIE]
  143. const stable = Object.entries(cookies)
  144. .sort(([left], [right]) => left.localeCompare(right))
  145. .map(([name, value]) => `${name}=${value}`)
  146. .join(';')
  147. return createHmac('sha256', 'hermes-yaoyao-account-scope')
  148. .update(stable || `anonymous:${fallback}`)
  149. .digest('base64url')
  150. }
  151. export function requestAccountKey(request: IncomingMessage): string {
  152. return accountKeyFromCookieHeader(request.headers.cookie, request.socket.remoteAddress)
  153. }
  154. export function applySecurityHeaders(ctx: Koa.Context, tls: boolean, allowedHosts: ReadonlySet<string> = new Set()): void {
  155. const socketOrigin = `${tls ? 'wss' : 'ws'}://${ctx.host}`
  156. const proxySocketOrigins = [...allowedHosts].flatMap(host => [`ws://${host}`, `wss://${host}`])
  157. ctx.set('Content-Security-Policy', [
  158. "default-src 'self'",
  159. "base-uri 'none'",
  160. "frame-ancestors 'none'",
  161. "form-action 'self'",
  162. "object-src 'none'",
  163. "script-src 'self'",
  164. "style-src 'self' 'unsafe-inline'",
  165. "img-src 'self' data: blob:",
  166. "media-src 'self' blob:",
  167. `connect-src 'self' ${socketOrigin} ${proxySocketOrigins.join(' ')}`.trim(),
  168. ].join('; '))
  169. const requestHostname = splitHostPort(ctx.host)?.hostname ?? ''
  170. if (tls || isLoopbackHost(requestHostname)) {
  171. ctx.set('Cross-Origin-Opener-Policy', 'same-origin')
  172. }
  173. ctx.set('Referrer-Policy', 'no-referrer')
  174. ctx.set('X-Content-Type-Options', 'nosniff')
  175. ctx.set('X-Frame-Options', 'DENY')
  176. ctx.set('Permissions-Policy', 'camera=(), geolocation=(), microphone=()')
  177. if (tls) ctx.set('Strict-Transport-Security', 'max-age=31536000')
  178. }