loopbackAuthorization.ts 2.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
  1. import { Agent as HTTPAgent, request as httpRequest } from 'node:http'
  2. import { Agent as HTTPSAgent, request as httpsRequest } from 'node:https'
  3. import { Readable } from 'node:stream'
  4. /** Literal loopback only: never treat forwarded headers, LAN addresses, Docker
  5. * hostnames or a hostname resolving to localhost as permission to acquire a token. */
  6. export function isLocalAuthorizationTarget(url: URL): boolean {
  7. return ['http:', 'https:'].includes(url.protocol)
  8. && ['127.0.0.1', '[::1]', '::1'].includes(url.hostname)
  9. && !url.username && !url.password
  10. }
  11. export function localSessionToken(html: string): string | undefined {
  12. const value = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(html)?.[1]
  13. if (!value) return undefined
  14. try {
  15. const token: unknown = JSON.parse(value)
  16. return typeof token === 'string' && /^[A-Za-z0-9_-]{20,4096}$/.test(token) ? token : undefined
  17. } catch { return undefined }
  18. }
  19. export function allowsLocalAuthorization(status: Record<string, unknown>): boolean {
  20. return (status.auth_required === false || status.authRequired === false)
  21. && status.auth_required !== true && status.authRequired !== true
  22. }
  23. /** Explicit agents do not inherit proxy environment settings. Loopback tokens
  24. * must not travel through an HTTP_PROXY configured for unrelated remote traffic. */
  25. export class LoopbackTransport {
  26. readonly httpAgent = new HTTPAgent({ keepAlive: true, proxyEnv: {} })
  27. readonly httpsAgent = new HTTPSAgent({ keepAlive: true, proxyEnv: {} })
  28. agent(url: URL): HTTPAgent | HTTPSAgent { return url.protocol === 'https:' || url.protocol === 'wss:' ? this.httpsAgent : this.httpAgent }
  29. async fetch(url: URL, init: RequestInit): Promise<Response> {
  30. if (!isLocalAuthorizationTarget(url)) throw new Error('Loopback transport requires a literal loopback URL')
  31. const source = new Request(url, init)
  32. const body = source.body ? Buffer.from(await source.arrayBuffer()) : undefined
  33. return new Promise((resolve, reject) => {
  34. const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
  35. method: source.method, headers: Object.fromEntries(source.headers.entries()),
  36. agent: this.agent(url), signal: source.signal,
  37. }, response => {
  38. const headers = new Headers()
  39. for (let i = 0; i < response.rawHeaders.length; i += 2) headers.append(response.rawHeaders[i]!, response.rawHeaders[i + 1]!)
  40. const empty = source.method === 'HEAD' || [204, 205, 304].includes(response.statusCode ?? 200)
  41. if (empty) response.resume()
  42. resolve(new Response(empty ? null : Readable.toWeb(response) as ReadableStream<Uint8Array>, {
  43. status: response.statusCode ?? 502, headers,
  44. }))
  45. })
  46. request.once('error', reject)
  47. request.end(body)
  48. })
  49. }
  50. close(): void { this.httpAgent.destroy(); this.httpsAgent.destroy() }
  51. }