diff --git a/src/lib/proxy-pass.middleware.ts b/src/lib/proxy-pass.middleware.ts index 4d00ff1..4ce36da 100644 --- a/src/lib/proxy-pass.middleware.ts +++ b/src/lib/proxy-pass.middleware.ts @@ -5,6 +5,7 @@ import { Express } from 'express'; import { createLogger } from './logger.js'; import { colors } from './helpers/color.helper.js'; import { ServerResponse, IncomingMessage } from 'http'; +import { StringDecoder } from 'node:string_decoder'; import { tokenLoginFunction } from './helpers/login.helper'; import { MiAPI } from './pp.middleware'; import type { NextHandleFunction } from 'connect'; @@ -30,14 +31,130 @@ const hostOriginRegExp = /^(https?:\/\/)([^/]+)(\/.*)?$/i; export const PROXY_HEADER = 'X-PP-Proxy'; -// TODO: Implement interceptor for streaming responses -function streamResponseInterceptor(interceptor?: (data: Buffer, encoding: BufferEncoding) => Buffer) { +/** Media types whose streamed payload is safe to run the text interceptor over. */ +const TEXTUAL_MEDIA_TYPE_REGEXPS = [ + /^text\//, + /^application\/(?:[\w.-]+\+)?(?:json|xml)$/, + /^application\/(?:x-)?(?:java|ecma)script$/, +]; + +function isTextualContentType(contentType: string): boolean { + const mediaType = contentType.split(';', 1)[0].trim().toLowerCase(); + + return TEXTUAL_MEDIA_TYPE_REGEXPS.some((pattern) => pattern.test(mediaType)); +} + +/** + * Longest chunk we hold back while waiting for a line break. A stream that never emits one + * (or emits very long lines) is flushed once it reaches this size so the client keeps + * receiving data and memory stays bounded. + */ +const MAX_PENDING_STREAM_CHUNK = 64 * 1024; + +/** + * Streamed bodies are only rewritten when they are plain text we can decode: a + * `content-encoding` means the bytes are compressed, and a non-textual `content-type` (the + * `x-accel-buffering: no` path also carries binary downloads) must reach the client untouched. + */ +function isRewritableStream(headers: IncomingMessage['headers']): boolean { + const contentEncoding = headers['content-encoding']; + + if (typeof contentEncoding === 'string' && contentEncoding.trim() && contentEncoding.trim() !== 'identity') { + return false; + } + + const contentType = headers['content-type']; + + return typeof contentType === 'string' && isTextualContentType(contentType); +} + +/** + * Streams a proxied response to the client, applying {@link interceptor} to the decoded text + * as it flows. Complete lines are forwarded immediately (an SSE event always ends with a line + * break, so nothing is delayed) while a trailing partial line is held back, which keeps a + * replaced value from being split across two chunks and missed. + */ +export function streamResponseInterceptor(interceptor?: (data: Buffer, encoding: BufferEncoding) => Buffer) { return async (proxyRes: T, req: T, res: ServerResponse) => { + const rewrite = interceptor && isRewritableStream(proxyRes.headers); + + res.statusCode = proxyRes.statusCode ?? res.statusCode; + + if (proxyRes.statusMessage) { + res.statusMessage = proxyRes.statusMessage; + } + res.setHeader(PROXY_HEADER, 1); - res.setHeaders(new Map(Object.entries(proxyRes.headers)) as any); + for (const [name, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) { + continue; + } + + // The rewritten payload no longer matches the upstream length. + if (rewrite && name.toLowerCase() === 'content-length') { + continue; + } + + res.setHeader(name, value); + } + + if (!rewrite) { + proxyRes.pipe(res); + + return; + } + + // Decodes incrementally so a multi-byte character split across chunks stays intact. + const decoder = new StringDecoder('utf8'); + let pending = ''; + + let waitingForDrain = false; + + // piping handled backpressure for us; writing by hand means honouring it here. + const flush = (text: string) => { + if (!text) { + return; + } + + const flushed = res.write(interceptor(Buffer.from(text, 'utf8'), 'utf8')); + + if (!flushed && !waitingForDrain) { + waitingForDrain = true; + proxyRes.pause(); + + res.once('drain', () => { + waitingForDrain = false; + proxyRes.resume(); + }); + } + }; + + proxyRes.on('data', (chunk: Buffer) => { + pending += decoder.write(chunk); + + const lastBreak = pending.lastIndexOf('\n'); + + if (lastBreak !== -1) { + flush(pending.slice(0, lastBreak + 1)); + pending = pending.slice(lastBreak + 1); + } + + if (pending.length >= MAX_PENDING_STREAM_CHUNK) { + flush(pending); + pending = ''; + } + }); + + proxyRes.on('end', () => { + flush(pending + decoder.end()); + pending = ''; + res.end(); + }); - proxyRes.pipe(res); + proxyRes.on('error', () => { + res.end(); + }); }; } diff --git a/tests/integration/middleware/proxy-pass.stream.spec.ts b/tests/integration/middleware/proxy-pass.stream.spec.ts new file mode 100644 index 0000000..c4b6452 --- /dev/null +++ b/tests/integration/middleware/proxy-pass.stream.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createServer, request, type Server } from 'node:http'; +import initProxy from '../../../src/lib/proxy-pass.middleware.js'; +import type { MiAPI } from '../../../src/lib/pp.middleware.js'; + +/** + * End-to-end check that a proxied streaming response gets the same treatment as every other + * proxied response: the upstream host is rewritten to the host the browser asked for, and the + * upstream status code reaches the client. Both were lost for `text/event-stream` responses, + * which were piped through untouched with a hard-coded 200. + */ +describe('proxy-pass streaming responses', () => { + let upstream: Server; + let local: Server; + let upstreamHost: string; + let localPort: number; + + const miAPI = { + personalAccessToken: undefined, + v7Features: false, + internalPageName: undefined, + } as unknown as MiAPI; + + const get = (path: string) => + new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = request({ host: '127.0.0.1', port: localPort, path, method: 'GET' }, (res) => { + const chunks: Buffer[] = []; + + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + }); + + req.on('error', reject); + req.end(); + }); + + beforeEach(async () => { + upstream = createServer((req, res) => { + if (req.url?.startsWith('/stream')) { + res.writeHead(503, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + res.write(`data: {"next":"http://${upstreamHost}/data/page/next"}\n\n`); + res.end(); + + return; + } + + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(`go`); + }); + + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', () => resolve())); + + const upstreamAddress = upstream.address(); + + upstreamHost = `127.0.0.1:${typeof upstreamAddress === 'object' && upstreamAddress ? upstreamAddress.port : 0}`; + + const proxy = initProxy({ baseURL: `http://${upstreamHost}`, devServer: {} as never, miAPI }); + + local = createServer((req, res) => { + proxy(req as never, res as never, () => { + res.statusCode = 404; + res.end('not proxied'); + }); + }); + + await new Promise((resolve) => local.listen(0, '127.0.0.1', () => resolve())); + + const localAddress = local.address(); + + localPort = typeof localAddress === 'object' && localAddress ? localAddress.port : 0; + }); + + afterEach(async () => { + await new Promise((resolve) => local.close(() => resolve())); + await new Promise((resolve) => upstream.close(() => resolve())); + }); + + it('rewrites the upstream host in a streamed body', async () => { + const streamed = await get('/stream'); + + expect(streamed.body).toContain(`http://127.0.0.1:${localPort}/data/page/next`); + expect(streamed.body).not.toContain(upstreamHost); + }); + + it('forwards the upstream status code of a streamed response', async () => { + const streamed = await get('/stream'); + + expect(streamed.status).toBe(503); + }); + + it('still rewrites the upstream host in a non-streamed body', async () => { + const html = await get('/page'); + + expect(html.status).toBe(200); + expect(html.body).toContain(`http://127.0.0.1:${localPort}/data/page/next`); + }); +}); diff --git a/tests/unit/lib/proxy-pass.stream.spec.ts b/tests/unit/lib/proxy-pass.stream.spec.ts new file mode 100644 index 0000000..f6a8ab3 --- /dev/null +++ b/tests/unit/lib/proxy-pass.stream.spec.ts @@ -0,0 +1,282 @@ +import { describe, it, expect } from 'vitest'; +import { PassThrough } from 'node:stream'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { streamResponseInterceptor, PROXY_HEADER } from '../../../src/lib/proxy-pass.middleware.js'; + +/** + * Regression tests for the "streamed responses are never rewritten" bug. + * + * `streamResponseInterceptor()` accepted an interceptor and then piped the upstream response + * straight to the client without ever calling it, so the host rewriting applied to every + * non-streaming response was silently skipped for `text/event-stream` (and for chunked + * responses sent with `x-accel-buffering: no`). The upstream status code was dropped the same + * way: a streamed 503 reached the browser as 200. + * + * Rewriting must still stay off compressed and non-textual payloads, whose bytes cannot be + * decoded as text, and must survive a replaced value being split across two chunks. + */ + +type ProxyRes = PassThrough & { headers: IncomingMessage['headers']; statusCode?: number; statusMessage?: string }; + +function makeProxyRes(headers: IncomingMessage['headers'], statusCode = 200, statusMessage?: string): ProxyRes { + const proxyRes = new PassThrough() as ProxyRes; + + proxyRes.headers = headers; + proxyRes.statusCode = statusCode; + proxyRes.statusMessage = statusMessage; + + return proxyRes; +} + +function makeRes({ writeReturns = true }: { writeReturns?: boolean } = {}) { + const chunks: Buffer[] = []; + const headers: Record = {}; + const drainListeners: Array<() => void> = []; + + const res = { + statusCode: 200, + statusMessage: '', + headersSent: false, + setHeader(name: string, value: unknown) { + headers[name.toLowerCase()] = value; + }, + getHeader(name: string) { + return headers[name.toLowerCase()]; + }, + write(chunk: Buffer | string) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + + return writeReturns; + }, + /** Fires the `drain` a real ServerResponse emits once its write queue empties. */ + drain() { + drainListeners.splice(0).forEach((listener) => listener()); + }, + end(chunk?: Buffer | string) { + if (chunk) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + res.finished = true; + }, + on() { + return res; + }, + once(event: string, listener: () => void) { + if (event === 'drain') { + drainListeners.push(listener); + } + + return res; + }, + emit() { + return false; + }, + finished: false, + get body() { + return Buffer.concat(chunks).toString('utf8'); + }, + headers, + }; + + return res; +} + +const upstreamHost = 'mi.company.com'; +const localHost = 'localhost:3000'; + +/** Same shape the proxy builds: replace the upstream host with the host the browser asked for. */ +const hostRewriter = (data: Buffer, encoding: BufferEncoding) => + Buffer.from(data.toString(encoding).split(upstreamHost).join(localHost), encoding); + +async function runStream( + proxyRes: ProxyRes, + res: ReturnType, + write: (stream: ProxyRes) => void, + interceptor = hostRewriter, +) { + await streamResponseInterceptor(interceptor)( + proxyRes as unknown as IncomingMessage, + {} as IncomingMessage, + res as unknown as ServerResponse, + ); + + write(proxyRes); + + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('streamResponseInterceptor', () => { + it('applies the interceptor to a streamed text/event-stream body', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => { + stream.end(`data: {"next":"https://${upstreamHost}/data/page/next"}\n\n`); + }); + + expect(res.body).toBe(`data: {"next":"https://${localHost}/data/page/next"}\n\n`); + expect(res.getHeader(PROXY_HEADER)).toBe(1); + }); + + it('forwards the upstream status code and message', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }, 503, 'Service Unavailable'); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end('data: down\n\n')); + + expect(res.statusCode).toBe(503); + expect(res.statusMessage).toBe('Service Unavailable'); + }); + + it('rewrites a value split across two chunks', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => { + stream.write(`data: https://mi.com`); + stream.end(`pany.com/data/page/next\n\n`); + }); + + expect(res.body).toBe(`data: https://${localHost}/data/page/next\n\n`); + }); + + it('forwards a complete event as soon as it arrives, without waiting for the stream to end', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.write(`data: https://${upstreamHost}/a\n\n`)); + + expect(res.body).toBe(`data: https://${localHost}/a\n\n`); + expect(res.finished).toBe(false); + + proxyRes.end(); + }); + + it('keeps multi-byte characters intact when a chunk splits them', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + const payload = Buffer.from('data: héllo\n\n', 'utf8'); + + await runStream(proxyRes, res, (stream) => { + // Split inside the two-byte "é". + stream.write(payload.subarray(0, 7)); + stream.end(payload.subarray(7)); + }); + + expect(res.body).toBe('data: héllo\n\n'); + }); + + it('leaves a compressed stream untouched', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream', 'content-encoding': 'gzip' }); + const res = makeRes(); + const payload = `data: https://${upstreamHost}/a\n\n`; + + await runStream(proxyRes, res, (stream) => stream.end(payload)); + + expect(res.body).toBe(payload); + }); + + it('leaves a non-textual stream untouched', async () => { + const proxyRes = makeProxyRes({ + 'content-type': 'application/octet-stream', + 'transfer-encoding': 'chunked', + 'x-accel-buffering': 'no', + }); + const res = makeRes(); + const payload = `binary https://${upstreamHost}/a`; + + await runStream(proxyRes, res, (stream) => stream.end(payload)); + + expect(res.body).toBe(payload); + }); + + it('pipes through when no interceptor is supplied', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + const payload = `data: https://${upstreamHost}/a\n\n`; + + await streamResponseInterceptor()( + proxyRes as unknown as IncomingMessage, + {} as IncomingMessage, + res as unknown as ServerResponse, + ); + + proxyRes.end(payload); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(res.body).toBe(payload); + }); + + it('drops the upstream content-length when the body is rewritten', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/plain', 'content-length': '42' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end(`https://${upstreamHost}/a\n`)); + + expect(res.getHeader('content-length')).toBeUndefined(); + expect(res.getHeader('content-type')).toBe('text/plain'); + }); + + it('keeps the upstream content-length when the body is passed through', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'application/octet-stream', 'content-length': '42' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end('binary')); + + expect(res.getHeader('content-length')).toBe('42'); + }); + + it('skips headers the upstream did not set', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream', 'x-missing': undefined }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end('data: ok\n\n')); + + expect('x-missing' in res.headers).toBe(false); + }); + + it.each(['application/json', 'application/json; charset=utf-8', 'application/xml'])( + 'applies the interceptor to a chunked %s body', + async (contentType) => { + const proxyRes = makeProxyRes({ + 'content-type': contentType, + 'transfer-encoding': 'chunked', + 'x-accel-buffering': 'no', + }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end(`{"next":"https://${upstreamHost}/a"}\n`)); + + expect(res.body).toBe(`{"next":"https://${localHost}/a"}\n`); + }, + ); + + it('pauses the upstream stream until the client response drains', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes({ writeReturns: false }); + + await runStream(proxyRes, res, (stream) => stream.write(`data: https://${upstreamHost}/a\n\n`)); + + expect(proxyRes.isPaused()).toBe(true); + + res.drain(); + + expect(proxyRes.isPaused()).toBe(false); + + proxyRes.end(); + }); + + it('flushes a line that never breaks once it exceeds the pending limit', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + const long = `https://${upstreamHost}/${'a'.repeat(64 * 1024)}`; + + await runStream(proxyRes, res, (stream) => stream.write(long)); + + expect(res.body).toBe(long.replace(upstreamHost, localHost)); + + proxyRes.end(); + }); +});