|
| 1 | +/** |
| 2 | + * Hard size ceilings for everything the session broker parses or stores from the network. |
| 3 | + * |
| 4 | + * The broker is loopback-only by default, but a hostile or buggy local process (and any remote |
| 5 | + * peer when HUNK_MCP_UNSAFE_ALLOW_REMOTE=1) can otherwise stream unbounded HTTP bodies or |
| 6 | + * websocket frames, or register a changeset with an unbounded number of files, hunks, comments, |
| 7 | + * or patch bytes. These caps keep memory bounded while staying far above any realistic review. |
| 8 | + */ |
| 9 | + |
| 10 | +/** Maximum decoded byte length accepted for one HTTP API request body. */ |
| 11 | +export const MAX_HTTP_BODY_BYTES = 4 * 1024 * 1024; |
| 12 | + |
| 13 | +/** Maximum byte length accepted for one inbound websocket message. */ |
| 14 | +export const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024; |
| 15 | + |
| 16 | +/** Maximum number of files accepted in one session registration payload. */ |
| 17 | +export const MAX_REGISTRATION_FILES = 5_000; |
| 18 | + |
| 19 | +/** Maximum number of hunks accepted per registered file. */ |
| 20 | +export const MAX_REGISTRATION_HUNKS_PER_FILE = 10_000; |
| 21 | + |
| 22 | +/** Maximum byte length accepted for one registered file's patch text. */ |
| 23 | +export const MAX_REGISTRATION_PATCH_BYTES = 2 * 1024 * 1024; |
| 24 | + |
| 25 | +/** Maximum number of live comments accepted in one session snapshot. */ |
| 26 | +export const MAX_SNAPSHOT_LIVE_COMMENTS = 10_000; |
| 27 | + |
| 28 | +/** Maximum number of review notes accepted in one session snapshot. */ |
| 29 | +export const MAX_SNAPSHOT_REVIEW_NOTES = 10_000; |
| 30 | + |
| 31 | +/** Raised when an inbound payload exceeds its configured byte budget. */ |
| 32 | +export class PayloadTooLargeError extends Error { |
| 33 | + constructor(readonly limitBytes: number) { |
| 34 | + super(`Payload exceeds the ${limitBytes}-byte session broker limit.`); |
| 35 | + this.name = "PayloadTooLargeError"; |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +// Reused across every websocket message, HTTP body, and patch check to avoid a per-call alloc. |
| 40 | +const sharedTextEncoder = new TextEncoder(); |
| 41 | + |
| 42 | +/** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */ |
| 43 | +export function utf8ByteLength(value: string): number { |
| 44 | + return sharedTextEncoder.encode(value).length; |
| 45 | +} |
| 46 | + |
| 47 | +/** |
| 48 | + * Read one request body as text while enforcing a hard byte ceiling. |
| 49 | + * |
| 50 | + * The Content-Length header is rejected early when it already declares an oversized body, and the |
| 51 | + * stream is aborted mid-read so a missing or lying Content-Length cannot force the daemon to |
| 52 | + * buffer an unbounded body before the cap is noticed. |
| 53 | + */ |
| 54 | +export async function readRequestTextWithLimit( |
| 55 | + request: Request, |
| 56 | + maxBytes: number, |
| 57 | +): Promise<string> { |
| 58 | + const declared = request.headers.get("content-length"); |
| 59 | + if (declared) { |
| 60 | + const length = Number.parseInt(declared, 10); |
| 61 | + if (Number.isInteger(length) && length > maxBytes) { |
| 62 | + throw new PayloadTooLargeError(maxBytes); |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + const body = request.body; |
| 67 | + if (!body) { |
| 68 | + // Some runtimes do not expose a streaming body; the Content-Length guard above still bounds |
| 69 | + // well-behaved clients, and the post-read check bounds the rest. |
| 70 | + const text = await request.text(); |
| 71 | + if (utf8ByteLength(text) > maxBytes) { |
| 72 | + throw new PayloadTooLargeError(maxBytes); |
| 73 | + } |
| 74 | + |
| 75 | + return text; |
| 76 | + } |
| 77 | + |
| 78 | + const reader = body.getReader(); |
| 79 | + const chunks: Uint8Array[] = []; |
| 80 | + let total = 0; |
| 81 | + for (;;) { |
| 82 | + let done: boolean; |
| 83 | + let value: Uint8Array | undefined; |
| 84 | + try { |
| 85 | + const result = await reader.read(); |
| 86 | + done = result.done; |
| 87 | + value = result.value; |
| 88 | + } catch (error) { |
| 89 | + reader.releaseLock(); |
| 90 | + throw error; |
| 91 | + } |
| 92 | + |
| 93 | + if (done) { |
| 94 | + break; |
| 95 | + } |
| 96 | + |
| 97 | + if (!value) { |
| 98 | + continue; |
| 99 | + } |
| 100 | + |
| 101 | + total += value.byteLength; |
| 102 | + if (total > maxBytes) { |
| 103 | + // Stop pulling from the stream immediately so the body cannot grow past the cap. |
| 104 | + await reader.cancel().catch(() => {}); |
| 105 | + // cancel() does not release the lock per the Streams spec; release it explicitly so the |
| 106 | + // over-limit path matches the normal-exit path instead of waiting for GC. |
| 107 | + reader.releaseLock(); |
| 108 | + throw new PayloadTooLargeError(maxBytes); |
| 109 | + } |
| 110 | + |
| 111 | + chunks.push(value); |
| 112 | + } |
| 113 | + |
| 114 | + reader.releaseLock(); |
| 115 | + |
| 116 | + const merged = new Uint8Array(total); |
| 117 | + let offset = 0; |
| 118 | + for (const chunk of chunks) { |
| 119 | + merged.set(chunk, offset); |
| 120 | + offset += chunk.byteLength; |
| 121 | + } |
| 122 | + |
| 123 | + return new TextDecoder().decode(merged); |
| 124 | +} |
0 commit comments