1
0

body.ts 2.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. /** Bounded raw HTTP body intake for GitHub signature verification. */
  2. import type { IncomingMessage } from 'node:http'
  3. /** HTTP refusal whose message is safe to return without request data. */
  4. export class WebhookHttpError extends Error {
  5. override readonly name = 'WebhookHttpError'
  6. constructor(
  7. readonly status: 400 | 401 | 405 | 413 | 415 | 503,
  8. message: string,
  9. ) {
  10. super(message)
  11. }
  12. }
  13. /** Parse a decimal Content-Length or reject an ambiguous header. */
  14. function contentLength(request: IncomingMessage): number | undefined {
  15. const value = request.headers['content-length']
  16. if (value === undefined) return undefined
  17. if (!/^(0|[1-9]\d*)$/.test(value)) {
  18. throw new WebhookHttpError(400, 'invalid Content-Length')
  19. }
  20. const length = Number(value)
  21. if (!Number.isSafeInteger(length)) throw new WebhookHttpError(413, 'request body is too large')
  22. return length
  23. }
  24. /**
  25. * Read one request body as exact, bounded UTF-8 text.
  26. * @param request - incoming request before any parser consumes it.
  27. * @param maxBodyBytes - positive byte ceiling.
  28. * @returns the decoded body after EOF.
  29. * @throws {WebhookHttpError} for invalid length, excessive bytes, invalid UTF-8, or an aborted stream.
  30. */
  31. export async function readBoundedUtf8Body(
  32. request: IncomingMessage,
  33. maxBodyBytes: number,
  34. ): Promise<string> {
  35. const declared = contentLength(request)
  36. if (declared !== undefined && declared > maxBodyBytes) {
  37. request.resume()
  38. throw new WebhookHttpError(413, 'request body is too large')
  39. }
  40. const chunks: Buffer[] = []
  41. let size = 0
  42. try {
  43. for await (const raw of request) {
  44. const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as string)
  45. size += chunk.byteLength
  46. if (size > maxBodyBytes) {
  47. request.resume()
  48. throw new WebhookHttpError(413, 'request body is too large')
  49. }
  50. chunks.push(chunk)
  51. }
  52. } catch (error: unknown) {
  53. if (error instanceof WebhookHttpError) throw error
  54. throw new WebhookHttpError(400, 'request body was aborted')
  55. }
  56. if (!request.complete) throw new WebhookHttpError(400, 'request body was aborted')
  57. try {
  58. return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size))
  59. } catch {
  60. // TextDecoder is the only statement in the try; GitHub JSON must be valid UTF-8.
  61. throw new WebhookHttpError(400, 'request body is not valid UTF-8')
  62. }
  63. }