upstream.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import type Koa from 'koa'
  2. import { parse } from 'cookie'
  3. import { HttpError } from './errors.js'
  4. import { appendSetCookies, CSRF_COOKIE } from './security.js'
  5. const RESPONSE_HEADER_ALLOWLIST = [
  6. 'accept-ranges',
  7. 'cache-control',
  8. 'content-disposition',
  9. 'content-range',
  10. 'content-type',
  11. 'etag',
  12. 'last-modified',
  13. ] as const
  14. function headerSetCookies(headers: Headers): string[] {
  15. if (typeof headers.getSetCookie === 'function') return headers.getSetCookie()
  16. const combined = headers.get('set-cookie')
  17. if (!combined) return []
  18. // This fallback is only for fetch shims without getSetCookie. It avoids
  19. // splitting the comma in an Expires attribute.
  20. return combined.split(/,(?=\s*[^;,\s]+=)/g).map((value) => value.trim())
  21. }
  22. function cookiePair(value: string): [string, string] | null {
  23. const pair = value.split(';', 1)[0]?.trim()
  24. const separator = pair?.indexOf('=') ?? -1
  25. if (!pair || separator <= 0) return null
  26. const name = pair.slice(0, separator).trim()
  27. const cookieValue = pair.slice(separator + 1).trim()
  28. if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) return null
  29. return [name, cookieValue]
  30. }
  31. function browserCookie(value: string, publicSecure: boolean): string | null {
  32. const pair = cookiePair(value)
  33. if (!pair || pair[0] === CSRF_COOKIE) return null
  34. const attributes = value.split(';').slice(1).map((part) => part.trim())
  35. const kept = attributes.filter((attribute) => {
  36. const name = attribute.split('=', 1)[0]?.toLowerCase()
  37. return name !== 'domain' && name !== 'path' && name !== 'secure'
  38. })
  39. return [`${pair[0]}=${pair[1]}`, 'Path=/', ...kept, ...(publicSecure ? ['Secure'] : [])].join('; ')
  40. }
  41. export class CookieJar {
  42. readonly #values = new Map<string, string>()
  43. readonly #browserCookies = new Map<string, string>()
  44. constructor(cookieHeader?: string) {
  45. for (const [name, value] of Object.entries(parse(cookieHeader ?? ''))) {
  46. if (name !== CSRF_COOKIE && value !== undefined) this.#values.set(name, value)
  47. }
  48. }
  49. absorb(headers: Headers, publicSecure = false): void {
  50. for (const raw of headerSetCookies(headers)) {
  51. const pair = cookiePair(raw)
  52. const sanitized = browserCookie(raw, publicSecure)
  53. if (!pair || !sanitized) continue
  54. const [name, value] = pair
  55. if (!value || /(?:^|;)\s*max-age=0(?:;|$)/i.test(raw)) this.#values.delete(name)
  56. else this.#values.set(name, value)
  57. this.#browserCookies.set(name, sanitized)
  58. }
  59. }
  60. get header(): string | undefined {
  61. if (this.#values.size === 0) return undefined
  62. return [...this.#values.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
  63. }
  64. get browserCookies(): string[] {
  65. return [...this.#browserCookies.values()]
  66. }
  67. }
  68. export interface UpstreamResponse {
  69. status: number
  70. headers: Headers
  71. body: Buffer
  72. }
  73. export class UpstreamHttpError extends HttpError {
  74. constructor(readonly response: UpstreamResponse) {
  75. super(response.status, `Hermes returned HTTP ${response.status}`, 'upstream_http_error')
  76. }
  77. }
  78. export interface UpstreamRequestOptions {
  79. method?: string
  80. search?: URLSearchParams
  81. body?: unknown
  82. rawBody?: BodyInit
  83. headers?: Record<string, string>
  84. maxResponseBytes?: number
  85. clientAddress?: string
  86. }
  87. export class UpstreamClient {
  88. constructor(
  89. readonly baseURL: URL,
  90. readonly fetchImpl: typeof fetch = fetch,
  91. readonly publicSecure = false,
  92. ) {}
  93. async request(
  94. path: string,
  95. jar: CookieJar,
  96. options: UpstreamRequestOptions = {},
  97. ): Promise<UpstreamResponse> {
  98. if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
  99. throw new HttpError(500, 'Invalid internal upstream path')
  100. }
  101. const url = new URL(this.baseURL)
  102. const prefix = this.baseURL.pathname === '/' ? '' : this.baseURL.pathname.replace(/\/$/, '')
  103. const expectedPath = `${prefix}${path}`
  104. url.pathname = expectedPath
  105. if (url.pathname !== expectedPath) {
  106. throw new HttpError(500, 'Upstream path normalization escaped its allowlisted route')
  107. }
  108. url.search = options.search?.toString() ?? ''
  109. url.hash = ''
  110. const headers = new Headers({
  111. accept: 'application/json',
  112. origin: this.baseURL.origin,
  113. 'x-forwarded-host': this.baseURL.host,
  114. 'x-forwarded-proto': this.publicSecure ? 'https' : 'http',
  115. ...options.headers,
  116. })
  117. if (options.clientAddress) {
  118. const clientAddress = options.clientAddress.replace(/^::ffff:/, '')
  119. if (/^[0-9a-f:.]+$/i.test(clientAddress)) headers.set('x-forwarded-for', clientAddress)
  120. }
  121. if (jar.header) headers.set('cookie', jar.header)
  122. let body: BodyInit | undefined
  123. if (options.rawBody !== undefined) {
  124. body = options.rawBody
  125. } else if (options.body !== undefined) {
  126. headers.set('content-type', 'application/json')
  127. body = JSON.stringify(options.body)
  128. }
  129. let response: Response
  130. let timeout: ReturnType<typeof setTimeout> | undefined
  131. try {
  132. response = await Promise.race([
  133. this.fetchImpl(url, {
  134. method: options.method ?? 'GET',
  135. headers,
  136. body,
  137. redirect: 'manual',
  138. }),
  139. new Promise<never>((_resolve, reject) => {
  140. timeout = setTimeout(() => reject(new Error('request timed out')), 30_000)
  141. timeout.unref()
  142. }),
  143. ])
  144. } catch (error) {
  145. const message = error instanceof Error ? error.message : 'connection failed'
  146. throw new HttpError(502, `Unable to reach Hermes: ${message}`, 'upstream_unavailable')
  147. } finally {
  148. if (timeout) clearTimeout(timeout)
  149. }
  150. jar.absorb(response.headers, this.publicSecure)
  151. const declaredLength = Number(response.headers.get('content-length') ?? '0')
  152. const limit = options.maxResponseBytes ?? 64 * 1_024 * 1_024
  153. if (Number.isFinite(declaredLength) && declaredLength > limit) {
  154. await response.body?.cancel()
  155. throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
  156. }
  157. const bodyBuffer = Buffer.from(await response.arrayBuffer())
  158. if (bodyBuffer.byteLength > limit) {
  159. throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
  160. }
  161. return { status: response.status, headers: response.headers, body: bodyBuffer }
  162. }
  163. async json<T>(
  164. path: string,
  165. jar: CookieJar,
  166. options: UpstreamRequestOptions = {},
  167. ): Promise<T> {
  168. const response = await this.request(path, jar, options)
  169. if (response.status < 200 || response.status >= 300) throw new UpstreamHttpError(response)
  170. try {
  171. return JSON.parse(response.body.toString('utf8')) as T
  172. } catch {
  173. throw new HttpError(502, 'Hermes returned invalid JSON', 'invalid_upstream_json')
  174. }
  175. }
  176. }
  177. export function applyUpstreamCookies(ctx: Koa.Context, jar: CookieJar): void {
  178. appendSetCookies(ctx, jar.browserCookies)
  179. }
  180. export function sendUpstreamResponse(
  181. ctx: Koa.Context,
  182. response: UpstreamResponse,
  183. _jar: CookieJar,
  184. ): void {
  185. ctx.status = response.status
  186. for (const name of RESPONSE_HEADER_ALLOWLIST) {
  187. const value = response.headers.get(name)
  188. if (value) ctx.set(name, value)
  189. }
  190. ctx.body = response.status === 204 || ctx.method === 'HEAD' ? null : response.body
  191. }
  192. export function decodeUpstreamError(response: UpstreamResponse): { error: string; code: string } {
  193. try {
  194. const value = JSON.parse(response.body.toString('utf8')) as Record<string, unknown>
  195. const message = typeof value.error === 'string'
  196. ? value.error
  197. : typeof value.message === 'string' ? value.message : `Hermes returned HTTP ${response.status}`
  198. return { error: message, code: 'upstream_rejected' }
  199. } catch {
  200. return { error: `Hermes returned HTTP ${response.status}`, code: 'upstream_rejected' }
  201. }
  202. }